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
47 changed files with 425 additions and 212 deletions
-98
View File
@@ -135,83 +135,6 @@ jobs:
path: ./packages/frontend/apps/mobile/dist
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:
name: Build Server native - ${{ matrix.targets.name }}
runs-on: ubuntu-latest
@@ -256,9 +179,6 @@ jobs:
- build-web
- build-mobile
- build-admin
- build-web-selfhost
- build-mobile-selfhost
- build-admin-selfhost
- build-server-native
steps:
- uses: actions/checkout@v4
@@ -334,24 +254,6 @@ jobs:
name: admin
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
run: |
yarn config set --json supportedArchitectures.cpu '["x64", "arm64", "arm"]'
+4
View File
@@ -24,6 +24,10 @@ export async function createApp() {
logger: AFFiNE.affine.stable ? ['log'] : ['verbose'],
});
if (AFFiNE.server.path) {
app.setGlobalPrefix(AFFiNE.server.path);
}
app.use(serverTimingAndCache);
app.use(
@@ -18,7 +18,6 @@ interface RenderOptions {
}
interface HtmlAssets {
html: string;
css: string[];
js: string[];
publicPath: string;
@@ -27,7 +26,6 @@ interface HtmlAssets {
}
const defaultAssets: HtmlAssets = {
html: '',
css: [],
js: [],
publicPath: '/',
@@ -152,9 +150,15 @@ export class DocRendererController {
return null;
}
// @TODO(@forehalo): pre-compile html template to accelerate serializing
_render(opts: RenderOptions | null, assets: HtmlAssets): string {
if (!opts && assets.html) {
return assets.html;
// TODO(@forehalo): how can we enable the type reference to @affine/env
const env: Record<string, any> = {
publicPath: assets.publicPath,
};
if (this.config.isSelfhosted) {
env.isSelfHosted = true;
}
const title = opts?.title
@@ -182,7 +186,7 @@ export class DocRendererController {
<title>${title}</title>
<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="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.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:description" content="${summary}" />
<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')}
</head>
<body>
@@ -214,11 +222,20 @@ export class DocRendererController {
*/
private readHtmlAssets(path: string): HtmlAssets {
const manifestPath = join(path, 'assets-manifest.json');
const htmlPath = join(path, 'index.html');
try {
const assets = JSON.parse(readFileSync(manifestPath, 'utf-8'));
assets.html = readFileSync(htmlPath, 'utf-8');
const assets: HtmlAssets = JSON.parse(
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;
} catch (e) {
if (this.config.node.prod) {
@@ -9,6 +9,7 @@ import {
import { HttpAdapterHost } from '@nestjs/core';
import type { Application, Request, Response } from 'express';
import { static as serveStatic } from 'express';
import isMobile from 'is-mobile';
import { Config } from '../../fundamentals';
import { AuthModule } from '../auth';
@@ -58,50 +59,106 @@ export class SelfhostModule implements 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
if (!this.adapterHost.httpAdapter) {
return;
}
const app = this.adapterHost.httpAdapter.getInstance<Application>();
// for example, '/affine' in host [//host.com/affine]
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) => {
res.redirect(basePath + '/admin');
return res.redirect(basePath + '/admin');
});
// serve all static files
app.use(
basePath + '/admin',
serveStatic(join(staticPath, 'admin', 'selfhost'), {
basePath,
serveStatic(join(staticPath, 'admin'), {
redirect: false,
index: false,
fallthrough: true,
})
);
// fallback all unknown routes
app.get(
[basePath + '/admin', basePath + '/admin/*'],
this.check.use,
(_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) => {
res.redirect(basePath);
});
// START REGION: /mobile
// serve all static files
app.use(
basePath,
serveStatic(join(staticPath, 'selfhost'), {
serveStatic(join(staticPath, 'mobile'), {
redirect: false,
index: false,
fallthrough: true,
})
);
app.get('*', this.check.use, (_req, res) => {
res.sendFile(join(staticPath, 'selfhost', 'index.html'));
// END REGION
// 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
}
}
@@ -3,7 +3,7 @@ import { defineStartupConfig, ModuleConfig } from '../../fundamentals/config';
export interface ServerStartupConfigurations {
/**
* 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;
/**
@@ -17,12 +17,17 @@ const test = ava as TestFn<{
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 = {
'selfhost/index.html': `<!DOCTYPE html><html><body>AFFiNE</body><script src="main.js"/></html>`,
'selfhost/main.js': `const name = 'affine'`,
'admin/selfhost/index.html': `<!DOCTYPE html><html><body>AFFiNE Admin</body><script src="/admin/main.js"/></html>`,
'admin/selfhost/main.js': `const name = 'affine-admin'`,
'selfhost.html': `<!DOCTYPE html><html><body>AFFiNE</body><script src="main.a.js"/></html>`,
'main.a.js': `const name = 'affine'`,
'admin/selfhost.html': `<!DOCTYPE html><html><body>AFFiNE Admin</body><script src="/admin/main.b.js"/></html>`,
'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)) {
@@ -35,6 +40,7 @@ function initTestStaticFiles(staticPath: string) {
test.before('init selfhost server', async t => {
// @ts-expect-error override
AFFiNE.isSelfhosted = true;
AFFiNE.flavor.renderer = true;
const { app } = await createTestingApp({
imports: [buildAppModule()],
});
@@ -54,7 +60,7 @@ test.beforeEach(async t => {
server._initialized = false;
});
test.afterEach.always(async t => {
test.after.always(async t => {
await t.context.app.close();
});
@@ -70,19 +76,28 @@ test('do not allow visit index.html directly', async t => {
.expect(302);
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 => {
let res = await request(t.context.app.getHttpServer())
.get('/main.js')
.get('/main.a.js')
.expect(200);
t.is(res.text, "const name = 'affine'");
res = await request(t.context.app.getHttpServer())
.get('/admin/main.js')
.get('/main.b.js')
.expect(200);
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({
data: {
name: 'test',
@@ -91,14 +106,19 @@ test('should always return static asset files', async t => {
});
res = await request(t.context.app.getHttpServer())
.get('/main.js')
.get('/main.a.js')
.expect(200);
t.is(res.text, "const name = 'affine'");
res = await request(t.context.app.getHttpServer())
.get('/admin/main.js')
.get('/main.b.js')
.expect(200);
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 => {
@@ -167,3 +187,19 @@ test('should redirect to admin if initialized', async t => {
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'));
});
@@ -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');
+50 -19
View File
@@ -31,12 +31,12 @@ export type BUILD_CONFIG_TYPE = {
// see: tools/workers
imageProxyUrl: string;
linkPreviewUrl: string;
// TODO(@forehalo): remove
isSelfHosted: boolean;
};
export type Environment = {
// Variant
isSelfHosted: boolean;
// Device
isLinux: boolean;
isMacOs: boolean;
@@ -47,8 +47,10 @@ export type Environment = {
isMobile: boolean;
isChrome: boolean;
isPwa: boolean;
chromeVersion?: number;
// runtime configs
publicPath: string;
};
export function setupGlobal() {
@@ -56,24 +58,25 @@ export function setupGlobal() {
return;
}
let environment: Environment;
let environment: Environment = {
isLinux: false,
isMacOs: false,
isSafari: false,
isWindows: false,
isFireFox: false,
isChrome: false,
isIOS: false,
isPwa: false,
isMobile: false,
isSelfHosted: false,
publicPath: '/',
};
if (!globalThis.navigator) {
environment = {
isLinux: false,
isMacOs: false,
isSafari: false,
isWindows: false,
isFireFox: false,
isChrome: false,
isIOS: false,
isPwa: false,
isMobile: false,
};
} else {
if (globalThis.navigator) {
const uaHelper = new UaHelper(globalThis.navigator);
environment = {
...environment,
isMobile: uaHelper.isMobile,
isLinux: uaHelper.isLinux,
isMacOs: uaHelper.isMacOs,
@@ -96,7 +99,35 @@ export function setupGlobal() {
}
}
globalThis.environment = environment;
applyEnvironmentOverrides(environment);
globalThis.environment = environment;
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);
}
});
}
+1
View File
@@ -1,4 +1,5 @@
import './global.css';
import './setup';
import { createRoot } from 'react-dom/client';
+3
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.
@@ -4,11 +4,12 @@ import { setupEnvironment } from './app';
import { polyfillBrowser, polyfillElectron } from './polyfill';
export function setupElectron() {
polyfillElectron();
setupEnvironment();
polyfillElectron();
}
export async function setupBrowser() {
await polyfillBrowser();
setupEnvironment();
__webpack_public_path__ = environment.publicPath;
await polyfillBrowser();
}
@@ -5,12 +5,9 @@ import {
export function getFontConfigExtension() {
return FontConfigExtension(
BUILD_CONFIG.isSelfHosted
? AffineCanvasTextFonts.map(font => ({
...font,
// self-hosted fonts are served from /assets
url: '/assets/' + new URL(font.url).pathname.split('/').pop(),
}))
: AffineCanvasTextFonts
AffineCanvasTextFonts.map(font => ({
...font,
url: environment.publicPath + 'fonts/' + font.url.split('/').pop(),
}))
);
}
+1 -1
View File
@@ -35,8 +35,8 @@ function createMixpanel() {
appVersion: BUILD_CONFIG.appVersion,
environment: BUILD_CONFIG.appBuildType,
editorVersion: BUILD_CONFIG.editorVersion,
isSelfHosted: BUILD_CONFIG.isSelfHosted,
isDesktop: BUILD_CONFIG.isElectron,
isSelfHosted: environment.isSelfHosted,
});
},
reset() {
+3 -4
View File
@@ -10,10 +10,9 @@ const fontPath = join(
'..',
'packages',
'frontend',
'apps',
'web',
'dist',
'assets'
'core',
'public',
'fonts'
);
await Promise.all(
+16 -11
View File
@@ -71,19 +71,23 @@ export const getPublicPath = (buildFlags: BuildFlags) => {
if (typeof process.env.PUBLIC_PATH === 'string') {
return process.env.PUBLIC_PATH;
}
const publicPath = '/';
if (process.env.COVERAGE || buildFlags.distribution === 'desktop') {
return publicPath;
if (
buildFlags.mode === 'development' ||
process.env.COVERAGE ||
buildFlags.distribution === 'desktop'
) {
return '/';
}
if (BUILD_TYPE === 'canary') {
return `https://dev.affineassets.com/`;
} else if (BUILD_TYPE === 'beta') {
return `https://beta.affineassets.com/`;
} else if (BUILD_TYPE === 'stable') {
return `https://prod.affineassets.com/`;
switch (BUILD_TYPE) {
case 'stable':
return 'https://prod.affineassets.com/';
case 'beta':
return 'https://beta.affineassets.com/';
default:
return 'https://dev.affineassets.com/';
}
return publicPath;
};
export const createConfiguration: (
@@ -126,7 +130,8 @@ export const createConfiguration: (
path: join(cwd, 'dist'),
clean: buildFlags.mode === 'production',
globalObject: 'globalThis',
publicPath: getPublicPath(buildFlags),
// NOTE(@forehalo): always keep it '/'
publicPath: '/',
workerPublicPath: '/',
},
target: ['web', 'es2022'],
+1 -1
View File
@@ -26,7 +26,7 @@ export class WebpackS3Plugin implements WebpackPluginInstance {
compiler.hooks.assetEmitted.tapPromise(
'WebpackS3Plugin',
async (asset, { outputPath }) => {
if (asset === 'index.html') {
if (asset.endsWith('.html')) {
return;
}
const assetPath = join(outputPath, asset);
+1 -1
View File
@@ -16,7 +16,7 @@
<title>AFFiNE</title>
<meta name="theme-color" content="#fafafa" />
<link rel="preconnect" href="<%= PUBLIC_PATH %>" />
<%= PRECONNECT %>
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" sizes="192x192" href="/favicon-192.png" />
+101 -35
View File
@@ -5,10 +5,16 @@ import type { BuildFlags } from '@affine/cli/config';
import { Repository } from '@napi-rs/simple-git';
import HTMLPlugin from 'html-webpack-plugin';
import { once } from 'lodash-es';
import type { Compiler } from 'webpack';
import webpack from 'webpack';
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';
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;
const createHTMLPlugin = (entryName = 'app') => {
return new HTMLPlugin({
const publicPath = getPublicPath(flags);
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'),
inject: 'body',
filename: 'index.html',
minify: false,
templateParameters: templateParams,
chunks: [entryName],
filename: `${entryName === 'app' ? 'index' : entryName}.html`, // main entry should take name index.html
templateParameters: (compilation, assets) => {
if (entryName === 'app') {
// emit assets manifest for ssr
compilation.emitAsset(
`assets-manifest.json`,
new webpack.sources.RawSource(
JSON.stringify(
{
...assets,
gitHash: gitShortHash(),
description: DESCRIPTION,
},
null,
2
)
),
{
immutable: true,
}
);
}
return {
GIT_SHORT_SHA: gitShortHash(),
DESCRIPTION,
PUBLIC_PATH: config.output?.publicPath,
VIEWPORT_FIT: flags.distribution === 'mobile' ? 'cover' : 'auto',
};
},
});
} satisfies HTMLPlugin.Options;
if (entryName === 'app') {
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(
`assets-manifest.json`,
new webpack.sources.RawSource(
JSON.stringify(
{
...arg.assets,
js: arg.assets.js.map(file =>
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,
2
)
),
{
immutable: false,
}
);
}
return arg;
}
);
}
);
},
},
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, {
entry: entry,
plugins: Object.keys(entry).map(createHTMLPlugin),
entry,
plugins: Object.keys(entry).map(createHTMLPlugins).flat(),
});
}