diff --git a/packages/angular/build/src/builders/application/tests/behavior/platform-server-domino_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/platform-server-domino_spec.ts new file mode 100644 index 000000000000..14d3915e491a --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/platform-server-domino_spec.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { OutputMode, Platform } from '../../schema'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +/** + * `@angular/platform-server/init` (bundled into the server polyfills) and + * `@angular/platform-server` (bundled into the main server bundle) both import this file. + */ +const DOMINO_BUNDLE = 'third_party/domino/bundled-domino.mjs'; + +/** The export of the server polyfills bundle. The output may escape non-ASCII identifiers. */ +const DOMINO_EXPORT = '(?:ɵ|\\\\u0275)domino'; +const POLYFILLS_EXPORT_RE = new RegExp(`export \\{[^}]*${DOMINO_EXPORT}`); +const MAIN_IMPORT_RE = new RegExp( + `import \\{\\s*${DOMINO_EXPORT}\\s*\\} from "\\./polyfills\\.server\\.mjs"`, +); + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Domino is bundled once for the server"', () => { + beforeEach(async () => { + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('main.server.ts', 'server.ts'); + + return JSON.stringify(tsConfig); + }); + + await harness.writeFile('src/server.ts', `console.log('Hello!');`); + + // The default template uses i18n which requires `@angular/localize` to prerender. + await harness.writeFile('src/app/app.component.html', `

Hello!

