Skip to content

Commit 9f1c146

Browse files
perf(@angular/build): bundle a single copy of domino for the server
`@angular/platform-server/init` (bundled into the server polyfills) and `@angular/platform-server` (bundled into the main server bundle) both import the domino DOM implementation shipped with the package. As the two bundles are separate esbuild contexts, each output contained its own copy, and both were evaluated at runtime. Besides the size and memory cost, the two copies are distinct class hierarchies: DOM globals captured at module evaluation time (the scenario `init` exists for) come from the polyfills copy, while the platform renders with the other, so `instanceof` checks between them fail. The server polyfills bundle now re-exports the domino module it already contains, and the main server bundle redirects the `@angular/platform-server` import of domino to that export. The polyfills bundle is already imported first and marked external by every server bundle, which keeps the evaluation order unchanged. Fixes #34514
1 parent 70c2143 commit 9f1c146

3 files changed

Lines changed: 250 additions & 1 deletion

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { buildApplication } from '../../index';
10+
import { OutputMode, Platform } from '../../schema';
11+
import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup';
12+
13+
/**
14+
* `@angular/platform-server/init` (bundled into the server polyfills) and
15+
* `@angular/platform-server` (bundled into the main server bundle) both import this file.
16+
*/
17+
const DOMINO_BUNDLE = 'third_party/domino/bundled-domino.mjs';
18+
19+
/** The export of the server polyfills bundle. The output may escape non-ASCII identifiers. */
20+
const DOMINO_EXPORT = '(?:ɵ|\\\\u0275)domino';
21+
const POLYFILLS_EXPORT_RE = new RegExp(`export \\{[^}]*${DOMINO_EXPORT}`);
22+
const MAIN_IMPORT_RE = new RegExp(
23+
`import \\{\\s*${DOMINO_EXPORT}\\s*\\} from "\\./polyfills\\.server\\.mjs"`,
24+
);
25+
26+
describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
27+
describe('Behavior: "Domino is bundled once for the server"', () => {
28+
beforeEach(async () => {
29+
await harness.modifyFile('src/tsconfig.app.json', (content) => {
30+
const tsConfig = JSON.parse(content);
31+
tsConfig.files ??= [];
32+
tsConfig.files.push('main.server.ts', 'server.ts');
33+
34+
return JSON.stringify(tsConfig);
35+
});
36+
37+
await harness.writeFile('src/server.ts', `console.log('Hello!');`);
38+
39+
// The default template uses i18n which requires `@angular/localize` to prerender.
40+
await harness.writeFile('src/app/app.component.html', `<p>Hello!</p>`);
41+
});
42+
43+
it('shares the server polyfills copy of Domino with the main server bundle', async () => {
44+
harness.useTarget('build', {
45+
...BASE_OPTIONS,
46+
server: 'src/main.server.ts',
47+
ssr: true,
48+
});
49+
50+
const { result } = await harness.executeOnce();
51+
expect(result?.success).toBeTrue();
52+
53+
harness.expectFile('dist/server/polyfills.server.mjs').content.toContain(DOMINO_BUNDLE);
54+
harness.expectFile('dist/server/polyfills.server.mjs').content.toMatch(POLYFILLS_EXPORT_RE);
55+
56+
harness.expectFile('dist/server/main.server.mjs').content.not.toContain(DOMINO_BUNDLE);
57+
harness.expectFile('dist/server/main.server.mjs').content.toMatch(MAIN_IMPORT_RE);
58+
});
59+
60+
it('shares the server polyfills copy of Domino when "outputMode" is "server"', async () => {
61+
harness.useTarget('build', {
62+
...BASE_OPTIONS,
63+
server: 'src/main.server.ts',
64+
ssr: { entry: 'src/server.ts' },
65+
outputMode: OutputMode.Server,
66+
});
67+
68+
const { result } = await harness.executeOnce();
69+
expect(result?.success).toBeTrue();
70+
71+
harness.expectFile('dist/server/polyfills.server.mjs').content.toContain(DOMINO_BUNDLE);
72+
harness.expectFile('dist/server/main.server.mjs').content.not.toContain(DOMINO_BUNDLE);
73+
harness.expectFile('dist/server/main.server.mjs').content.toMatch(MAIN_IMPORT_RE);
74+
harness.expectFile('dist/server/server.mjs').content.not.toContain(DOMINO_BUNDLE);
75+
});
76+
77+
it('shares the server polyfills copy of Domino when "ssr.platform" is "neutral"', async () => {
78+
harness.useTarget('build', {
79+
...BASE_OPTIONS,
80+
server: 'src/main.server.ts',
81+
ssr: { entry: 'src/server.ts', platform: Platform.Neutral },
82+
outputMode: OutputMode.Server,
83+
});
84+
85+
const { result } = await harness.executeOnce();
86+
expect(result?.success).toBeTrue();
87+
88+
harness.expectFile('dist/server/polyfills.server.mjs').content.toContain(DOMINO_BUNDLE);
89+
harness.expectFile('dist/server/main.server.mjs').content.not.toContain(DOMINO_BUNDLE);
90+
});
91+
});
92+
});

