Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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', `<p>Hello!</p>`);
});

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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -677,6 +692,7 @@ function getEsBuildCommonPolyfillsOptions(
namespace: string,
tryToResolvePolyfillsAsRelative: boolean,
loadResultCache: LoadResultCache | undefined,
additionalContent?: (build: PluginBuild) => Promise<string | undefined>,
): BuildOptions | undefined {
const { jit, workspaceRoot, i18nOptions, externalPackages } = options;

Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string | undefined> {
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<string | undefined> | 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,
}));
},
};
}
Loading