Skip to content
Merged
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
4 changes: 3 additions & 1 deletion packages/angular/build/src/builders/application/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
import { urlJoin } from '../../utils/url';
import {
Schema as ApplicationBuilderOptions,
ExperimentalPlatform,
I18NTranslation,
OutputHashing,
OutputMode,
Expand Down Expand Up @@ -264,10 +265,11 @@ export async function normalizeOptions(
if (options.ssr === true) {
ssrOptions = {};
} else if (typeof options.ssr === 'object') {
const { entry } = options.ssr;
const { entry, experimentalPlatform = ExperimentalPlatform.Node } = options.ssr;

ssrOptions = {
entry: entry && path.join(workspaceRoot, entry),
platform: experimentalPlatform,
};
}

Expand Down
5 changes: 5 additions & 0 deletions packages/angular/build/src/builders/application/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,11 @@
"entry": {
"type": "string",
"description": "The server entry-point that when executed will spawn the web server."
},
"experimentalPlatform": {
"description": "Specifies the platform for which the server bundle is generated. This affects the APIs and modules available in the server-side code. \n\n- `node`: (Default) Generates a bundle optimized for Node.js environments. \n- `neutral`: Generates a platform-neutral bundle suitable for environments like edge workers, and other serverless platforms. This option avoids using Node.js-specific APIs, making the bundle more portable. \n\nPlease note that this feature does not provide polyfills for Node.js modules. Additionally, it is experimental, and the schematics may undergo changes in future versions.",
"default": "node",
"enum": ["node", "neutral"]
}
},
"additionalProperties": false
Expand Down
70 changes: 47 additions & 23 deletions packages/angular/build/src/tools/esbuild/application-code-bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import assert from 'node:assert';
import { createHash } from 'node:crypto';
import { extname, relative } from 'node:path';
import type { NormalizedApplicationBuildOptions } from '../../builders/application/options';
import { ExperimentalPlatform } from '../../builders/application/schema';
import { allowMangle } from '../../utils/environment-options';
import {
SERVER_APP_ENGINE_MANIFEST_FILENAME,
Expand All @@ -24,6 +25,7 @@ import { createExternalPackagesPlugin } from './external-packages-plugin';
import { createAngularLocaleDataPlugin } from './i18n-locale-plugin';
import { createLoaderImportAttributePlugin } from './loader-import-attribute-plugin';
import { createRxjsEsmResolutionPlugin } from './rxjs-esm-resolution-plugin';
import { createServerBundleMetadata } from './server-bundle-metadata-plugin';
import { createSourcemapIgnorelistPlugin } from './sourcemap-ignorelist-plugin';
import { SERVER_GENERATED_EXTERNALS, getFeatureSupport, isZonelessApp } from './utils';
import { createVirtualModulePlugin } from './virtual-module-plugin';
Expand Down Expand Up @@ -160,8 +162,10 @@ export function createServerPolyfillBundleOptions(
): BundlerOptionsFactory | undefined {
const serverPolyfills: string[] = [];
const polyfillsFromConfig = new Set(options.polyfills);
const isNodePlatform = options.ssrOptions?.platform !== ExperimentalPlatform.Neutral;

if (!isZonelessApp(options.polyfills)) {
serverPolyfills.push('zone.js/node');
serverPolyfills.push(isNodePlatform ? 'zone.js/node' : 'zone.js');
}

if (
Expand Down Expand Up @@ -190,28 +194,33 @@ export function createServerPolyfillBundleOptions(

const buildOptions: BuildOptions = {
...polyfillBundleOptions,
platform: 'node',
platform: isNodePlatform ? 'node' : 'neutral',
outExtension: { '.js': '.mjs' },
// Note: `es2015` is needed for RxJS v6. If not specified, `module` would
// match and the ES5 distribution would be bundled and ends up breaking at
// runtime with the RxJS testing library.
// More details: https://github.com/angular/angular-cli/issues/25405.
mainFields: ['es2020', 'es2015', 'module', 'main'],
entryNames: '[name]',
banner: {
js: [
// Note: Needed as esbuild does not provide require shims / proxy from ESModules.
// See: https://github.com/evanw/esbuild/issues/1921.
`import { createRequire } from 'node:module';`,
`globalThis['require'] ??= createRequire(import.meta.url);`,
].join('\n'),
},
banner: isNodePlatform
? {
js: [
// Note: Needed as esbuild does not provide require shims / proxy from ESModules.
// See: https://github.com/evanw/esbuild/issues/1921.
`import { createRequire } from 'node:module';`,
`globalThis['require'] ??= createRequire(import.meta.url);`,
].join('\n'),
}
: undefined,
target,
entryPoints: {
'polyfills.server': namespace,
},
};

buildOptions.plugins ??= [];
buildOptions.plugins.push(createServerBundleMetadata());

return () => buildOptions;
}

Expand Down Expand Up @@ -285,8 +294,17 @@ export function createServerMainCodeBundleOptions(

// 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 !== ExperimentalPlatform.Neutral;

if (!isNodePlatform) {
// `@angular/platform-server` lazily depends on `xhr2` for XHR usage with the HTTP client.
// Since `xhr2` has Node.js dependencies, it cannot be used when targeting non-Node.js platforms.
// Note: The framework already issues a warning when using XHR with SSR.
buildOptions.external.push('xhr2');
}

buildOptions.plugins.push(
createServerBundleMetadata(),
createVirtualModulePlugin({
namespace: mainServerInjectPolyfillsNamespace,
cache: sourceFileCache?.loadResultCache,
Expand Down Expand Up @@ -373,6 +391,13 @@ export function createSsrEntryCodeBundleOptions(
const ssrEntryNamespace = 'angular:ssr-entry';
const ssrInjectManifestNamespace = 'angular:ssr-entry-inject-manifest';
const ssrInjectRequireNamespace = 'angular:ssr-entry-inject-require';
const isNodePlatform = options.ssrOptions?.platform !== ExperimentalPlatform.Neutral;

const inject: string[] = [ssrInjectManifestNamespace];
if (isNodePlatform) {
inject.unshift(ssrInjectRequireNamespace);
}

const buildOptions: BuildOptions = {
...getEsBuildServerCommonOptions(options),
target,
Expand All @@ -390,7 +415,7 @@ export function createSsrEntryCodeBundleOptions(
styleOptions,
),
],
inject: [ssrInjectRequireNamespace, ssrInjectManifestNamespace],
inject,
};

buildOptions.plugins ??= [];
Expand All @@ -404,18 +429,15 @@ export function createSsrEntryCodeBundleOptions(
// Mark manifest file as external. As this will be generated later on.
(buildOptions.external ??= []).push('*/main.server.mjs', ...SERVER_GENERATED_EXTERNALS);

if (!isNodePlatform) {
// `@angular/platform-server` lazily depends on `xhr2` for XHR usage with the HTTP client.
// Since `xhr2` has Node.js dependencies, it cannot be used when targeting non-Node.js platforms.
// Note: The framework already issues a warning when using XHR with SSR.
buildOptions.external.push('xhr2');
}

buildOptions.plugins.push(
{
name: 'angular-ssr-metadata',
setup(build) {
build.onEnd((result) => {
if (result.metafile) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(result.metafile as any)['ng-ssr-entry-bundle'] = true;
}
});
},
},
createServerBundleMetadata({ ssrEntryBundle: true }),
createVirtualModulePlugin({
namespace: ssrInjectRequireNamespace,
cache: sourceFileCache?.loadResultCache,
Expand Down Expand Up @@ -490,9 +512,11 @@ export function createSsrEntryCodeBundleOptions(
}

function getEsBuildServerCommonOptions(options: NormalizedApplicationBuildOptions): BuildOptions {
const isNodePlatform = options.ssrOptions?.platform !== ExperimentalPlatform.Neutral;

return {
...getEsBuildCommonOptions(options),
platform: 'node',
platform: isNodePlatform ? 'node' : 'neutral',
outExtension: { '.js': '.mjs' },
// Note: `es2015` is needed for RxJS v6. If not specified, `module` would
// match and the ES5 distribution would be bundled and ends up breaking at
Expand Down
25 changes: 14 additions & 11 deletions packages/angular/build/src/tools/esbuild/bundler-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,14 @@ export class BundlerContext {
};
}

const {
'ng-platform-server': isPlatformServer = false,
'ng-ssr-entry-bundle': isSsrEntryBundle = false,
} = result.metafile as Metafile & {
'ng-platform-server'?: boolean;
'ng-ssr-entry-bundle'?: boolean;
};

// Find all initial files
const initialFiles = new Map<string, InitialFileRecord>();
for (const outputFile of result.outputFiles) {
Expand All @@ -299,7 +307,7 @@ export class BundlerContext {
name,
type,
entrypoint: true,
serverFile: this.#platformIsServer,
serverFile: isPlatformServer,
depth: 0,
};

Expand Down Expand Up @@ -332,7 +340,7 @@ export class BundlerContext {
type: initialImport.kind === 'import-rule' ? 'style' : 'script',
entrypoint: false,
external: initialImport.external,
serverFile: this.#platformIsServer,
serverFile: isPlatformServer,
depth: entryRecord.depth + 1,
};

Expand Down Expand Up @@ -371,9 +379,8 @@ export class BundlerContext {
// All files that are not JS, CSS, WASM, or sourcemaps for them are considered media
if (!/\.([cm]?js|css|wasm)(\.map)?$/i.test(file.path)) {
fileType = BuildOutputFileType.Media;
} else if (this.#platformIsServer) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fileType = (result.metafile as any)['ng-ssr-entry-bundle']
} else if (isPlatformServer) {
fileType = isSsrEntryBundle
? BuildOutputFileType.ServerRoot
: BuildOutputFileType.ServerApplication;
} else {
Expand All @@ -384,7 +391,7 @@ export class BundlerContext {
});

let externalConfiguration = this.#esbuildOptions.external;
if (this.#platformIsServer && externalConfiguration) {
if (isPlatformServer && externalConfiguration) {
externalConfiguration = externalConfiguration.filter(
(dep) => !SERVER_GENERATED_EXTERNALS.has(dep),
);
Expand All @@ -400,7 +407,7 @@ export class BundlerContext {
outputFiles,
initialFiles,
externalImports: {
[this.#platformIsServer ? 'server' : 'browser']: externalImports,
[isPlatformServer ? 'server' : 'browser']: externalImports,
},
externalConfiguration,
errors: undefined,
Expand All @@ -422,10 +429,6 @@ export class BundlerContext {
}
}

get #platformIsServer(): boolean {
return this.#esbuildOptions?.platform === 'node';
}

/**
* Invalidate a stored bundler result based on the previous watch files
* and a list of changed files.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* @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 } from 'esbuild';

/**
* Generates an esbuild plugin that appends metadata to the output bundle,
* marking it with server-side rendering (SSR) details for Angular SSR scenarios.
*
* @param options Optional configuration object.
* - `ssrEntryBundle`: If `true`, marks the bundle as an SSR entry point.
*
* @note We can't rely on `platform: node` or `platform: neutral`, as the latter
* is used for non-SSR-related code too (e.g., global scripts).
* @returns An esbuild plugin that injects SSR metadata into the build result's metafile.
*/
export function createServerBundleMetadata(options?: { ssrEntryBundle?: boolean }): Plugin {
return {
name: 'angular-server-bundle-metadata',
setup(build) {
build.onEnd((result) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const metafile = result.metafile as any;
if (metafile) {
metafile['ng-ssr-entry-bundle'] = !!options?.ssrEntryBundle;
metafile['ng-platform-server'] = true;
}
});
},
};
}
Loading