fix(server): blank screen on mobile (#8460)

Co-authored-by: forehalo <forehalo@gmail.com>
This commit is contained in:
野声 2024-10-16 13:12:40 +08:00 committed by GitHub
parent 82916e8264
commit f393f89a3f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 425 additions and 212 deletions

View File

@ -135,83 +135,6 @@ jobs:
path: ./packages/frontend/apps/mobile/dist path: ./packages/frontend/apps/mobile/dist
if-no-files-found: error if-no-files-found: error
build-web-selfhost:
name: Build @affine/web selfhost
runs-on: ubuntu-latest
environment: ${{ github.event.inputs.flavor }}
steps:
- uses: actions/checkout@v4
- name: Setup Version
id: version
uses: ./.github/actions/setup-version
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Build Core
run: yarn nx build @affine/web --skip-nx-cache
env:
BUILD_TYPE: ${{ github.event.inputs.flavor }}
PUBLIC_PATH: '/'
SELF_HOSTED: true
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
- name: Download selfhost fonts
run: node ./scripts/download-blocksuite-fonts.mjs
- name: Upload web artifact
uses: actions/upload-artifact@v4
with:
name: selfhost-web
path: ./packages/frontend/apps/web/dist
if-no-files-found: error
build-mobile-selfhost:
name: Build @affine/mobile selfhost
runs-on: ubuntu-latest
environment: ${{ github.event.inputs.flavor }}
steps:
- uses: actions/checkout@v4
- name: Setup Version
id: version
uses: ./.github/actions/setup-version
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Build Mobile
run: yarn nx build @affine/mobile --skip-nx-cache
env:
BUILD_TYPE: ${{ github.event.inputs.flavor }}
PUBLIC_PATH: '/'
SELF_HOSTED: true
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
- name: Upload mobile artifact
uses: actions/upload-artifact@v4
with:
name: selfhost-mobile
path: ./packages/frontend/apps/mobile/dist
if-no-files-found: error
build-admin-selfhost:
name: Build @affine/admin selfhost
runs-on: ubuntu-latest
environment: ${{ github.event.inputs.flavor }}
steps:
- uses: actions/checkout@v4
- name: Setup Version
id: version
uses: ./.github/actions/setup-version
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Build admin
run: yarn nx build @affine/admin --skip-nx-cache
env:
BUILD_TYPE: ${{ github.event.inputs.flavor }}
PUBLIC_PATH: '/admin/'
SELF_HOSTED: true
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
- name: Upload admin artifact
uses: actions/upload-artifact@v4
with:
name: selfhost-admin
path: ./packages/frontend/admin/dist
if-no-files-found: error
build-server-native: build-server-native:
name: Build Server native - ${{ matrix.targets.name }} name: Build Server native - ${{ matrix.targets.name }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -256,9 +179,6 @@ jobs:
- build-web - build-web
- build-mobile - build-mobile
- build-admin - build-admin
- build-web-selfhost
- build-mobile-selfhost
- build-admin-selfhost
- build-server-native - build-server-native
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -334,24 +254,6 @@ jobs:
name: admin name: admin
path: ./packages/frontend/admin/dist path: ./packages/frontend/admin/dist
- name: Download selfhost web artifact
uses: actions/download-artifact@v4
with:
name: selfhost-web
path: ./packages/frontend/apps/web/dist/selfhost
- name: Download selfhost mobile artifact
uses: actions/download-artifact@v4
with:
name: selfhost-mobile
path: ./packages/frontend/apps/mobile/dist/selfhost
- name: Download selfhost admin artifact
uses: actions/download-artifact@v4
with:
name: selfhost-admin
path: ./packages/frontend/admin/dist/selfhost
- name: Install Node.js dependencies - name: Install Node.js dependencies
run: | run: |
yarn config set --json supportedArchitectures.cpu '["x64", "arm64", "arm"]' yarn config set --json supportedArchitectures.cpu '["x64", "arm64", "arm"]'

View File

@ -24,6 +24,10 @@ export async function createApp() {
logger: AFFiNE.affine.stable ? ['log'] : ['verbose'], logger: AFFiNE.affine.stable ? ['log'] : ['verbose'],
}); });
if (AFFiNE.server.path) {
app.setGlobalPrefix(AFFiNE.server.path);
}
app.use(serverTimingAndCache); app.use(serverTimingAndCache);
app.use( app.use(

View File

@ -18,7 +18,6 @@ interface RenderOptions {
} }
interface HtmlAssets { interface HtmlAssets {
html: string;
css: string[]; css: string[];
js: string[]; js: string[];
publicPath: string; publicPath: string;
@ -27,7 +26,6 @@ interface HtmlAssets {
} }
const defaultAssets: HtmlAssets = { const defaultAssets: HtmlAssets = {
html: '',
css: [], css: [],
js: [], js: [],
publicPath: '/', publicPath: '/',
@ -152,9 +150,15 @@ export class DocRendererController {
return null; return null;
} }
// @TODO(@forehalo): pre-compile html template to accelerate serializing
_render(opts: RenderOptions | null, assets: HtmlAssets): string { _render(opts: RenderOptions | null, assets: HtmlAssets): string {
if (!opts && assets.html) { // TODO(@forehalo): how can we enable the type reference to @affine/env
return assets.html; const env: Record<string, any> = {
publicPath: assets.publicPath,
};
if (this.config.isSelfhosted) {
env.isSelfHosted = true;
} }
const title = opts?.title const title = opts?.title
@ -182,7 +186,7 @@ export class DocRendererController {
<title>${title}</title> <title>${title}</title>
<meta name="theme-color" content="#fafafa" /> <meta name="theme-color" content="#fafafa" />
<link rel="preconnect" href="${assets.publicPath}"> ${assets.publicPath.startsWith('/') ? '' : `<link rel="preconnect" href="${assets.publicPath}">`}
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" sizes="192x192" href="/favicon-192.png" /> <link rel="icon" sizes="192x192" href="/favicon-192.png" />
@ -199,6 +203,10 @@ export class DocRendererController {
<meta property="og:title" content="${title}" /> <meta property="og:title" content="${title}" />
<meta property="og:description" content="${summary}" /> <meta property="og:description" content="${summary}" />
<meta property="og:image" content="${image}" /> <meta property="og:image" content="${image}" />
<meta name="renderer" content="ssr" />
${Object.entries(env)
.map(([key, val]) => `<meta name="env:${key}" content="${val}" />`)
.join('\n')}
${assets.css.map(url => `<link rel="stylesheet" href="${url}" />`).join('\n')} ${assets.css.map(url => `<link rel="stylesheet" href="${url}" />`).join('\n')}
</head> </head>
<body> <body>
@ -214,11 +222,20 @@ export class DocRendererController {
*/ */
private readHtmlAssets(path: string): HtmlAssets { private readHtmlAssets(path: string): HtmlAssets {
const manifestPath = join(path, 'assets-manifest.json'); const manifestPath = join(path, 'assets-manifest.json');
const htmlPath = join(path, 'index.html');
try { try {
const assets = JSON.parse(readFileSync(manifestPath, 'utf-8')); const assets: HtmlAssets = JSON.parse(
assets.html = readFileSync(htmlPath, 'utf-8'); readFileSync(manifestPath, 'utf-8')
);
const publicPath = this.config.isSelfhosted
? this.config.server.host + '/'
: assets.publicPath;
assets.publicPath = publicPath;
assets.js = assets.js.map(path => publicPath + path);
assets.css = assets.css.map(path => publicPath + path);
return assets; return assets;
} catch (e) { } catch (e) {
if (this.config.node.prod) { if (this.config.node.prod) {

View File

@ -9,6 +9,7 @@ import {
import { HttpAdapterHost } from '@nestjs/core'; import { HttpAdapterHost } from '@nestjs/core';
import type { Application, Request, Response } from 'express'; import type { Application, Request, Response } from 'express';
import { static as serveStatic } from 'express'; import { static as serveStatic } from 'express';
import isMobile from 'is-mobile';
import { Config } from '../../fundamentals'; import { Config } from '../../fundamentals';
import { AuthModule } from '../auth'; import { AuthModule } from '../auth';
@ -58,50 +59,106 @@ export class SelfhostModule implements OnModuleInit {
) {} ) {}
onModuleInit() { onModuleInit() {
// selfhost static file location
// web => 'static/selfhost'
// admin => 'static/admin/selfhost'
// mobile => 'static/mobile/selfhost'
const staticPath = join(this.config.projectRoot, 'static');
// in command line mode // in command line mode
if (!this.adapterHost.httpAdapter) { if (!this.adapterHost.httpAdapter) {
return; return;
} }
const app = this.adapterHost.httpAdapter.getInstance<Application>(); const app = this.adapterHost.httpAdapter.getInstance<Application>();
// for example, '/affine' in host [//host.com/affine]
const basePath = this.config.server.path; const basePath = this.config.server.path;
const staticPath = join(this.config.projectRoot, 'static');
// web => {
// affine: 'static/index.html',
// selfhost: 'static/selfhost.html'
// }
// admin => {
// affine: 'static/admin/index.html',
// selfhost: 'static/admin/selfhost.html'
// }
// mobile => {
// affine: 'static/mobile/index.html',
// selfhost: 'static/mobile/selfhost.html'
// }
// NOTE(@forehalo):
// the order following routes should be respected,
// otherwise the app won't work properly.
// START REGION: /admin
// do not allow '/index.html' url, redirect to '/'
app.get(basePath + '/admin/index.html', (_req, res) => { app.get(basePath + '/admin/index.html', (_req, res) => {
res.redirect(basePath + '/admin'); return res.redirect(basePath + '/admin');
}); });
// serve all static files
app.use( app.use(
basePath + '/admin', basePath,
serveStatic(join(staticPath, 'admin', 'selfhost'), { serveStatic(join(staticPath, 'admin'), {
redirect: false, redirect: false,
index: false, index: false,
fallthrough: true,
}) })
); );
// fallback all unknown routes
app.get( app.get(
[basePath + '/admin', basePath + '/admin/*'], [basePath + '/admin', basePath + '/admin/*'],
this.check.use, this.check.use,
(_req, res) => { (_req, res) => {
res.sendFile(join(staticPath, 'admin', 'selfhost', 'index.html')); res.sendFile(
join(
staticPath,
'admin',
this.config.isSelfhosted ? 'selfhost.html' : 'index.html'
)
);
} }
); );
// END REGION
app.get(basePath + '/index.html', (_req, res) => { // START REGION: /mobile
res.redirect(basePath); // serve all static files
});
app.use( app.use(
basePath, basePath,
serveStatic(join(staticPath, 'selfhost'), { serveStatic(join(staticPath, 'mobile'), {
redirect: false, redirect: false,
index: false, index: false,
fallthrough: true,
}) })
); );
app.get('*', this.check.use, (_req, res) => { // END REGION
res.sendFile(join(staticPath, 'selfhost', 'index.html'));
// START REGION: /
// do not allow '/index.html' url, redirect to '/'
app.get(basePath + '/index.html', (_req, res) => {
return res.redirect(basePath);
}); });
// serve all static files
app.use(
basePath,
serveStatic(staticPath, {
redirect: false,
index: false,
fallthrough: true,
})
);
// fallback all unknown routes
app.get([basePath, basePath + '/*'], this.check.use, (req, res) => {
const mobile = isMobile({
ua: req.headers['user-agent'] ?? undefined,
});
return res.sendFile(
join(
staticPath,
mobile ? 'mobile' : '',
this.config.isSelfhosted ? 'selfhost.html' : 'index.html'
)
);
});
// END REGION
} }
} }

View File

@ -3,7 +3,7 @@ import { defineStartupConfig, ModuleConfig } from '../../fundamentals/config';
export interface ServerStartupConfigurations { export interface ServerStartupConfigurations {
/** /**
* Base url of AFFiNE server, used for generating external urls. * Base url of AFFiNE server, used for generating external urls.
* default to be `[AFFiNE.protocol]://[AFFiNE.host][:AFFiNE.port]?[AFFiNE.path]` if not specified * default to be `[AFFiNE.protocol]://[AFFiNE.host][:AFFiNE.port]/[AFFiNE.path]` if not specified
*/ */
externalUrl: string; externalUrl: string;
/** /**

View File

@ -17,12 +17,17 @@ const test = ava as TestFn<{
db: PrismaClient; db: PrismaClient;
}>; }>;
const mobileUAString =
'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36';
function initTestStaticFiles(staticPath: string) { function initTestStaticFiles(staticPath: string) {
const files = { const files = {
'selfhost/index.html': `<!DOCTYPE html><html><body>AFFiNE</body><script src="main.js"/></html>`, 'selfhost.html': `<!DOCTYPE html><html><body>AFFiNE</body><script src="main.a.js"/></html>`,
'selfhost/main.js': `const name = 'affine'`, 'main.a.js': `const name = 'affine'`,
'admin/selfhost/index.html': `<!DOCTYPE html><html><body>AFFiNE Admin</body><script src="/admin/main.js"/></html>`, 'admin/selfhost.html': `<!DOCTYPE html><html><body>AFFiNE Admin</body><script src="/admin/main.b.js"/></html>`,
'admin/selfhost/main.js': `const name = 'affine-admin'`, 'admin/main.b.js': `const name = 'affine-admin'`,
'mobile/selfhost.html': `<!DOCTYPE html><html><body>AFFiNE mobile</body><script src="/mobile/main.c.js"/></html>`,
'mobile/main.c.js': `const name = 'affine-mobile'`,
}; };
for (const [filename, content] of Object.entries(files)) { for (const [filename, content] of Object.entries(files)) {
@ -35,6 +40,7 @@ function initTestStaticFiles(staticPath: string) {
test.before('init selfhost server', async t => { test.before('init selfhost server', async t => {
// @ts-expect-error override // @ts-expect-error override
AFFiNE.isSelfhosted = true; AFFiNE.isSelfhosted = true;
AFFiNE.flavor.renderer = true;
const { app } = await createTestingApp({ const { app } = await createTestingApp({
imports: [buildAppModule()], imports: [buildAppModule()],
}); });
@ -54,7 +60,7 @@ test.beforeEach(async t => {
server._initialized = false; server._initialized = false;
}); });
test.afterEach.always(async t => { test.after.always(async t => {
await t.context.app.close(); await t.context.app.close();
}); });
@ -70,19 +76,28 @@ test('do not allow visit index.html directly', async t => {
.expect(302); .expect(302);
t.is(res.header.location, '/admin'); t.is(res.header.location, '/admin');
res = await request(t.context.app.getHttpServer())
.get('/mobile/index.html')
.expect(302);
}); });
test('should always return static asset files', async t => { test('should always return static asset files', async t => {
let res = await request(t.context.app.getHttpServer()) let res = await request(t.context.app.getHttpServer())
.get('/main.js') .get('/main.a.js')
.expect(200); .expect(200);
t.is(res.text, "const name = 'affine'"); t.is(res.text, "const name = 'affine'");
res = await request(t.context.app.getHttpServer()) res = await request(t.context.app.getHttpServer())
.get('/admin/main.js') .get('/main.b.js')
.expect(200); .expect(200);
t.is(res.text, "const name = 'affine-admin'"); t.is(res.text, "const name = 'affine-admin'");
res = await request(t.context.app.getHttpServer())
.get('/main.c.js')
.expect(200);
t.is(res.text, "const name = 'affine-mobile'");
await t.context.db.user.create({ await t.context.db.user.create({
data: { data: {
name: 'test', name: 'test',
@ -91,14 +106,19 @@ test('should always return static asset files', async t => {
}); });
res = await request(t.context.app.getHttpServer()) res = await request(t.context.app.getHttpServer())
.get('/main.js') .get('/main.a.js')
.expect(200); .expect(200);
t.is(res.text, "const name = 'affine'"); t.is(res.text, "const name = 'affine'");
res = await request(t.context.app.getHttpServer()) res = await request(t.context.app.getHttpServer())
.get('/admin/main.js') .get('/main.b.js')
.expect(200); .expect(200);
t.is(res.text, "const name = 'affine-admin'"); t.is(res.text, "const name = 'affine-admin'");
res = await request(t.context.app.getHttpServer())
.get('/main.c.js')
.expect(200);
t.is(res.text, "const name = 'affine-mobile'");
}); });
test('should be able to call apis', async t => { test('should be able to call apis', async t => {
@ -167,3 +187,19 @@ test('should redirect to admin if initialized', async t => {
t.is(res.header.location, '/admin'); t.is(res.header.location, '/admin');
}); });
test('should return mobile assets if visited by mobile', async t => {
await t.context.db.user.create({
data: {
name: 'test',
email: 'test@affine.pro',
},
});
const res = await request(t.context.app.getHttpServer())
.get('/')
.set('user-agent', mobileUAString)
.expect(200);
t.true(res.text.includes('AFFiNE mobile'));
});

View File

@ -0,0 +1,94 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import type { TestFn } from 'ava';
import ava from 'ava';
import request from 'supertest';
import { DocRendererModule } from '../../src/core/doc-renderer';
import { createTestingApp } from '../utils';
const test = ava as TestFn<{
app: INestApplication;
db: PrismaClient;
}>;
const mobileUAString =
'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36';
function initTestStaticFiles(staticPath: string) {
const files = {
'main.a.js': `const name = 'affine'`,
'assets-manifest.json': JSON.stringify({
js: ['main.a.js'],
css: [],
publicPath: 'https://app.affine.pro/',
gitHash: '',
description: '',
}),
'admin/main.b.js': `const name = 'affine-admin'`,
'mobile/main.c.js': `const name = 'affine-mobile'`,
'mobile/assets-manifest.json': JSON.stringify({
js: ['main.c.js'],
css: [],
publicPath: 'https://app.affine.pro/',
gitHash: '',
description: '',
}),
};
for (const [filename, content] of Object.entries(files)) {
const filePath = path.join(staticPath, filename);
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, content);
}
}
test.before('init selfhost server', async t => {
const staticPath = path.join(
fileURLToPath(import.meta.url),
'../../../static'
);
initTestStaticFiles(staticPath);
const { app } = await createTestingApp({
imports: [DocRendererModule],
});
t.context.app = app;
t.context.db = t.context.app.get(PrismaClient);
});
test.after.always(async t => {
await t.context.app.close();
});
test('should render correct html', async t => {
const res = await request(t.context.app.getHttpServer())
.get('/workspace/xxxx/xxx')
.expect(200);
t.true(
res.text.includes(
`<script src="https://app.affine.pro/main.a.js"></script>`
)
);
});
test('should render correct mobile html', async t => {
const res = await request(t.context.app.getHttpServer())
.get('/workspace/xxxx/xxx')
.set('user-agent', mobileUAString)
.expect(200);
t.true(
res.text.includes(
`<script src="https://app.affine.pro/main.c.js"></script>`
)
);
});
test.todo('should render correct page preview');

View File

@ -31,12 +31,12 @@ export type BUILD_CONFIG_TYPE = {
// see: tools/workers // see: tools/workers
imageProxyUrl: string; imageProxyUrl: string;
linkPreviewUrl: string; linkPreviewUrl: string;
// TODO(@forehalo): remove
isSelfHosted: boolean;
}; };
export type Environment = { export type Environment = {
// Variant
isSelfHosted: boolean;
// Device // Device
isLinux: boolean; isLinux: boolean;
isMacOs: boolean; isMacOs: boolean;
@ -47,8 +47,10 @@ export type Environment = {
isMobile: boolean; isMobile: boolean;
isChrome: boolean; isChrome: boolean;
isPwa: boolean; isPwa: boolean;
chromeVersion?: number; chromeVersion?: number;
// runtime configs
publicPath: string;
}; };
export function setupGlobal() { export function setupGlobal() {
@ -56,10 +58,7 @@ export function setupGlobal() {
return; return;
} }
let environment: Environment; let environment: Environment = {
if (!globalThis.navigator) {
environment = {
isLinux: false, isLinux: false,
isMacOs: false, isMacOs: false,
isSafari: false, isSafari: false,
@ -69,11 +68,15 @@ export function setupGlobal() {
isIOS: false, isIOS: false,
isPwa: false, isPwa: false,
isMobile: false, isMobile: false,
isSelfHosted: false,
publicPath: '/',
}; };
} else {
if (globalThis.navigator) {
const uaHelper = new UaHelper(globalThis.navigator); const uaHelper = new UaHelper(globalThis.navigator);
environment = { environment = {
...environment,
isMobile: uaHelper.isMobile, isMobile: uaHelper.isMobile,
isLinux: uaHelper.isLinux, isLinux: uaHelper.isLinux,
isMacOs: uaHelper.isMacOs, isMacOs: uaHelper.isMacOs,
@ -96,7 +99,35 @@ export function setupGlobal() {
} }
} }
globalThis.environment = environment; applyEnvironmentOverrides(environment);
globalThis.environment = environment;
globalThis.$AFFINE_SETUP = true; globalThis.$AFFINE_SETUP = true;
} }
function applyEnvironmentOverrides(environment: Environment) {
if (typeof document === 'undefined') {
return;
}
const metaTags = document.querySelectorAll('meta');
metaTags.forEach(meta => {
if (!meta.name.startsWith('env:')) {
return;
}
const name = meta.name.substring(4);
// all environments should have default value
// so ignore non-defined overrides
if (name in environment) {
// @ts-expect-error safe
environment[name] =
// @ts-expect-error safe
typeof environment[name] === 'string'
? meta.content
: JSON.parse(meta.content);
}
});
}

View File

@ -1,4 +1,5 @@
import './global.css'; import './global.css';
import './setup';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';

View File

@ -0,0 +1,3 @@
import { setupBrowser } from '@affine/core/bootstrap';
await setupBrowser();

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -4,11 +4,12 @@ import { setupEnvironment } from './app';
import { polyfillBrowser, polyfillElectron } from './polyfill'; import { polyfillBrowser, polyfillElectron } from './polyfill';
export function setupElectron() { export function setupElectron() {
polyfillElectron();
setupEnvironment(); setupEnvironment();
polyfillElectron();
} }
export async function setupBrowser() { export async function setupBrowser() {
await polyfillBrowser();
setupEnvironment(); setupEnvironment();
__webpack_public_path__ = environment.publicPath;
await polyfillBrowser();
} }

View File

@ -5,12 +5,9 @@ import {
export function getFontConfigExtension() { export function getFontConfigExtension() {
return FontConfigExtension( return FontConfigExtension(
BUILD_CONFIG.isSelfHosted AffineCanvasTextFonts.map(font => ({
? AffineCanvasTextFonts.map(font => ({
...font, ...font,
// self-hosted fonts are served from /assets url: environment.publicPath + 'fonts/' + font.url.split('/').pop(),
url: '/assets/' + new URL(font.url).pathname.split('/').pop(),
})) }))
: AffineCanvasTextFonts
); );
} }

View File

@ -35,8 +35,8 @@ function createMixpanel() {
appVersion: BUILD_CONFIG.appVersion, appVersion: BUILD_CONFIG.appVersion,
environment: BUILD_CONFIG.appBuildType, environment: BUILD_CONFIG.appBuildType,
editorVersion: BUILD_CONFIG.editorVersion, editorVersion: BUILD_CONFIG.editorVersion,
isSelfHosted: BUILD_CONFIG.isSelfHosted,
isDesktop: BUILD_CONFIG.isElectron, isDesktop: BUILD_CONFIG.isElectron,
isSelfHosted: environment.isSelfHosted,
}); });
}, },
reset() { reset() {

View File

@ -10,10 +10,9 @@ const fontPath = join(
'..', '..',
'packages', 'packages',
'frontend', 'frontend',
'apps', 'core',
'web', 'public',
'dist', 'fonts'
'assets'
); );
await Promise.all( await Promise.all(

View File

@ -71,19 +71,23 @@ export const getPublicPath = (buildFlags: BuildFlags) => {
if (typeof process.env.PUBLIC_PATH === 'string') { if (typeof process.env.PUBLIC_PATH === 'string') {
return process.env.PUBLIC_PATH; return process.env.PUBLIC_PATH;
} }
const publicPath = '/';
if (process.env.COVERAGE || buildFlags.distribution === 'desktop') { if (
return publicPath; buildFlags.mode === 'development' ||
process.env.COVERAGE ||
buildFlags.distribution === 'desktop'
) {
return '/';
} }
if (BUILD_TYPE === 'canary') { switch (BUILD_TYPE) {
return `https://dev.affineassets.com/`; case 'stable':
} else if (BUILD_TYPE === 'beta') { return 'https://prod.affineassets.com/';
return `https://beta.affineassets.com/`; case 'beta':
} else if (BUILD_TYPE === 'stable') { return 'https://beta.affineassets.com/';
return `https://prod.affineassets.com/`; default:
return 'https://dev.affineassets.com/';
} }
return publicPath;
}; };
export const createConfiguration: ( export const createConfiguration: (
@ -126,7 +130,8 @@ export const createConfiguration: (
path: join(cwd, 'dist'), path: join(cwd, 'dist'),
clean: buildFlags.mode === 'production', clean: buildFlags.mode === 'production',
globalObject: 'globalThis', globalObject: 'globalThis',
publicPath: getPublicPath(buildFlags), // NOTE(@forehalo): always keep it '/'
publicPath: '/',
workerPublicPath: '/', workerPublicPath: '/',
}, },
target: ['web', 'es2022'], target: ['web', 'es2022'],

View File

@ -26,7 +26,7 @@ export class WebpackS3Plugin implements WebpackPluginInstance {
compiler.hooks.assetEmitted.tapPromise( compiler.hooks.assetEmitted.tapPromise(
'WebpackS3Plugin', 'WebpackS3Plugin',
async (asset, { outputPath }) => { async (asset, { outputPath }) => {
if (asset === 'index.html') { if (asset.endsWith('.html')) {
return; return;
} }
const assetPath = join(outputPath, asset); const assetPath = join(outputPath, asset);

View File

@ -16,7 +16,7 @@
<title>AFFiNE</title> <title>AFFiNE</title>
<meta name="theme-color" content="#fafafa" /> <meta name="theme-color" content="#fafafa" />
<link rel="preconnect" href="<%= PUBLIC_PATH %>" /> <%= PRECONNECT %>
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" sizes="192x192" href="/favicon-192.png" /> <link rel="icon" sizes="192x192" href="/favicon-192.png" />

View File

@ -5,10 +5,16 @@ import type { BuildFlags } from '@affine/cli/config';
import { Repository } from '@napi-rs/simple-git'; import { Repository } from '@napi-rs/simple-git';
import HTMLPlugin from 'html-webpack-plugin'; import HTMLPlugin from 'html-webpack-plugin';
import { once } from 'lodash-es'; import { once } from 'lodash-es';
import type { Compiler } from 'webpack';
import webpack from 'webpack'; import webpack from 'webpack';
import { merge } from 'webpack-merge'; import { merge } from 'webpack-merge';
import { createConfiguration, rootPath, workspaceRoot } from './config.js'; import {
createConfiguration,
getPublicPath,
rootPath,
workspaceRoot,
} from './config.js';
import { getBuildConfig } from './runtime-config.js'; import { getBuildConfig } from './runtime-config.js';
const DESCRIPTION = `There can be more than Notion and Miro. AFFiNE is a next-gen knowledge base that brings planning, sorting and creating all together.`; const DESCRIPTION = `There can be more than Notion and Miro. AFFiNE is a next-gen knowledge base that brings planning, sorting and creating all together.`;
@ -41,46 +47,106 @@ export function createWebpackConfig(cwd: string, flags: BuildFlags) {
} }
: flags.entry; : flags.entry;
const createHTMLPlugin = (entryName = 'app') => { const publicPath = getPublicPath(flags);
return new HTMLPlugin({ const cdnOrigin = publicPath.startsWith('/')
? undefined
: new URL(publicPath).origin;
const templateParams = {
GIT_SHORT_SHA: gitShortHash(),
DESCRIPTION,
PRECONNECT: cdnOrigin
? `<link rel="preconnect" href="${cdnOrigin}" />`
: '',
VIEWPORT_FIT: flags.distribution === 'mobile' ? 'cover' : 'auto',
};
const createHTMLPlugins = (entryName: string) => {
const htmlPluginOptions = {
template: join(rootPath, 'webpack', 'template.html'), template: join(rootPath, 'webpack', 'template.html'),
inject: 'body', inject: 'body',
filename: 'index.html',
minify: false, minify: false,
templateParameters: templateParams,
chunks: [entryName], chunks: [entryName],
filename: `${entryName === 'app' ? 'index' : entryName}.html`, // main entry should take name index.html } satisfies HTMLPlugin.Options;
templateParameters: (compilation, assets) => {
if (entryName === 'app') { if (entryName === 'app') {
// emit assets manifest for ssr return [
{
apply(compiler: Compiler) {
compiler.hooks.compilation.tap(
'assets-manifest-plugin',
compilation => {
HTMLPlugin.getHooks(compilation).beforeAssetTagGeneration.tap(
'assets-manifest-plugin',
arg => {
if (!compilation.getAsset('assets-manifest.json')) {
compilation.emitAsset( compilation.emitAsset(
`assets-manifest.json`, `assets-manifest.json`,
new webpack.sources.RawSource( new webpack.sources.RawSource(
JSON.stringify( JSON.stringify(
{ {
...assets, ...arg.assets,
gitHash: gitShortHash(), js: arg.assets.js.map(file =>
description: DESCRIPTION, file.substring(arg.assets.publicPath.length)
),
css: arg.assets.css.map(file =>
file.substring(arg.assets.publicPath.length)
),
gitHash: templateParams.GIT_SHORT_SHA,
description: templateParams.DESCRIPTION,
}, },
null, null,
2 2
) )
), ),
{ {
immutable: true, immutable: false,
} }
); );
} }
return {
GIT_SHORT_SHA: gitShortHash(), return arg;
DESCRIPTION, }
PUBLIC_PATH: config.output?.publicPath, );
VIEWPORT_FIT: flags.distribution === 'mobile' ? 'cover' : 'auto', }
}; );
}, },
}); },
new HTMLPlugin({
...htmlPluginOptions,
publicPath,
meta: {
'env:publicPath': publicPath,
},
}),
// selfhost html
new HTMLPlugin({
...htmlPluginOptions,
meta: {
'env:isSelfHosted': 'true',
'env:publicPath': '/',
},
filename: 'selfhost.html',
templateParameters: {
...htmlPluginOptions.templateParameters,
PRECONNECT: '',
},
}),
];
} else {
return [
new HTMLPlugin({
...htmlPluginOptions,
filename: `${entryName}.html`,
}),
];
}
}; };
return merge(config, { return merge(config, {
entry: entry, entry,
plugins: Object.keys(entry).map(createHTMLPlugin), plugins: Object.keys(entry).map(createHTMLPlugins).flat(),
}); });
} }