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