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__; +};