diff --git a/.eslintignore.web b/.eslintignore.web
index 61de1aa6..746e09a7 100644
--- a/.eslintignore.web
+++ b/.eslintignore.web
@@ -8,4 +8,5 @@ vite-env.d.ts
coverage/
src/proto/**/*
cypress/e2e/
-cypress/support/
\ No newline at end of file
+cypress/support/
+deploy/*.test.ts
\ No newline at end of file
diff --git a/deploy/server.test.ts b/deploy/server.test.ts
new file mode 100644
index 00000000..7ad23a7e
--- /dev/null
+++ b/deploy/server.test.ts
@@ -0,0 +1,524 @@
+/** @jest-environment node */
+
+import { jest } from '@jest/globals';
+import { load } from 'cheerio';
+
+const mockBunFetch = jest.fn();
+const mockReadFileSync = jest.fn();
+
+jest.mock('pino', () => () => ({
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ debug: jest.fn(),
+}));
+
+jest.mock('fs', () => ({
+ ...jest.requireActual('fs'),
+ readFileSync: (...args: unknown[]) => mockReadFileSync(...args),
+}));
+
+jest.mock(
+ 'bun',
+ () => ({
+ fetch: (...args: unknown[]) => mockBunFetch(...args),
+ }),
+ { virtual: true }
+);
+
+describe('deploy/server', () => {
+ const htmlTemplate = `
+
+
+ AppFlowy
+
+
+
+
+
+
+
+ `;
+
+ let createServer: typeof import('./server').createServer;
+ let start: typeof import('./server').start;
+
+ const makeRequest = (path: string, init: RequestInit = {}) => {
+ const headers = new Headers(init.headers);
+ headers.set('host', 'appflowy.test');
+
+ return new Request(`https://appflowy.test${path}`, {
+ ...init,
+ method: init.method ?? 'GET',
+ headers,
+ });
+ };
+
+ const getHtml = async (response: Response) => await response.text();
+
+ const extractPublishError = (html: string) => {
+ const $ = load(html);
+ const scriptContent = $('#appflowy-publish-error').html();
+
+ if (!scriptContent) return undefined;
+
+ const match = scriptContent.match(/window.__APPFLOWY_PUBLISH_ERROR__ = (.*);/);
+ if (!match) return undefined;
+
+ return JSON.parse(match[1]);
+ };
+
+ beforeAll(async () => {
+ process.env.NODE_ENV = 'test';
+ process.env.APPFLOWY_BASE_URL = 'https://api.example.com';
+ const globalAny = global as typeof globalThis & { btoa?: (value: string) => string };
+
+ if (!globalAny.btoa) {
+ globalAny.btoa = (value: string) => Buffer.from(value, 'binary').toString('base64');
+ }
+
+ ({ createServer, start } = await import('./server'));
+ });
+
+ beforeEach(() => {
+ mockBunFetch.mockReset();
+ mockReadFileSync.mockReset();
+ mockReadFileSync.mockReturnValue(htmlTemplate);
+ });
+
+ it('redirects "/" to /app without hitting the API', async () => {
+ const response = await createServer(makeRequest('/'));
+
+ expect(response.status).toBe(302);
+ expect(response.headers.get('Location')).toBe('/app');
+ expect(mockBunFetch).not.toHaveBeenCalled();
+ expect(mockReadFileSync).not.toHaveBeenCalled();
+ });
+
+ it('renders marketing routes with custom metadata', async () => {
+ const response = await createServer(makeRequest('/login'));
+ const html = await getHtml(response);
+ const $ = load(html);
+
+ expect(response.status).toBe(200);
+ expect($('title').text()).toBe('Login | AppFlowy');
+ expect($('meta[name="description"]').attr('content')).toBe('Login to AppFlowy');
+ expect(mockBunFetch).not.toHaveBeenCalled();
+ expect(mockReadFileSync).toHaveBeenCalled();
+ });
+
+ it('renders /after-payment route with payment metadata', async () => {
+ const response = await createServer(makeRequest('/after-payment'));
+ const html = await getHtml(response);
+ const $ = load(html);
+
+ expect(response.status).toBe(200);
+ expect($('title').text()).toBe('Payment Success | AppFlowy');
+ expect($('meta[name="description"]').attr('content')).toBe('Payment success on AppFlowy');
+ });
+
+ it('renders /app route without custom metadata', async () => {
+ const response = await createServer(makeRequest('/app'));
+ const html = await getHtml(response);
+ const $ = load(html);
+
+ expect(response.status).toBe(200);
+ expect($('title').text()).toBe('AppFlowy');
+ expect(mockBunFetch).not.toHaveBeenCalled();
+ });
+
+ it('redirects namespace-only requests when publish info exists', async () => {
+ mockBunFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ data: { info: { namespace: 'space', publish_name: 'hello world' } },
+ }),
+ });
+
+ const response = await createServer(makeRequest('/space'));
+
+ expect(response.status).toBe(302);
+ expect(response.headers.get('Location')).toBe('/space/hello%20world');
+ expect(mockBunFetch).toHaveBeenCalledWith(
+ 'https://api.example.com/api/workspace/published/space',
+ { verbose: false }
+ );
+ expect(mockReadFileSync).not.toHaveBeenCalled();
+ });
+
+ it('injects error payload when namespace has no default publish page', async () => {
+ mockBunFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({ data: {} }),
+ });
+
+ const encodedNamespace = encodeURIComponent('foo');
+ const response = await createServer(makeRequest(`/${encodedNamespace}`));
+ const html = await getHtml(response);
+ const payload = extractPublishError(html);
+
+ expect(payload).toMatchObject({
+ code: 'NO_DEFAULT_PAGE',
+ namespace: 'foo',
+ });
+
+ const scriptText = load(html)('#appflowy-publish-error').html()!;
+ expect(scriptText).toContain('\\u003cbar\\u003e');
+ expect(scriptText).not.toContain('');
+ });
+
+ it('renders publish pages when metadata exists', async () => {
+ mockBunFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ code: 0,
+ data: {
+ view: {
+ name: 'Doc',
+ icon: { ty: 0, value: '๐' },
+ extra: JSON.stringify({ cover: { type: 'custom', value: 'https://img/pic.png' } }),
+ },
+ },
+ }),
+ });
+
+ const response = await createServer(makeRequest('/space/doc'));
+ const html = await getHtml(response);
+ const $ = load(html);
+
+ expect($('title').text()).toContain('Doc | AppFlowy');
+ expect(extractPublishError(html)).toBeUndefined();
+ expect(mockBunFetch).toHaveBeenCalledWith(
+ 'https://api.example.com/api/workspace/v1/published/space/doc',
+ { verbose: false }
+ );
+ });
+
+ it('sets emoji favicon when icon ty=0', async () => {
+ mockBunFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ code: 0,
+ data: {
+ view: {
+ name: 'Doc',
+ icon: { ty: 0, value: '๐' },
+ },
+ },
+ }),
+ });
+
+ const response = await createServer(makeRequest('/space/doc'));
+ const html = await getHtml(response);
+ const $ = load(html);
+
+ expect($('link[rel="icon"]').attr('href')).toContain('emoji_u1f600.svg');
+ });
+
+ it('sets custom icon favicon when icon ty=2', async () => {
+ const iconValue = JSON.stringify({
+ iconContent: '',
+ color: '0xFF0000FF',
+ });
+
+ mockBunFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ code: 0,
+ data: {
+ view: {
+ name: 'Doc',
+ icon: { ty: 2, value: iconValue },
+ },
+ },
+ }),
+ });
+
+ const response = await createServer(makeRequest('/space/doc'));
+ const html = await getHtml(response);
+ const $ = load(html);
+
+ expect($('link[rel="icon"]').attr('href')).toContain('data:image/svg+xml;base64,');
+ expect($('link[rel="icon"]').attr('type')).toBe('image/svg+xml');
+ });
+
+ it('handles invalid icon JSON gracefully', async () => {
+ mockBunFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ code: 0,
+ data: {
+ view: {
+ name: 'Doc',
+ icon: { ty: 2, value: 'invalid json' },
+ },
+ },
+ }),
+ });
+
+ const response = await createServer(makeRequest('/space/doc'));
+ const html = await getHtml(response);
+ const $ = load(html);
+
+ expect(response.status).toBe(200);
+ expect($('title').text()).toContain('Doc');
+ });
+
+ it('uses built_in cover image path', async () => {
+ mockBunFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ code: 0,
+ data: {
+ view: {
+ name: 'Doc',
+ extra: JSON.stringify({ cover: { type: 'built_in', value: '1' } }),
+ },
+ },
+ }),
+ });
+
+ const response = await createServer(makeRequest('/space/doc'));
+ const html = await getHtml(response);
+ const $ = load(html);
+
+ expect($('meta[property="og:image"]').attr('content')).toBe('/covers/m_cover_image_1.png');
+ });
+
+ it('uses unsplash cover image', async () => {
+ mockBunFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ code: 0,
+ data: {
+ view: {
+ name: 'Doc',
+ extra: JSON.stringify({ cover: { type: 'unsplash', value: 'https://unsplash.com/photo.jpg' } }),
+ },
+ },
+ }),
+ });
+
+ const response = await createServer(makeRequest('/space/doc'));
+ const html = await getHtml(response);
+ const $ = load(html);
+
+ expect($('meta[property="og:image"]').attr('content')).toBe('https://unsplash.com/photo.jpg');
+ });
+
+ it('handles invalid extra JSON gracefully', async () => {
+ mockBunFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ code: 0,
+ data: {
+ view: {
+ name: 'Doc',
+ extra: 'not valid json',
+ },
+ },
+ }),
+ });
+
+ const response = await createServer(makeRequest('/space/doc'));
+ const html = await getHtml(response);
+ const $ = load(html);
+
+ expect(response.status).toBe(200);
+ expect($('title').text()).toContain('Doc');
+ expect($('meta[property="og:image"]').attr('content')).toBe('/og-image.png');
+ });
+
+ it('captures HTTP errors as FETCH_ERROR', async () => {
+ mockBunFetch.mockResolvedValue({
+ ok: false,
+ status: 500,
+ });
+
+ const response = await createServer(makeRequest('/space/doc'));
+ const html = await getHtml(response);
+ const payload = extractPublishError(html);
+
+ expect(payload?.code).toBe('FETCH_ERROR');
+ expect(payload?.detail).toContain('HTTP error');
+ });
+
+ it('injects publish view lookup errors when metadata request fails validation', async () => {
+ mockBunFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ code: 404,
+ data: { message: 'not found' },
+ }),
+ });
+
+ const response = await createServer(makeRequest('/team/home'));
+ const html = await getHtml(response);
+ const payload = extractPublishError(html);
+
+ expect(payload).toMatchObject({
+ code: 'PUBLISH_VIEW_LOOKUP_FAILED',
+ namespace: 'team',
+ publishName: 'home',
+ });
+ });
+
+ it('marks fallback renders when metadata payload is empty', async () => {
+ mockBunFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ code: 0,
+ data: null,
+ }),
+ });
+
+ const response = await createServer(makeRequest('/org/page'));
+ const html = await getHtml(response);
+ const payload = extractPublishError(html);
+
+ expect(payload).toMatchObject({
+ code: 'UNKNOWN_FALLBACK',
+ namespace: 'org',
+ publishName: 'page',
+ });
+ });
+
+ it('captures fetch failures as FETCH_ERROR', async () => {
+ mockBunFetch.mockRejectedValue(new Error('network down'));
+
+ const response = await createServer(makeRequest('/alpha/doc'));
+ const html = await getHtml(response);
+ const payload = extractPublishError(html);
+
+ expect(payload?.code).toBe('FETCH_ERROR');
+ expect(payload?.detail).toContain('network down');
+ });
+
+ it('returns 405 for non-GET methods', async () => {
+ const response = await createServer(
+ makeRequest('/namespace/page', { method: 'POST', body: 'data' })
+ );
+
+ expect(response.status).toBe(405);
+ });
+
+ it('start() wires Bun.serve to createServer', () => {
+ const previousBun = (globalThis as unknown as { Bun?: unknown }).Bun;
+ const serve = jest.fn();
+
+ (globalThis as unknown as { Bun?: unknown }).Bun = { serve };
+
+ start();
+
+ expect(serve).toHaveBeenCalledTimes(1);
+ const args = serve.mock.calls[0][0];
+ expect(args.port).toBe(3000);
+ expect(args.fetch).toBe(createServer);
+
+ const errorResponse = args.error(new Error('boom'));
+ expect(errorResponse.status).toBe(500);
+
+ if (previousBun) {
+ (globalThis as unknown as { Bun?: unknown }).Bun = previousBun;
+ } else {
+ delete (globalThis as unknown as { Bun?: unknown }).Bun;
+ }
+ });
+
+ it('start() exits process on Bun.serve error', () => {
+ const previousBun = (globalThis as unknown as { Bun?: unknown }).Bun;
+ const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => undefined as never);
+
+ (globalThis as unknown as { Bun?: unknown }).Bun = {
+ serve: () => {
+ throw new Error('serve failed');
+ },
+ };
+
+ start();
+
+ expect(mockExit).toHaveBeenCalledWith(1);
+
+ mockExit.mockRestore();
+ if (previousBun) {
+ (globalThis as unknown as { Bun?: unknown }).Bun = previousBun;
+ } else {
+ delete (globalThis as unknown as { Bun?: unknown }).Bun;
+ }
+ });
+
+ it('creates meta tags that do not exist in template', async () => {
+ const minimalTemplate = `
+
+
+ AppFlowy
+
+
+
+ `;
+ mockReadFileSync.mockReturnValue(minimalTemplate);
+
+ mockBunFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ code: 0,
+ data: {
+ view: { name: 'Doc' },
+ },
+ }),
+ });
+
+ const response = await createServer(makeRequest('/space/doc'));
+ const html = await getHtml(response);
+ const $ = load(html);
+
+ expect($('meta[property="og:title"]').attr('content')).toBe('Doc | AppFlowy');
+ expect($('meta[property="og:description"]').length).toBe(1);
+ expect($('meta[name="twitter:card"]').attr('content')).toBe('summary_large_image');
+ });
+
+ it('handles view without name gracefully', async () => {
+ mockBunFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ code: 0,
+ data: {
+ view: {},
+ },
+ }),
+ });
+
+ const response = await createServer(makeRequest('/space/doc'));
+ const html = await getHtml(response);
+ const $ = load(html);
+
+ expect(response.status).toBe(200);
+ expect($('title').text()).toBe('AppFlowy');
+ });
+
+ it('handles ARGB color without alpha correctly', async () => {
+ const iconValue = JSON.stringify({
+ iconContent: '',
+ color: '#FF0000',
+ });
+
+ mockBunFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ code: 0,
+ data: {
+ view: {
+ name: 'Doc',
+ icon: { ty: 2, value: iconValue },
+ },
+ },
+ }),
+ });
+
+ const response = await createServer(makeRequest('/space/doc'));
+ const html = await getHtml(response);
+ const $ = load(html);
+
+ expect($('link[rel="icon"]').attr('href')).toContain('data:image/svg+xml;base64,');
+ });
+});
diff --git a/deploy/server.ts b/deploy/server.ts
index 64cfe06b..ffe2ff26 100644
--- a/deploy/server.ts
+++ b/deploy/server.ts
@@ -12,9 +12,31 @@ const indexPath = path.join(distDir, 'index.html');
const baseURL = process.env.APPFLOWY_BASE_URL as string;
const defaultSite = 'https://appflowy.com';
+type PublishErrorPayload = {
+ code: 'NO_DEFAULT_PAGE' | 'PUBLISH_VIEW_LOOKUP_FAILED' | 'FETCH_ERROR' | 'UNKNOWN_FALLBACK';
+ message: string;
+ namespace?: string;
+ publishName?: string;
+ response?: unknown;
+ detail?: string;
+};
+
+const appendPublishErrorScript = ($: CheerioAPI, error: PublishErrorPayload) => {
+ const serialized = JSON.stringify(error)
+ .replace(//g, '\\u003e');
+
+ $('head').append(
+ ``
+ );
+};
+
const setOrUpdateMetaTag = ($: CheerioAPI, selector: string, attribute: string, content: string) => {
if ($(selector).length === 0) {
- $('head').append(``);
+ const valueMatch = selector.match(/\[.*?="([^"]+)"\]/);
+ const value = valueMatch?.[1] ?? '';
+
+ $('head').append(``);
} else {
$(selector).attr('content', content);
}
@@ -58,27 +80,23 @@ const fetchMetaData = async (namespace: string, publishName?: string) => {
}
logger.debug(`Fetching meta data from ${url}`);
- try {
- const response = await fetch(url, {
- verbose: false,
- });
- if (!response.ok) {
- throw new Error(`HTTP error! Status: ${response.status}`);
- }
+ const response = await fetch(url, {
+ verbose: false,
+ });
- const data = await response.json();
-
- logger.debug(`Fetched meta data from ${url}: ${JSON.stringify(data)}`);
-
- return data;
- } catch (error) {
- logger.error(`Failed to fetch meta data from ${url}: ${error}`);
- return null;
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
}
+
+ const data = await response.json();
+
+ logger.debug(`Fetched meta data from ${url}: ${JSON.stringify(data)}`);
+
+ return data;
};
-const createServer = async (req: Request) => {
+export const createServer = async (req: Request) => {
const timer = logRequestTimer(req);
const reqUrl = new URL(req.url);
const hostname = req.headers.get('host');
@@ -122,7 +140,9 @@ const createServer = async (req: Request) => {
});
}
- const [namespace, publishName] = reqUrl.pathname.slice(1).split('/');
+ const [rawNamespace, rawPublishName] = reqUrl.pathname.slice(1).split('/');
+ const namespace = decodeURIComponent(rawNamespace);
+ const publishName = rawPublishName ? decodeURIComponent(rawPublishName) : undefined;
logger.debug(`Namespace: ${namespace}, Publish Name: ${publishName}`);
@@ -139,6 +159,7 @@ const createServer = async (req: Request) => {
let metaData;
let redirectAttempted = false;
+ let publishError: PublishErrorPayload | null = null;
try {
const data = await fetchMetaData(namespace, publishName);
@@ -150,6 +171,13 @@ const createServer = async (req: Request) => {
logger.error(
`Publish view lookup failed for namespace="${namespace}" publishName="${publishName}" response=${JSON.stringify(data)}`
);
+ publishError = {
+ code: 'PUBLISH_VIEW_LOOKUP_FAILED',
+ message: 'The page you\'re looking for doesn\'t exist or has been unpublished.',
+ namespace,
+ publishName,
+ response: data,
+ };
}
} else {
const publishInfo = data?.data?.info;
@@ -168,10 +196,23 @@ const createServer = async (req: Request) => {
});
} else {
logger.warn(`Namespace "${namespace}" has no default publish page. response=${JSON.stringify(data)}`);
+ publishError = {
+ code: 'NO_DEFAULT_PAGE',
+ message: 'This workspace doesn\'t have a default published page. Please check the URL or contact the workspace owner.',
+ namespace,
+ response: data,
+ };
}
}
} catch (error) {
logger.error(`Error fetching meta data: ${error}`);
+ publishError = {
+ code: 'FETCH_ERROR',
+ message: 'Unable to load this page. Please check your internet connection and try again.',
+ namespace,
+ publishName,
+ detail: error instanceof Error ? error.message : String(error),
+ };
}
const htmlData = fs.readFileSync(indexPath, 'utf8');
@@ -236,6 +277,14 @@ const createServer = async (req: Request) => {
logger.warn(
`Serving fallback landing page for namespace="${namespace}" publishName="${publishName ?? ''}". redirectAttempted=${redirectAttempted}`
);
+ if (!publishError) {
+ publishError = {
+ code: 'UNKNOWN_FALLBACK',
+ message: 'We couldn\'t load this page. Please try again later.',
+ namespace,
+ publishName,
+ };
+ }
}
$('title').text(title);
@@ -254,6 +303,10 @@ const createServer = async (req: Request) => {
setOrUpdateMetaTag($, 'meta[name="twitter:image"]', 'name', image);
setOrUpdateMetaTag($, 'meta[name="twitter:site"]', 'name', '@appflowy');
+ if (publishError) {
+ appendPublishErrorScript($, publishError);
+ }
+
timer();
return new Response($.html(), {
headers: { 'Content-Type': 'text/html' },
@@ -269,7 +322,7 @@ declare const Bun: {
serve: (options: { port: number; fetch: typeof createServer; error: (err: Error) => Response }) => void;
};
-const start = () => {
+export const start = () => {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
Bun.serve({
@@ -288,9 +341,9 @@ const start = () => {
}
};
-start();
-
-export { };
+if (process.env.NODE_ENV !== 'test') {
+ start();
+}
function getIconBase64(svgText: string, color: string) {
let newSvgText = svgText.replace(/fill="[^"]*"/g, ``);
diff --git a/src/components/error/NotFound.tsx b/src/components/error/NotFound.tsx
index 890f4e1d..941d95a2 100644
--- a/src/components/error/NotFound.tsx
+++ b/src/components/error/NotFound.tsx
@@ -1,10 +1,13 @@
+import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ReactComponent as NoAccessLogo } from '@/assets/icons/no_access.svg';
import LandingPage from '@/components/_shared/landing-page/LandingPage';
+import { getPublishError } from '@/utils/publish-error';
const NotFound = () => {
const { t } = useTranslation();
+ const publishError = useMemo(() => getPublishError(), []);
return (
@@ -12,13 +15,39 @@ const NotFound = () => {
Logo={NoAccessLogo}
title={t('landingPage.noAccess.title')}
description={
-
- {t('publish.createWithAppFlowy')}
-
-
{t('publish.fastWithAI')}
-
{t('publish.tryItNow')}
+ <>
+ {publishError && (
+
+
{t('landingPage.noAccess.title')}
+
{publishError.message}
+ {publishError.detail && (
+
{publishError.detail}
+ )}
+ {(publishError.namespace || publishError.publishName) && (
+
+ {publishError.namespace && (
+ <>
+ Namespace: {publishError.namespace}
+ >
+ )}
+ {publishError.publishName && (
+ <>
+ {' '}
+ ยท Publish page: {publishError.publishName}
+ >
+ )}
+
+ )}
+
+ )}
+
+ {t('publish.createWithAppFlowy')}
+
+
{t('publish.fastWithAI')}
+
{t('publish.tryItNow')}
+
-
+ >
}
primaryAction={{
onClick: () => window.open('https://appflowy.com/download', '_self'),
diff --git a/src/utils/publish-error.ts b/src/utils/publish-error.ts
new file mode 100644
index 00000000..9cfe76c2
--- /dev/null
+++ b/src/utils/publish-error.ts
@@ -0,0 +1,22 @@
+export type PublishErrorPayload = {
+ code: 'NO_DEFAULT_PAGE' | 'PUBLISH_VIEW_LOOKUP_FAILED' | 'FETCH_ERROR' | 'UNKNOWN_FALLBACK';
+ message: string;
+ namespace?: string;
+ publishName?: string;
+ response?: unknown;
+ detail?: string;
+};
+
+declare global {
+ interface Window {
+ __APPFLOWY_PUBLISH_ERROR__?: PublishErrorPayload;
+ }
+}
+
+export const getPublishError = (): PublishErrorPayload | undefined => {
+ if (typeof window === 'undefined') {
+ return undefined;
+ }
+
+ return window.__APPFLOWY_PUBLISH_ERROR__;
+};