‎packages/angular/build/src/tools/esbuild/application-code-bundle.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import type { BuildOptions, Plugin } from 'esbuild';
9+
import type { BuildOptions, Plugin, PluginBuild } from 'esbuild';
1010
import assert from 'node:assert';
1111
import { extname, relative } from 'node:path';
1212
import type { NormalizedApplicationBuildOptions } from '../../builders/application/options';
@@ -30,6 +30,11 @@ import { createExternalPackagesPlugin } from './external-packages-plugin';
3030
import { createAngularLocaleDataPlugin } from './i18n-locale-plugin';
3131
import type { LoadResultCache } from './load-result-cache';
3232
import { createLoaderImportAttributePlugin } from './loader-import-attribute-plugin';
33+
import {
34+
createPlatformServerDominoExport,
35+
createPlatformServerDominoPlugin,
36+
resolvePlatformServerDomino,
37+
} from './platform-server-domino-plugin';
3338
import { createRxjsEsmResolutionPlugin } from './rxjs-esm-resolution-plugin';
3439
import { createServerBundleMetadata } from './server-bundle-metadata-plugin';
3540
import { createSourcemapIgnorelistPlugin } from './sourcemap-ignorelist-plugin';
@@ -192,6 +197,13 @@ export function createServerPolyfillBundleOptions(
192197
namespace,
193198
false,
194199
loadResultCache,
200+
// Export the Domino copy bundled via `@angular/platform-server/init` so that the other
201+
// server bundles can share it instead of bundling their own copy.
202+
async (build) => {
203+
const dominoPath = await resolvePlatformServerDomino(build, options.workspaceRoot);
204+
205+
return dominoPath && createPlatformServerDominoExport(dominoPath);
206+
},
195207
);
196208

