mirror of
https://github.com/AppFlowy-IO/AppFlowy-Web.git
synced 2025-11-29 10:47:56 +08:00
fix: propagate publish error
This commit is contained in:
@@ -9,3 +9,4 @@ coverage/
|
||||
src/proto/**/*
|
||||
cypress/e2e/
|
||||
cypress/support/
|
||||
deploy/*.test.ts
|
||||
524
deploy/server.test.ts
Normal file
524
deploy/server.test.ts
Normal file
@@ -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 = `
|
||||
<html>
|
||||
<head>
|
||||
<title>AppFlowy</title>
|
||||
<meta name="description" content="">
|
||||
<meta property="og:image" content="">
|
||||
<link rel="icon" href="/appflowy.ico">
|
||||
<link rel="canonical" href="">
|
||||
</head>
|
||||
<body><div id="root"></div></body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
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<bar>');
|
||||
const response = await createServer(makeRequest(`/${encodedNamespace}`));
|
||||
const html = await getHtml(response);
|
||||
const payload = extractPublishError(html);
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
code: 'NO_DEFAULT_PAGE',
|
||||
namespace: 'foo<bar>',
|
||||
});
|
||||
|
||||
const scriptText = load(html)('#appflowy-publish-error').html()!;
|
||||
expect(scriptText).toContain('\\u003cbar\\u003e');
|
||||
expect(scriptText).not.toContain('<bar>');
|
||||
});
|
||||
|
||||
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: '<svg><path d="M10"/></svg>',
|
||||
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 = `
|
||||
<html>
|
||||
<head>
|
||||
<title>AppFlowy</title>
|
||||
</head>
|
||||
<body><div id="root"></div></body>
|
||||
</html>
|
||||
`;
|
||||
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: '<svg><path d="M10"/></svg>',
|
||||
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,');
|
||||
});
|
||||
});
|
||||
@@ -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, '\\u003c')
|
||||
.replace(/>/g, '\\u003e');
|
||||
|
||||
$('head').append(
|
||||
`<script id="appflowy-publish-error">window.__APPFLOWY_PUBLISH_ERROR__ = ${serialized};</script>`
|
||||
);
|
||||
};
|
||||
|
||||
const setOrUpdateMetaTag = ($: CheerioAPI, selector: string, attribute: string, content: string) => {
|
||||
if ($(selector).length === 0) {
|
||||
$('head').append(`<meta ${attribute}="${selector.match(/\[(.*?)\]/)?.[1]}" content="${content}">`);
|
||||
const valueMatch = selector.match(/\[.*?="([^"]+)"\]/);
|
||||
const value = valueMatch?.[1] ?? '';
|
||||
|
||||
$('head').append(`<meta ${attribute}="${value}" content="${content}">`);
|
||||
} 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, ``);
|
||||
|
||||
@@ -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 (
|
||||
<div data-testid={'public-not-found'}>
|
||||
@@ -12,13 +15,39 @@ const NotFound = () => {
|
||||
Logo={NoAccessLogo}
|
||||
title={t('landingPage.noAccess.title')}
|
||||
description={
|
||||
<div className='w-full text-center'>
|
||||
{t('publish.createWithAppFlowy')}
|
||||
<div className={'flex w-full items-center justify-center gap-1'}>
|
||||
<div className={'font-semibold text-text-action'}>{t('publish.fastWithAI')}</div>
|
||||
<div>{t('publish.tryItNow')}</div>
|
||||
<>
|
||||
{publishError && (
|
||||
<div className='mb-4 w-full rounded-lg border border-border-primary bg-fill-content p-4 text-left text-sm text-text-primary'>
|
||||
<div className='font-semibold text-text-action'>{t('landingPage.noAccess.title')}</div>
|
||||
<p className='mt-1 break-words text-text-secondary'>{publishError.message}</p>
|
||||
{publishError.detail && (
|
||||
<p className='mt-2 break-words text-xs text-text-secondary'>{publishError.detail}</p>
|
||||
)}
|
||||
{(publishError.namespace || publishError.publishName) && (
|
||||
<p className='mt-2 text-xs text-text-tertiary'>
|
||||
{publishError.namespace && (
|
||||
<>
|
||||
Namespace: <code className='text-xs'>{publishError.namespace}</code>
|
||||
</>
|
||||
)}
|
||||
{publishError.publishName && (
|
||||
<>
|
||||
{' '}
|
||||
· Publish page: <code className='text-xs'>{publishError.publishName}</code>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className='w-full text-center'>
|
||||
{t('publish.createWithAppFlowy')}
|
||||
<div className={'flex w-full items-center justify-center gap-1'}>
|
||||
<div className={'font-semibold text-text-action'}>{t('publish.fastWithAI')}</div>
|
||||
<div>{t('publish.tryItNow')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
primaryAction={{
|
||||
onClick: () => window.open('https://appflowy.com/download', '_self'),
|
||||
|
||||
22
src/utils/publish-error.ts
Normal file
22
src/utils/publish-error.ts
Normal file
@@ -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__;
|
||||
};
|
||||
Reference in New Issue
Block a user