From 3de8aaa565d68f442aacb08d12b168ddb4f96500 Mon Sep 17 00:00:00 2001 From: "Nathan.fooo" <86001920+appflowy@users.noreply.github.com> Date: Thu, 27 Nov 2025 16:02:54 +0800 Subject: [PATCH] refactor: server ts (#179) * refactor: server ts * refactor: add more tests --- .eslintignore.web | 4 +- deploy/api.ts | 31 ++ deploy/config.ts | 8 + deploy/html.ts | 192 +++++++ deploy/logger.ts | 16 + deploy/publish-error.ts | 14 + deploy/routes.ts | 140 +++++ deploy/server.test.ts | 868 ++++++++++++++++++++++++++++++ deploy/server.ts | 294 +--------- src/components/error/NotFound.tsx | 41 +- src/utils/publish-error.ts | 22 + tsconfig.json | 2 +- 12 files changed, 1353 insertions(+), 279 deletions(-) create mode 100644 deploy/api.ts create mode 100644 deploy/config.ts create mode 100644 deploy/html.ts create mode 100644 deploy/logger.ts create mode 100644 deploy/publish-error.ts create mode 100644 deploy/routes.ts create mode 100644 deploy/server.test.ts create mode 100644 src/utils/publish-error.ts diff --git a/.eslintignore.web b/.eslintignore.web index 61de1aa6..881aa7ec 100644 --- a/.eslintignore.web +++ b/.eslintignore.web @@ -8,4 +8,6 @@ vite-env.d.ts coverage/ src/proto/**/* cypress/e2e/ -cypress/support/ \ No newline at end of file +cypress/support/ +deploy/*.test.ts +deploy/*.integration.test.ts \ No newline at end of file diff --git a/deploy/api.ts b/deploy/api.ts new file mode 100644 index 00000000..e939a906 --- /dev/null +++ b/deploy/api.ts @@ -0,0 +1,31 @@ +// @ts-expect-error no bun +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +import { fetch } from 'bun'; + +import { baseURL } from './config'; +import { logger } from './logger'; + +export const fetchPublishMetadata = async (namespace: string, publishName?: string) => { + const encodedNamespace = encodeURIComponent(namespace); + let url = `${baseURL}/api/workspace/published/${encodedNamespace}`; + + if (publishName) { + url = `${baseURL}/api/workspace/v1/published/${encodedNamespace}/${encodeURIComponent(publishName)}`; + } + + logger.debug(`Fetching meta data from ${url}`); + + const response = await fetch(url, { + verbose: false, + }); + + 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; +}; diff --git a/deploy/config.ts b/deploy/config.ts new file mode 100644 index 00000000..eb8e0b4a --- /dev/null +++ b/deploy/config.ts @@ -0,0 +1,8 @@ +import path from 'path'; + +export const distDir = path.join(__dirname, 'dist'); +export const indexPath = path.join(distDir, 'index.html'); +export const baseURL = process.env.APPFLOWY_BASE_URL as string; +// Used when a namespace is requested without /publishName; users get redirected to the +// public marketing site if the namespace segment is empty (see redirect in publish route). +export const defaultSite = 'https://appflowy.com'; diff --git a/deploy/html.ts b/deploy/html.ts new file mode 100644 index 00000000..5ebee921 --- /dev/null +++ b/deploy/html.ts @@ -0,0 +1,192 @@ +import * as fs from 'fs'; +import { type CheerioAPI, load } from 'cheerio'; + +import { indexPath } from './config'; +import { logger } from './logger'; +import { type PublishErrorPayload } from './publish-error'; + +const DEFAULT_DESCRIPTION = 'Write, share, and publish docs quickly on AppFlowy.\nGet started for free.'; +const DEFAULT_IMAGE = '/og-image.png'; +const DEFAULT_FAVICON = '/appflowy.ico'; + +const MARKETING_META: Record< + string, + { + title?: string; + description?: string; + } +> = { + '/after-payment': { + title: 'Payment Success | AppFlowy', + description: 'Payment success on AppFlowy', + }, + '/login': { + title: 'Login | AppFlowy', + description: 'Login to AppFlowy', + }, +}; + +export const renderMarketingPage = (pathname: string) => { + const htmlData = fs.readFileSync(indexPath, 'utf8'); + const $ = load(htmlData); + const meta = MARKETING_META[pathname]; + + if (meta?.title) { + $('title').text(meta.title); + } + + if (meta?.description) { + setOrUpdateMetaTag($, 'meta[name="description"]', 'name', meta.description); + } + + return $.html(); +}; + +type PublishViewMeta = { + name?: string; + icon?: { + ty: number; + value: string; + }; + extra?: string; +}; + +export type RenderPublishPageOptions = { + hostname: string | null; + pathname: string; + metaData?: { + view?: PublishViewMeta; + }; + publishError?: PublishErrorPayload | null; +}; + +export const renderPublishPage = ({ hostname, pathname, metaData, publishError }: RenderPublishPageOptions) => { + const htmlData = fs.readFileSync(indexPath, 'utf8'); + const $ = load(htmlData); + + const description = DEFAULT_DESCRIPTION; + let title = 'AppFlowy'; + const url = `https://${hostname ?? ''}${pathname}`; + let image = DEFAULT_IMAGE; + let favicon = DEFAULT_FAVICON; + + try { + if (metaData && metaData.view) { + const view = metaData.view; + const emoji = view.icon?.ty === 0 && view.icon?.value; + const icon = view.icon?.ty === 2 && view.icon?.value; + const titleList: string[] = []; + + if (emoji) { + const emojiCode = emoji.codePointAt(0)?.toString(16); + const baseUrl = 'https://raw.githubusercontent.com/googlefonts/noto-emoji/main/svg/emoji_u'; + + if (emojiCode) { + favicon = `${baseUrl}${emojiCode}.svg`; + } + } else if (icon) { + try { + const { iconContent, color } = JSON.parse(icon); + + favicon = getIconBase64(iconContent, color); + $('link[rel="icon"]').attr('type', 'image/svg+xml'); + } catch (_) { + // ignore icon parsing errors + } + } + + if (view.name) { + titleList.push(view.name); + titleList.push('|'); + } + + titleList.push('AppFlowy'); + title = titleList.join(' '); + + try { + const cover = view.extra ? JSON.parse(view.extra)?.cover : null; + + if (cover) { + if (['unsplash', 'custom'].includes(cover.type)) { + image = cover.value; + } else if (cover.type === 'built_in') { + image = `/covers/m_cover_image_${cover.value}.png`; + } + } + } catch (_) { + // ignore cover parsing errors + } + } + } catch (error) { + logger.error(`Error injecting meta data: ${error}`); + } + + $('title').text(title); + $('link[rel="icon"]').attr('href', favicon); + $('link[rel="canonical"]').attr('href', url); + setOrUpdateMetaTag($, 'meta[name="description"]', 'name', description); + setOrUpdateMetaTag($, 'meta[property="og:title"]', 'property', title); + setOrUpdateMetaTag($, 'meta[property="og:description"]', 'property', description); + setOrUpdateMetaTag($, 'meta[property="og:image"]', 'property', image); + setOrUpdateMetaTag($, 'meta[property="og:url"]', 'property', url); + setOrUpdateMetaTag($, 'meta[property="og:site_name"]', 'property', 'AppFlowy'); + setOrUpdateMetaTag($, 'meta[property="og:type"]', 'property', 'website'); + setOrUpdateMetaTag($, 'meta[name="twitter:card"]', 'name', 'summary_large_image'); + setOrUpdateMetaTag($, 'meta[name="twitter:title"]', 'name', title); + setOrUpdateMetaTag($, 'meta[name="twitter:description"]', 'name', description); + setOrUpdateMetaTag($, 'meta[name="twitter:image"]', 'name', image); + setOrUpdateMetaTag($, 'meta[name="twitter:site"]', 'name', '@appflowy'); + + if (publishError) { + appendPublishErrorScript($, publishError); + } + + return $.html(); +}; + +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) { + const valueMatch = selector.match(/\[.*?="([^"]+)"\]/); + const value = valueMatch?.[1] ?? ''; + + $('head').append(``); + } else { + $(selector).attr('content', content); + } +}; + +const getIconBase64 = (svgText: string, color: string) => { + let newSvgText = svgText.replace(/fill="[^"]*"/g, ``); + + newSvgText = newSvgText.replace(' { + const hex = color.replace(/^#|0x/, ''); + const hasAlpha = hex.length === 8; + + if (!hasAlpha) { + return color.replace('0x', '#'); + } + + const r = parseInt(hex.slice(2, 4), 16); + const g = parseInt(hex.slice(4, 6), 16); + const b = parseInt(hex.slice(6, 8), 16); + const a = hasAlpha ? parseInt(hex.slice(0, 2), 16) / 255 : 1; + + return `rgba(${r}, ${g}, ${b}, ${a})`; +}; diff --git a/deploy/logger.ts b/deploy/logger.ts new file mode 100644 index 00000000..d2c5dce3 --- /dev/null +++ b/deploy/logger.ts @@ -0,0 +1,16 @@ +import pino from 'pino'; + +const prettyTransport = { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'SYS:standard', + }, +}; + +export const logger = pino({ + transport: process.env.NODE_ENV === 'production' ? undefined : prettyTransport, + level: process.env.LOG_LEVEL || 'info', +}); + +// Request timing logic removed โ€“ we only keep the shared logger here. diff --git a/deploy/publish-error.ts b/deploy/publish-error.ts new file mode 100644 index 00000000..02eba7c9 --- /dev/null +++ b/deploy/publish-error.ts @@ -0,0 +1,14 @@ +export type PublishErrorCode = + | 'NO_DEFAULT_PAGE' + | 'PUBLISH_VIEW_LOOKUP_FAILED' + | 'FETCH_ERROR' + | 'UNKNOWN_FALLBACK'; + +export type PublishErrorPayload = { + code: PublishErrorCode; + message: string; + namespace?: string; + publishName?: string; + response?: unknown; + detail?: string; +}; diff --git a/deploy/routes.ts b/deploy/routes.ts new file mode 100644 index 00000000..e2bab6fe --- /dev/null +++ b/deploy/routes.ts @@ -0,0 +1,140 @@ +import { defaultSite } from './config'; +import { fetchPublishMetadata } from './api'; +import { renderMarketingPage, renderPublishPage } from './html'; +import { logger } from './logger'; +import { type PublishErrorPayload } from './publish-error'; +import { type RequestContext } from './server'; + +type RouteHandler = (context: RequestContext) => Promise; + +const MARKETING_PATHS = ['/after-payment', '/login', '/as-template', '/app', '/accept-invitation', '/import']; + +const marketingRoute = async ({ req, url }: RequestContext) => { + if (req.method !== 'GET') { + return; + } + + if (MARKETING_PATHS.some(path => url.pathname.startsWith(path))) { + const html = renderMarketingPage(url.pathname); + + return new Response(html, { + headers: { 'Content-Type': 'text/html' }, + }); + } +}; + +const publishRoute = async ({ req, url, hostname }: RequestContext) => { + if (req.method !== 'GET') { + return; + } + + const [rawNamespace, rawPublishName] = url.pathname.slice(1).split('/'); + let namespace: string; + let publishName: string | undefined; + + try { + namespace = rawNamespace ? decodeURIComponent(rawNamespace) : ''; + publishName = rawPublishName ? decodeURIComponent(rawPublishName) : undefined; + } catch { + return new Response('Not Found', { status: 404 }); + } + + if (namespace === '') { + return new Response(null, { + status: 302, + headers: { Location: defaultSite }, + }); + } + + let metaData; + let redirectAttempted = false; + let publishError: PublishErrorPayload | null = null; + + try { + const data = await fetchPublishMetadata(namespace, publishName); + + if (publishName) { + if (data && data.code === 0) { + metaData = data.data; + } else { + 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; + + if (publishInfo?.namespace && publishInfo?.publish_name) { + const newURL = `/${encodeURIComponent(publishInfo.namespace)}/${encodeURIComponent(publishInfo.publish_name)}`; + + logger.debug(`Redirecting to default page in: ${JSON.stringify(publishInfo)}`); + redirectAttempted = true; + + return new Response(null, { + status: 302, + headers: { Location: newURL }, + }); + } 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), + }; + } + + if (!metaData) { + 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, + }; + } + } + + const html = renderPublishPage({ + hostname, + pathname: url.pathname, + metaData, + publishError, + }); + + return new Response(html, { + headers: { 'Content-Type': 'text/html' }, + }); +}; + +const methodNotAllowed = async ({ req }: RequestContext) => { + if (req.method !== 'GET') { + logger.error({ message: 'Method not allowed', method: req.method }); + return new Response('Method not allowed', { status: 405 }); + } +}; + +const notFound = async () => new Response('Not Found', { status: 404 }); + +export const routes: RouteHandler[] = [marketingRoute, publishRoute, methodNotAllowed, notFound]; diff --git a/deploy/server.test.ts b/deploy/server.test.ts new file mode 100644 index 00000000..481757bf --- /dev/null +++ b/deploy/server.test.ts @@ -0,0 +1,868 @@ +/** @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('renders /as-template route', async () => { + const response = await createServer(makeRequest('/as-template')); + + expect(response.status).toBe(200); + expect(mockBunFetch).not.toHaveBeenCalled(); + }); + + it('renders /accept-invitation route', async () => { + const response = await createServer(makeRequest('/accept-invitation')); + + expect(response.status).toBe(200); + expect(mockBunFetch).not.toHaveBeenCalled(); + }); + + it('renders /import route', async () => { + const response = await createServer(makeRequest('/import')); + + expect(response.status).toBe(200); + expect(mockBunFetch).not.toHaveBeenCalled(); + }); + + it('renders sub-paths of marketing routes like /app/workspace', async () => { + const response = await createServer(makeRequest('/app/workspace/123')); + + expect(response.status).toBe(200); + 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('re-encodes namespace when fetching metadata for namespace routes', async () => { + const namespace = 'space slug'; + const encodedNamespace = encodeURIComponent(namespace); + + mockBunFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + data: { info: { namespace, publish_name: 'home page' } }, + }), + }); + + const response = await createServer(makeRequest(`/${encodedNamespace}`)); + + expect(response.status).toBe(302); + expect(mockBunFetch).toHaveBeenCalledWith( + `https://api.example.com/api/workspace/published/${encodedNamespace}`, + { verbose: false } + ); + }); + + 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('re-encodes namespace and publishName when fetching metadata for publish pages', async () => { + const namespace = 'team slug'; + const publishName = 'hello world'; + const encodedNamespace = encodeURIComponent(namespace); + const encodedPublishName = encodeURIComponent(publishName); + + mockBunFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + code: 0, + data: { + view: { + name: 'Doc', + }, + }, + }), + }); + + const response = await createServer(makeRequest(`/${encodedNamespace}/${encodedPublishName}`)); + const html = await getHtml(response); + + expect(response.status).toBe(200); + expect(extractPublishError(html)).toBeUndefined(); + expect(mockBunFetch).toHaveBeenCalledWith( + `https://api.example.com/api/workspace/v1/published/${encodedNamespace}/${encodedPublishName}`, + { 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,'); + }); + + // Additional HTTP method tests + it('returns 405 for PUT requests', async () => { + const response = await createServer(makeRequest('/space/page', { method: 'PUT' })); + + expect(response.status).toBe(405); + }); + + it('returns 405 for DELETE requests', async () => { + const response = await createServer(makeRequest('/space/page', { method: 'DELETE' })); + + expect(response.status).toBe(405); + }); + + it('returns 405 for PATCH requests', async () => { + const response = await createServer(makeRequest('/space/page', { method: 'PATCH' })); + + expect(response.status).toBe(405); + }); + + // Content-Type header tests + it('returns text/html Content-Type for marketing routes', async () => { + const response = await createServer(makeRequest('/login')); + + expect(response.headers.get('Content-Type')).toBe('text/html'); + }); + + it('returns text/html Content-Type for publish routes', async () => { + mockBunFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + code: 0, + data: { view: { name: 'Test' } }, + }), + }); + + const response = await createServer(makeRequest('/space/page')); + + expect(response.headers.get('Content-Type')).toBe('text/html'); + }); + + // Canonical URL tests + it('sets correct canonical URL for publish pages', async () => { + mockBunFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + code: 0, + data: { view: { name: 'Test' } }, + }), + }); + + const response = await createServer(makeRequest('/workspace/my-page')); + const html = await getHtml(response); + const $ = load(html); + + expect($('link[rel="canonical"]').attr('href')).toBe('https://appflowy.test/workspace/my-page'); + }); + + // OG meta tags tests + it('sets correct og:url for publish pages', async () => { + mockBunFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + code: 0, + data: { view: { name: 'Test' } }, + }), + }); + + const response = await createServer(makeRequest('/workspace/page')); + const html = await getHtml(response); + const $ = load(html); + + expect($('meta[property="og:url"]').attr('content')).toBe('https://appflowy.test/workspace/page'); + }); + + it('sets og:site_name to AppFlowy', async () => { + mockBunFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + code: 0, + data: { view: { name: 'Test' } }, + }), + }); + + const response = await createServer(makeRequest('/workspace/page')); + const html = await getHtml(response); + const $ = load(html); + + expect($('meta[property="og:site_name"]').attr('content')).toBe('AppFlowy'); + }); + + it('sets og:type to website', async () => { + mockBunFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + code: 0, + data: { view: { name: 'Test' } }, + }), + }); + + const response = await createServer(makeRequest('/workspace/page')); + const html = await getHtml(response); + const $ = load(html); + + expect($('meta[property="og:type"]').attr('content')).toBe('website'); + }); + + // Twitter card tests + it('sets twitter:card to summary_large_image', async () => { + mockBunFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + code: 0, + data: { view: { name: 'Test' } }, + }), + }); + + const response = await createServer(makeRequest('/workspace/page')); + const html = await getHtml(response); + const $ = load(html); + + expect($('meta[name="twitter:card"]').attr('content')).toBe('summary_large_image'); + }); + + it('sets twitter:site to @appflowy', async () => { + mockBunFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + code: 0, + data: { view: { name: 'Test' } }, + }), + }); + + const response = await createServer(makeRequest('/workspace/page')); + const html = await getHtml(response); + const $ = load(html); + + expect($('meta[name="twitter:site"]').attr('content')).toBe('@appflowy'); + }); + + // Edge case tests + it('handles special characters in namespace', async () => { + mockBunFetch.mockResolvedValue({ + ok: true, + json: async () => ({ data: {} }), + }); + + const response = await createServer(makeRequest('/test%2Fnamespace')); + const html = await getHtml(response); + + expect(response.status).toBe(200); + expect(html).toContain('NO_DEFAULT_PAGE'); + }); + + it('handles emoji in publish name', async () => { + mockBunFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + code: 0, + data: { view: { name: '๐Ÿ“ My Notes' } }, + }), + }); + + const response = await createServer(makeRequest('/space/%F0%9F%93%9D-notes')); + const html = await getHtml(response); + const $ = load(html); + + expect(response.status).toBe(200); + expect($('title').text()).toBe('๐Ÿ“ My Notes | AppFlowy'); + }); + + it('handles very long page names', async () => { + const longName = 'A'.repeat(200); + + mockBunFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + code: 0, + data: { view: { name: longName } }, + }), + }); + + const response = await createServer(makeRequest('/space/page')); + const html = await getHtml(response); + const $ = load(html); + + expect(response.status).toBe(200); + expect($('title').text()).toBe(`${longName} | AppFlowy`); + }); + + // API endpoint verification + it('uses v1 API for publish page lookup', async () => { + mockBunFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + code: 0, + data: { view: { name: 'Test' } }, + }), + }); + + await createServer(makeRequest('/myspace/mypage')); + + expect(mockBunFetch).toHaveBeenCalledWith( + 'https://api.example.com/api/workspace/v1/published/myspace/mypage', + { verbose: false } + ); + }); + + it('uses non-v1 API for namespace lookup', async () => { + mockBunFetch.mockResolvedValue({ + ok: true, + json: async () => ({ data: {} }), + }); + + await createServer(makeRequest('/myspace')); + + expect(mockBunFetch).toHaveBeenCalledWith( + 'https://api.example.com/api/workspace/published/myspace', + { verbose: false } + ); + }); + + // Error message content tests + it('includes user-friendly message for NO_DEFAULT_PAGE error', async () => { + mockBunFetch.mockResolvedValue({ + ok: true, + json: async () => ({ data: {} }), + }); + + const response = await createServer(makeRequest('/space')); + const html = await getHtml(response); + + expect(html).toContain("doesn't have a default published page"); + }); + + it('includes user-friendly message for PUBLISH_VIEW_LOOKUP_FAILED error', async () => { + mockBunFetch.mockResolvedValue({ + ok: true, + json: async () => ({ code: 404 }), + }); + + const response = await createServer(makeRequest('/space/page')); + const html = await getHtml(response); + + expect(html).toContain("page you're looking for doesn't exist"); + }); + + it('includes user-friendly message for FETCH_ERROR', async () => { + mockBunFetch.mockRejectedValue(new Error('timeout')); + + const response = await createServer(makeRequest('/space/page')); + const html = await getHtml(response); + + expect(html).toContain('Unable to load this page'); + }); + + it('includes user-friendly message for UNKNOWN_FALLBACK', async () => { + mockBunFetch.mockResolvedValue({ + ok: true, + json: async () => ({ code: 0, data: null }), + }); + + const response = await createServer(makeRequest('/space/page')); + const html = await getHtml(response); + + expect(html).toContain("couldn't load this page"); + }); +}); diff --git a/deploy/server.ts b/deploy/server.ts index 64cfe06b..7984eca4 100644 --- a/deploy/server.ts +++ b/deploy/server.ts @@ -1,85 +1,15 @@ -import * as fs from 'fs'; -import path from 'path'; +import { baseURL } from './config'; +import { logger } from './logger'; +import { routes } from './routes'; -// @ts-expect-error no bun -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -import { fetch } from 'bun'; -import { type CheerioAPI, load } from 'cheerio'; -import pino from 'pino'; - -const distDir = path.join(__dirname, 'dist'); -const indexPath = path.join(distDir, 'index.html'); -const baseURL = process.env.APPFLOWY_BASE_URL as string; -const defaultSite = 'https://appflowy.com'; - -const setOrUpdateMetaTag = ($: CheerioAPI, selector: string, attribute: string, content: string) => { - if ($(selector).length === 0) { - $('head').append(``); - } else { - $(selector).attr('content', content); - } +export type RequestContext = { + req: Request; + url: URL; + hostname: string | null; + logger: typeof logger; }; -const prettyTransport = { - target: 'pino-pretty', - options: { - colorize: true, - translateTime: 'SYS:standard', - }, -}; - -const logger = pino({ - transport: process.env.NODE_ENV === 'production' ? undefined : prettyTransport, - level: process.env.LOG_LEVEL || 'info', -}); - -const logRequestTimer = (req: Request) => { - const start = Date.now(); - const pathname = new URL(req.url).pathname; - - if (!pathname.startsWith('/health')) { - logger.debug(`Incoming request: ${pathname}`); - } - - return () => { - const duration = Date.now() - start; - - if (!pathname.startsWith('/health')) { - logger.debug(`Request for ${pathname} took ${duration}ms`); - } - }; -}; - -const fetchMetaData = async (namespace: string, publishName?: string) => { - let url = `${baseURL}/api/workspace/published/${namespace}`; - - if (publishName) { - url = `${baseURL}/api/workspace/v1/published/${namespace}/${publishName}`; - } - - 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 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; - } -}; - -const createServer = async (req: Request) => { - const timer = logRequestTimer(req); +export const createServer = async (req: Request) => { const reqUrl = new URL(req.url); const hostname = req.headers.get('host'); @@ -88,7 +18,6 @@ const createServer = async (req: Request) => { } if (reqUrl.pathname === '/') { - timer(); return new Response(null, { status: 302, headers: { @@ -97,179 +26,29 @@ const createServer = async (req: Request) => { }); } - if (['/after-payment', '/login', '/as-template', '/app', '/accept-invitation', '/import'].some(item => reqUrl.pathname.startsWith(item))) { - timer(); - const htmlData = fs.readFileSync(indexPath, 'utf8'); - const $ = load(htmlData); + const context: RequestContext = { + req, + url: reqUrl, + hostname, + logger, + }; - let title, description; + for (const route of routes) { + const response = await route(context); - if (reqUrl.pathname === '/after-payment') { - title = 'Payment Success | AppFlowy'; - description = 'Payment success on AppFlowy'; + if (response) { + return response; } - - if (reqUrl.pathname === '/login') { - title = 'Login | AppFlowy'; - description = 'Login to AppFlowy'; - } - - if (title) $('title').text(title); - if (description) setOrUpdateMetaTag($, 'meta[name="description"]', 'name', description); - - return new Response($.html(), { - headers: { 'Content-Type': 'text/html' }, - }); } - const [namespace, publishName] = reqUrl.pathname.slice(1).split('/'); - - logger.debug(`Namespace: ${namespace}, Publish Name: ${publishName}`); - - if (req.method === 'GET') { - if (namespace === '') { - timer(); - return new Response(null, { - status: 302, - headers: { - Location: defaultSite, - }, - }); - } - - let metaData; - let redirectAttempted = false; - - try { - const data = await fetchMetaData(namespace, publishName); - - if (publishName) { - if (data && data.code === 0) { - metaData = data.data; - } else { - logger.error( - `Publish view lookup failed for namespace="${namespace}" publishName="${publishName}" response=${JSON.stringify(data)}` - ); - } - } else { - const publishInfo = data?.data?.info; - - if (publishInfo?.namespace && publishInfo?.publish_name) { - const newURL = `/${encodeURIComponent(publishInfo.namespace)}/${encodeURIComponent(publishInfo.publish_name)}`; - - logger.debug(`Redirecting to default page in: ${JSON.stringify(publishInfo)}`); - redirectAttempted = true; - timer(); - return new Response(null, { - status: 302, - headers: { - Location: newURL, - }, - }); - } else { - logger.warn(`Namespace "${namespace}" has no default publish page. response=${JSON.stringify(data)}`); - } - } - } catch (error) { - logger.error(`Error fetching meta data: ${error}`); - } - - const htmlData = fs.readFileSync(indexPath, 'utf8'); - const $ = load(htmlData); - - const description = 'Write, share, and publish docs quickly on AppFlowy.\nGet started for free.'; - let title = 'AppFlowy'; - const url = `https://${hostname}${reqUrl.pathname}`; - let image = '/og-image.png'; - let favicon = '/appflowy.ico'; - - try { - if (metaData && metaData.view) { - const view = metaData.view; - const emoji = view.icon?.ty === 0 && view.icon?.value; - const icon = view.icon?.ty === 2 && view.icon?.value; - const titleList = []; - - if (emoji) { - const emojiCode = emoji.codePointAt(0).toString(16); // Convert emoji to hex code - const baseUrl = 'https://raw.githubusercontent.com/googlefonts/noto-emoji/main/svg/emoji_u'; - - favicon = `${baseUrl}${emojiCode}.svg`; - } else if (icon) { - try { - const { iconContent, color } = JSON.parse(view.icon?.value); - - favicon = getIconBase64(iconContent, color); - $('link[rel="icon"]').attr('type', 'image/svg+xml'); - } catch (_) { - // Do nothing - } - } - - if (view.name) { - titleList.push(view.name); - titleList.push('|'); - } - - titleList.push('AppFlowy'); - title = titleList.join(' '); - - try { - const cover = view.extra ? JSON.parse(view.extra)?.cover : null; - - if (cover) { - if (['unsplash', 'custom'].includes(cover.type)) { - image = cover.value; - } else if (cover.type === 'built_in') { - image = `/covers/m_cover_image_${cover.value}.png`; - } - } - } catch (_) { - // Do nothing - } - } - } catch (error) { - logger.error(`Error injecting meta data: ${error}`); - } - - if (!metaData) { - logger.warn( - `Serving fallback landing page for namespace="${namespace}" publishName="${publishName ?? ''}". redirectAttempted=${redirectAttempted}` - ); - } - - $('title').text(title); - $('link[rel="icon"]').attr('href', favicon); - $('link[rel="canonical"]').attr('href', url); - setOrUpdateMetaTag($, 'meta[name="description"]', 'name', description); - setOrUpdateMetaTag($, 'meta[property="og:title"]', 'property', title); - setOrUpdateMetaTag($, 'meta[property="og:description"]', 'property', description); - setOrUpdateMetaTag($, 'meta[property="og:image"]', 'property', image); - setOrUpdateMetaTag($, 'meta[property="og:url"]', 'property', url); - setOrUpdateMetaTag($, 'meta[property="og:site_name"]', 'property', 'AppFlowy'); - setOrUpdateMetaTag($, 'meta[property="og:type"]', 'property', 'website'); - setOrUpdateMetaTag($, 'meta[name="twitter:card"]', 'name', 'summary_large_image'); - setOrUpdateMetaTag($, 'meta[name="twitter:title"]', 'name', title); - setOrUpdateMetaTag($, 'meta[name="twitter:description"]', 'name', description); - setOrUpdateMetaTag($, 'meta[name="twitter:image"]', 'name', image); - setOrUpdateMetaTag($, 'meta[name="twitter:site"]', 'name', '@appflowy'); - - timer(); - return new Response($.html(), { - headers: { 'Content-Type': 'text/html' }, - }); - } else { - timer(); - logger.error({ message: 'Method not allowed', method: req.method }); - return new Response('Method not allowed', { status: 405 }); - } + return new Response('Not Found', { status: 404 }); }; 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,33 +67,6 @@ const start = () => { } }; -start(); - -export { }; - -function getIconBase64(svgText: string, color: string) { - let newSvgText = svgText.replace(/fill="[^"]*"/g, ``); - - newSvgText = newSvgText.replace(' { 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__; +}; diff --git a/tsconfig.json b/tsconfig.json index 0e105e6c..e6cdf888 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -53,7 +53,7 @@ "vite.config.ts", "cypress.config.ts", "cypress", - "deploy/server.ts" + "deploy/**/*.ts" ], "exclude": [ "node_modules",