197209
if (!polyfillBundleOptions) {
@@ -308,6 +320,9 @@ export function createServerMainCodeBundleOptions(
308320
buildOptions.plugins.push(createRxjsEsmResolutionPlugin());
309321
}
310322

323+
// Use the Domino copy exported by the server polyfills bundle instead of bundling another copy.
324+
buildOptions.plugins.push(createPlatformServerDominoPlugin(workspaceRoot));
325+
311326
// Mark manifest and polyfills file as external as these are generated by a different bundle step.
312327
(buildOptions.external ??= []).push(...SERVER_GENERATED_EXTERNALS);
313328
const isNodePlatform = options.ssrOptions?.platform !== Platform.Neutral;
@@ -677,6 +692,7 @@ function getEsBuildCommonPolyfillsOptions(
677692
namespace: string,
678693
tryToResolvePolyfillsAsRelative: boolean,
679694
loadResultCache: LoadResultCache | undefined,
695+
additionalContent?: (build: PluginBuild) => Promise<string | undefined>,
680696
): BuildOptions | undefined {
681697
const { jit, workspaceRoot, i18nOptions, externalPackages } = options;
682698

@@ -753,6 +769,11 @@ function getEsBuildCommonPolyfillsOptions(
753769
contents += `(globalThis.$localize ??= {}).locale = "${i18nOptions.sourceLocale}";\n`;
754770
}
755771

772+
const extraContent = await additionalContent?.(build);
773+
if (extraContent) {
774+
contents += '\n' + extraContent;
775+
}
776+
756777
return {
757778
contents,
758779
loader: 'js',
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import type { Plugin, PluginBuild } from 'esbuild';
10+
import { access } from 'node:fs/promises';
11+
import { dirname, join, resolve } from 'node:path';
12+
import { toPosixPath } from '../../utils/path';
13+
14+
/**
15+
* The name of the export in the server polyfills bundle that provides the Domino DOM
16+
* implementation to the other server bundles.
17+
*/
18+
const SERVER_POLYFILLS_DOMINO_EXPORT = 'ɵdomino';
19+
20+
/**
21+
* The server polyfills bundle filename. Other server bundles import Domino from this file.
22+
*/
23+
const SERVER_POLYFILLS_FILENAME = './polyfills.server.mjs';
24+
25+
/**
26+
* The path of the Domino bundle within the `@angular/platform-server` package.
27+
* `@angular/platform-server/init` and `@angular/platform-server` both import this file.
28+
*/
29+
const DOMINO_PACKAGE_PATH = 'third_party/domino/bundled-domino.mjs';
30+
31+
/**
32+
* The import specifier used by the `@angular/platform-server` entry points to import Domino.
33+
*/
34+
const DOMINO_IMPORT_SPECIFIER = '../' + DOMINO_PACKAGE_PATH;
35+
36+
const DOMINO_NAMESPACE = 'angular:platform-server-domino';
37+
38+
/**
39+
* Resolves the path of the Domino bundle within the `@angular/platform-server` package
40+
* that is used by the application.
41+
*
42+
* `@angular/platform-server/init` (bundled into the server polyfills) and
43+
* `@angular/platform-server` (bundled into the main server bundle) both contain a copy of
44+
* Domino when bundled separately. The server polyfills bundle exports its copy via
45+
* `SERVER_POLYFILLS_DOMINO_EXPORT` so that the other server bundles can share it instead of
46+
* bundling their own.
47+
*
48+
* @param build The esbuild plugin build used to resolve the package.
49+
* @param workspaceRoot The root directory of the workspace.
50+
* @returns The absolute path to the Domino bundle, or `undefined` if it cannot be shared.
51+
*/
52+
export async function resolvePlatformServerDomino(
53+
build: PluginBuild,
54+
workspaceRoot: string,
55+
): Promise<string | undefined> {
56+
const result = await build.resolve('@angular/platform-server/package.json', {
57+
kind: 'import-statement',
58+
resolveDir: workspaceRoot,
59+
});
60+
61+
// The package is not installed or will not be bundled.
62+
if (result.errors.length > 0 || result.external || !result.path) {
63+
return undefined;
64+
}
65+
66+
const dominoPath = join(dirname(result.path), DOMINO_PACKAGE_PATH);
67+
try {
68+
await access(dominoPath);
69+
} catch {
70+
// Unexpected package layout. Each bundle will contain its own copy of Domino.
71+
return undefined;
72+
}
73+
74+
return dominoPath;
75+
}
76+
77+
/**
78+
* Creates the module content that re-exports Domino from the server polyfills bundle.
79+
*
80+
* @param dominoPath The absolute path to the Domino bundle.
81+
* @returns The module content.
82+
*/
83+
export function createPlatformServerDominoExport(dominoPath: string): string {
84+
const specifier = JSON.stringify(toPosixPath(dominoPath));
85+
86+
return `export { default as ${SERVER_POLYFILLS_DOMINO_EXPORT} } from ${specifier};`;
87+
}
88+
89+
/**
90+
* Creates a plugin that redirects the Domino import of `@angular/platform-server` to the
91+
* copy exported by the server polyfills bundle.
92+
*
93+
* The server polyfills bundle is imported by every server bundle before any other code and
94+
* is marked as external. This ensures a single copy of Domino is loaded at runtime, which
95+
* also keeps the DOM globals provided by `@angular/platform-server/init` consistent with the
96+
* DOM implementation used by the server platform.
97+
*
98+
* @param workspaceRoot The root directory of the workspace.
99+
* @returns An esbuild plugin.
100+
*/
101+
export function createPlatformServerDominoPlugin(workspaceRoot: string): Plugin {
102+
return {
103+
name: 'angular-platform-server-domino',
104+
setup(build) {
105+
let sharedDominoPath: Promise<string | undefined> | undefined;
106+
107+
build.onResolve({ filter: /bundled-domino\.mjs$/ }, async (args) => {
108+
if (args.path !== DOMINO_IMPORT_SPECIFIER || args.kind !== 'import-statement') {
109+
return null;
110+
}
111+
112+
sharedDominoPath ??= resolvePlatformServerDomino(build, workspaceRoot);
113+
const dominoPath = await sharedDominoPath;
114+
115+
// Only redirect imports of the exact file exported by the server polyfills bundle.
116+
if (!dominoPath || resolve(args.resolveDir, args.path) !== dominoPath) {
117+
return null;
118+
}
119+
120+
// The path must not look like a JavaScript file to avoid other plugins loading it.
121+
return {
122+
path: DOMINO_NAMESPACE,
123+
namespace: DOMINO_NAMESPACE,
124+
};
125+
});
126+
127+
build.onLoad({ filter: /./, namespace: DOMINO_NAMESPACE }, () => ({
128+
contents:
129+
`export { ${SERVER_POLYFILLS_DOMINO_EXPORT} as default } ` +
130+
`from '${SERVER_POLYFILLS_FILENAME}';`,
131+
loader: 'js',
132+
resolveDir: workspaceRoot,
133+
}));
134+
},
135+
};
136+
}

0 commit comments

Comments
 (0)