`); + }); + + it('shares the server polyfills copy of Domino with the main server bundle', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/server/polyfills.server.mjs').content.toContain(DOMINO_BUNDLE); + harness.expectFile('dist/server/polyfills.server.mjs').content.toMatch(POLYFILLS_EXPORT_RE); + + harness.expectFile('dist/server/main.server.mjs').content.not.toContain(DOMINO_BUNDLE); + harness.expectFile('dist/server/main.server.mjs').content.toMatch(MAIN_IMPORT_RE); + }); + + it('shares the server polyfills copy of Domino when "outputMode" is "server"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: { entry: 'src/server.ts' }, + outputMode: OutputMode.Server, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/server/polyfills.server.mjs').content.toContain(DOMINO_BUNDLE); + harness.expectFile('dist/server/main.server.mjs').content.not.toContain(DOMINO_BUNDLE); + harness.expectFile('dist/server/main.server.mjs').content.toMatch(MAIN_IMPORT_RE); + harness.expectFile('dist/server/server.mjs').content.not.toContain(DOMINO_BUNDLE); + }); + + it('shares the server polyfills copy of Domino when "ssr.platform" is "neutral"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: { entry: 'src/server.ts', platform: Platform.Neutral }, + outputMode: OutputMode.Server, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/server/polyfills.server.mjs').content.toContain(DOMINO_BUNDLE); + harness.expectFile('dist/server/main.server.mjs').content.not.toContain(DOMINO_BUNDLE); + }); + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts index c98ad3bcae28..e362327848ab 100644 --- a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts +++ b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import type { BuildOptions, Plugin } from 'esbuild'; +import type { BuildOptions, Plugin, PluginBuild } from 'esbuild'; import assert from 'node:assert'; import { extname, relative } from 'node:path'; import type { NormalizedApplicationBuildOptions } from '../../builders/application/options'; @@ -30,6 +30,11 @@ import { createExternalPackagesPlugin } from './external-packages-plugin'; import { createAngularLocaleDataPlugin } from './i18n-locale-plugin'; import type { LoadResultCache } from './load-result-cache'; import { createLoaderImportAttributePlugin } from './loader-import-attribute-plugin'; +import { + createPlatformServerDominoExport, + createPlatformServerDominoPlugin, + resolvePlatformServerDomino, +} from './platform-server-domino-plugin'; import { createRxjsEsmResolutionPlugin } from './rxjs-esm-resolution-plugin'; import { createServerBundleMetadata } from './server-bundle-metadata-plugin'; import { createSourcemapIgnorelistPlugin } from './sourcemap-ignorelist-plugin'; @@ -192,6 +197,13 @@ export function createServerPolyfillBundleOptions( namespace, false, loadResultCache, + // Export the Domino copy bundled via `@angular/platform-server/init` so that the other + // server bundles can share it instead of bundling their own copy. + async (build) => { + const dominoPath = await resolvePlatformServerDomino(build, options.workspaceRoot); + + return dominoPath && createPlatformServerDominoExport(dominoPath); + }, ); if (!polyfillBundleOptions) { @@ -308,6 +320,9 @@ export function createServerMainCodeBundleOptions( buildOptions.plugins.push(createRxjsEsmResolutionPlugin()); } + // Use the Domino copy exported by the server polyfills bundle instead of bundling another copy. + buildOptions.plugins.push(createPlatformServerDominoPlugin(workspaceRoot)); + // Mark manifest and polyfills file as external as these are generated by a different bundle step. (buildOptions.external ??= []).push(...SERVER_GENERATED_EXTERNALS); const isNodePlatform = options.ssrOptions?.platform !== Platform.Neutral; @@ -677,6 +692,7 @@ function getEsBuildCommonPolyfillsOptions( namespace: string, tryToResolvePolyfillsAsRelative: boolean, loadResultCache: LoadResultCache | undefined, + additionalContent?: (build: PluginBuild) => Promise, ): BuildOptions | undefined { const { jit, workspaceRoot, i18nOptions, externalPackages } = options; @@ -753,6 +769,11 @@ function getEsBuildCommonPolyfillsOptions( contents += `(globalThis.$localize ??= {}).locale = "${i18nOptions.sourceLocale}";\n`; } + const extraContent = await additionalContent?.(build); + if (extraContent) { + contents += '\n' + extraContent; + } + return { contents, loader: 'js', diff --git a/packages/angular/build/src/tools/esbuild/platform-server-domino-plugin.ts b/packages/angular/build/src/tools/esbuild/platform-server-domino-plugin.ts new file mode 100644 index 000000000000..dc060b3cc180 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/platform-server-domino-plugin.ts @@ -0,0 +1,136 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Plugin, PluginBuild } from 'esbuild'; +import { access } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { toPosixPath } from '../../utils/path'; + +/** + * The name of the export in the server polyfills bundle that provides the Domino DOM + * implementation to the other server bundles. + */ +const SERVER_POLYFILLS_DOMINO_EXPORT = 'ɵdomino'; + +/** + * The server polyfills bundle filename. Other server bundles import Domino from this file. + */ +const SERVER_POLYFILLS_FILENAME = './polyfills.server.mjs'; + +/** + * The path of the Domino bundle within the `@angular/platform-server` package. + * `@angular/platform-server/init` and `@angular/platform-server` both import this file. + */ +const DOMINO_PACKAGE_PATH = 'third_party/domino/bundled-domino.mjs'; + +/** + * The import specifier used by the `@angular/platform-server` entry points to import Domino. + */ +const DOMINO_IMPORT_SPECIFIER = '../' + DOMINO_PACKAGE_PATH; + +const DOMINO_NAMESPACE = 'angular:platform-server-domino'; + +/** + * Resolves the path of the Domino bundle within the `@angular/platform-server` package + * that is used by the application. + * + * `@angular/platform-server/init` (bundled into the server polyfills) and + * `@angular/platform-server` (bundled into the main server bundle) both contain a copy of + * Domino when bundled separately. The server polyfills bundle exports its copy via + * `SERVER_POLYFILLS_DOMINO_EXPORT` so that the other server bundles can share it instead of + * bundling their own. + * + * @param build The esbuild plugin build used to resolve the package. + * @param workspaceRoot The root directory of the workspace. + * @returns The absolute path to the Domino bundle, or `undefined` if it cannot be shared. + */ +export async function resolvePlatformServerDomino( + build: PluginBuild, + workspaceRoot: string, +): Promise { + const result = await build.resolve('@angular/platform-server/package.json', { + kind: 'import-statement', + resolveDir: workspaceRoot, + }); + + // The package is not installed or will not be bundled. + if (result.errors.length > 0 || result.external || !result.path) { + return undefined; + } + + const dominoPath = join(dirname(result.path), DOMINO_PACKAGE_PATH); + try { + await access(dominoPath); + } catch { + // Unexpected package layout. Each bundle will contain its own copy of Domino. + return undefined; + } + + return dominoPath; +} + +/** + * Creates the module content that re-exports Domino from the server polyfills bundle. + * + * @param dominoPath The absolute path to the Domino bundle. + * @returns The module content. + */ +export function createPlatformServerDominoExport(dominoPath: string): string { + const specifier = JSON.stringify(toPosixPath(dominoPath)); + + return `export { default as ${SERVER_POLYFILLS_DOMINO_EXPORT} } from ${specifier};`; +} + +/** + * Creates a plugin that redirects the Domino import of `@angular/platform-server` to the + * copy exported by the server polyfills bundle. + * + * The server polyfills bundle is imported by every server bundle before any other code and + * is marked as external. This ensures a single copy of Domino is loaded at runtime, which + * also keeps the DOM globals provided by `@angular/platform-server/init` consistent with the + * DOM implementation used by the server platform. + * + * @param workspaceRoot The root directory of the workspace. + * @returns An esbuild plugin. + */ +export function createPlatformServerDominoPlugin(workspaceRoot: string): Plugin { + return { + name: 'angular-platform-server-domino', + setup(build) { + let sharedDominoPath: Promise | undefined; + + build.onResolve({ filter: /bundled-domino\.mjs$/ }, async (args) => { + if (args.path !== DOMINO_IMPORT_SPECIFIER || args.kind !== 'import-statement') { + return null; + } + + sharedDominoPath ??= resolvePlatformServerDomino(build, workspaceRoot); + const dominoPath = await sharedDominoPath; + + // Only redirect imports of the exact file exported by the server polyfills bundle. + if (!dominoPath || resolve(args.resolveDir, args.path) !== dominoPath) { + return null; + } + + // The path must not look like a JavaScript file to avoid other plugins loading it. + return { + path: DOMINO_NAMESPACE, + namespace: DOMINO_NAMESPACE, + }; + }); + + build.onLoad({ filter: /./, namespace: DOMINO_NAMESPACE }, () => ({ + contents: + `export { ${SERVER_POLYFILLS_DOMINO_EXPORT} as default } ` + + `from '${SERVER_POLYFILLS_FILENAME}';`, + loader: 'js', + resolveDir: workspaceRoot, + })); + }, + }; +}