Skip to content

Commit 7d883a1

Browse files
committed
feat(@angular/build): introduce ssr.experimentalPlatform option
This commit introduces a new option called `experimentalPlatform` to the Angular SSR configuration. The `experimentalPlatform` option allows developers to specify the target platform for the server bundle, enabling the generation of platform-neutral bundles suitable for deployment in environments like edge workers and other serverless platforms that do not rely on Node.js APIs. This change enhances the portability of Angular SSR applications and expands their deployment possibilities. **Note:** that this feature does not include polyfills for Node.js modules and is experimental, subject to future changes.
1 parent accaa57 commit 7d883a1

File tree

6 files changed

+238
-35
lines changed

6 files changed

+238
-35
lines changed

packages/angular/build/src/builders/application/options.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
import { urlJoin } from '../../utils/url';
2828
import {
2929
Schema as ApplicationBuilderOptions,
30+
ExperimentalPlatform,
3031
I18NTranslation,
3132
OutputHashing,
3233
OutputMode,
@@ -264,10 +265,11 @@ export async function normalizeOptions(
264265
if (options.ssr === true) {
265266
ssrOptions = {};
266267
} else if (typeof options.ssr === 'object') {
267-
const { entry } = options.ssr;
268+
const { entry, experimentalPlatform = ExperimentalPlatform.Node } = options.ssr;
268269

269270
ssrOptions = {
270271
entry: entry && path.join(workspaceRoot, entry),
272+
platform: experimentalPlatform,
271273
};
272274
}
273275

packages/angular/build/src/builders/application/schema.json

+5
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,11 @@
518518
"entry": {
519519
"type": "string",
520520
"description": "The server entry-point that when executed will spawn the web server."
521+
},
522+
"experimentalPlatform": {
523+
"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.",
524+
"default": "node",
525+
"enum": ["node", "neutral"]
521526
}
522527
},
523528
"additionalProperties": false

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

+47-23
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import assert from 'node:assert';
1111
import { createHash } from 'node:crypto';
1212
import { extname, relative } from 'node:path';
1313
import type { NormalizedApplicationBuildOptions } from '../../builders/application/options';
14+
import { ExperimentalPlatform } from '../../builders/application/schema';
1415
import { allowMangle } from '../../utils/environment-options';
1516
import {
1617
SERVER_APP_ENGINE_MANIFEST_FILENAME,
@@ -24,6 +25,7 @@ import { createExternalPackagesPlugin } from './external-packages-plugin';
2425
import { createAngularLocaleDataPlugin } from './i18n-locale-plugin';
2526
import { createLoaderImportAttributePlugin } from './loader-import-attribute-plugin';
2627
import { createRxjsEsmResolutionPlugin } from './rxjs-esm-resolution-plugin';
28+
import { createServerBundleMetadata } from './server-bundle-metadata-plugin';
2729
import { createSourcemapIgnorelistPlugin } from './sourcemap-ignorelist-plugin';
2830
import { SERVER_GENERATED_EXTERNALS, getFeatureSupport, isZonelessApp } from './utils';
2931
import { createVirtualModulePlugin } from './virtual-module-plugin';
@@ -160,8 +162,10 @@ export function createServerPolyfillBundleOptions(
160162
): BundlerOptionsFactory | undefined {
161163
const serverPolyfills: string[] = [];
162164
const polyfillsFromConfig = new Set(options.polyfills);
165+
const isNodePlatform = options.ssrOptions?.platform !== ExperimentalPlatform.Neutral;
166+
163167
if (!isZonelessApp(options.polyfills)) {
164-
serverPolyfills.push('zone.js/node');
168+
serverPolyfills.push(isNodePlatform ? 'zone.js/node' : 'zone.js');
165169
}
166170

167171
if (
@@ -190,28 +194,33 @@ export function createServerPolyfillBundleOptions(
190194

191195
const buildOptions: BuildOptions = {
192196
...polyfillBundleOptions,
193-
platform: 'node',
197+
platform: isNodePlatform ? 'node' : 'neutral',
194198
outExtension: { '.js': '.mjs' },
195199
// Note: `es2015` is needed for RxJS v6. If not specified, `module` would
196200
// match and the ES5 distribution would be bundled and ends up breaking at
197201
// runtime with the RxJS testing library.
198202
// More details: https://github.com/angular/angular-cli/issues/25405.
199203
mainFields: ['es2020', 'es2015', 'module', 'main'],
200204
entryNames: '[name]',
201-
banner: {
202-
js: [
203-
// Note: Needed as esbuild does not provide require shims / proxy from ESModules.
204-
// See: https://github.com/evanw/esbuild/issues/1921.
205-
`import { createRequire } from 'node:module';`,
206-
`globalThis['require'] ??= createRequire(import.meta.url);`,
207-
].join('\n'),
208-
},
205+
banner: isNodePlatform
206+
? {
207+
js: [
208+
// Note: Needed as esbuild does not provide require shims / proxy from ESModules.
209+
// See: https://github.com/evanw/esbuild/issues/1921.
210+
`import { createRequire } from 'node:module';`,
211+
`globalThis['require'] ??= createRequire(import.meta.url);`,
212+
].join('\n'),
213+
}
214+
: undefined,
209215
target,
210216
entryPoints: {
211217
'polyfills.server': namespace,
212218
},
213219
};
214220

221+
buildOptions.plugins ??= [];
222+
buildOptions.plugins.push(createServerBundleMetadata());
223+
215224
return () => buildOptions;
216225
}
217226

@@ -285,8 +294,17 @@ export function createServerMainCodeBundleOptions(
285294

286295
// Mark manifest and polyfills file as external as these are generated by a different bundle step.
287296
(buildOptions.external ??= []).push(...SERVER_GENERATED_EXTERNALS);
297+
const isNodePlatform = options.ssrOptions?.platform !== ExperimentalPlatform.Neutral;
298+
299+
if (!isNodePlatform) {
300+
// `@angular/platform-server` lazily depends on `xhr2` for XHR usage with the HTTP client.
301+
// Since `xhr2` has Node.js dependencies, it cannot be used when targeting non-Node.js platforms.
302+
// Note: The framework already issues a warning when using XHR with SSR.
303+
buildOptions.external.push('xhr2');
304+
}
288305

289306
buildOptions.plugins.push(
307+
createServerBundleMetadata(),
290308
createVirtualModulePlugin({
291309
namespace: mainServerInjectPolyfillsNamespace,
292310
cache: sourceFileCache?.loadResultCache,
@@ -373,6 +391,13 @@ export function createSsrEntryCodeBundleOptions(
373391
const ssrEntryNamespace = 'angular:ssr-entry';
374392
const ssrInjectManifestNamespace = 'angular:ssr-entry-inject-manifest';
375393
const ssrInjectRequireNamespace = 'angular:ssr-entry-inject-require';
394+
const isNodePlatform = options.ssrOptions?.platform !== ExperimentalPlatform.Neutral;
395+
396+
const inject: string[] = [ssrInjectManifestNamespace];
397+
if (isNodePlatform) {
398+
inject.unshift(ssrInjectRequireNamespace);
399+
}
400+
376401
const buildOptions: BuildOptions = {
377402
...getEsBuildServerCommonOptions(options),
378403
target,
@@ -390,7 +415,7 @@ export function createSsrEntryCodeBundleOptions(
390415
styleOptions,
391416
),
392417
],
393-
inject: [ssrInjectRequireNamespace, ssrInjectManifestNamespace],
418+
inject,
394419
};
395420

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

432+
if (!isNodePlatform) {
433+
// `@angular/platform-server` lazily depends on `xhr2` for XHR usage with the HTTP client.
434+
// Since `xhr2` has Node.js dependencies, it cannot be used when targeting non-Node.js platforms.
435+
// Note: The framework already issues a warning when using XHR with SSR.
436+
buildOptions.external.push('xhr2');
437+
}
438+
407439
buildOptions.plugins.push(
408-
{
409-
name: 'angular-ssr-metadata',
410-
setup(build) {
411-
build.onEnd((result) => {
412-
if (result.metafile) {
413-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
414-
(result.metafile as any)['ng-ssr-entry-bundle'] = true;
415-
}
416-
});
417-
},
418-
},
440+
createServerBundleMetadata({ ssrEntryBundle: true }),
419441
createVirtualModulePlugin({
420442
namespace: ssrInjectRequireNamespace,
421443
cache: sourceFileCache?.loadResultCache,
@@ -490,9 +512,11 @@ export function createSsrEntryCodeBundleOptions(
490512
}
491513

492514
function getEsBuildServerCommonOptions(options: NormalizedApplicationBuildOptions): BuildOptions {
515+
const isNodePlatform = options.ssrOptions?.platform !== ExperimentalPlatform.Neutral;
516+
493517
return {
494518
...getEsBuildCommonOptions(options),
495-
platform: 'node',
519+
platform: isNodePlatform ? 'node' : 'neutral',
496520
outExtension: { '.js': '.mjs' },
497521
// Note: `es2015` is needed for RxJS v6. If not specified, `module` would
498522
// match and the ES5 distribution would be bundled and ends up breaking at

packages/angular/build/src/tools/esbuild/bundler-context.ts

+14-11
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,14 @@ export class BundlerContext {
276276
};
277277
}
278278

279+
const {
280+
'ng-platform-server': isPlatformServer = false,
281+
'ng-ssr-entry-bundle': isSsrEntryBundle = false,
282+
} = result.metafile as Metafile & {
283+
'ng-platform-server'?: boolean;
284+
'ng-ssr-entry-bundle'?: boolean;
285+
};
286+
279287
// Find all initial files
280288
const initialFiles = new Map<string, InitialFileRecord>();
281289
for (const outputFile of result.outputFiles) {
@@ -299,7 +307,7 @@ export class BundlerContext {
299307
name,
300308
type,
301309
entrypoint: true,
302-
serverFile: this.#platformIsServer,
310+
serverFile: isPlatformServer,
303311
depth: 0,
304312
};
305313

@@ -332,7 +340,7 @@ export class BundlerContext {
332340
type: initialImport.kind === 'import-rule' ? 'style' : 'script',
333341
entrypoint: false,
334342
external: initialImport.external,
335-
serverFile: this.#platformIsServer,
343+
serverFile: isPlatformServer,
336344
depth: entryRecord.depth + 1,
337345
};
338346

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

386393
let externalConfiguration = this.#esbuildOptions.external;
387-
if (this.#platformIsServer && externalConfiguration) {
394+
if (isPlatformServer && externalConfiguration) {
388395
externalConfiguration = externalConfiguration.filter(
389396
(dep) => !SERVER_GENERATED_EXTERNALS.has(dep),
390397
);
@@ -400,7 +407,7 @@ export class BundlerContext {
400407
outputFiles,
401408
initialFiles,
402409
externalImports: {
403-
[this.#platformIsServer ? 'server' : 'browser']: externalImports,
410+
[isPlatformServer ? 'server' : 'browser']: externalImports,
404411
},
405412
externalConfiguration,
406413
errors: undefined,
@@ -422,10 +429,6 @@ export class BundlerContext {
422429
}
423430
}
424431

425-
get #platformIsServer(): boolean {
426-
return this.#esbuildOptions?.platform === 'node';
427-
}
428-
429432
/**
430433
* Invalidate a stored bundler result based on the previous watch files
431434
* and a list of changed files.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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 } from 'esbuild';
10+
11+
/**
12+
* Generates an esbuild plugin that appends metadata to the output bundle,
13+
* marking it with server-side rendering (SSR) details for Angular SSR scenarios.
14+
*
15+
* @param options Optional configuration object.
16+
* - `ssrEntryBundle`: If `true`, marks the bundle as an SSR entry point.
17+
*
18+
* @note We can't rely on `platform: node` or `platform: neutral`, as the latter
19+
* is used for non-SSR-related code too (e.g., global scripts).
20+
* @returns An esbuild plugin that injects SSR metadata into the build result's metafile.
21+
*/
22+
export function createServerBundleMetadata(options?: { ssrEntryBundle?: boolean }): Plugin {
23+
return {
24+
name: 'angular-server-bundle-metadata',
25+
setup(build) {
26+
build.onEnd((result) => {
27+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
28+
const metafile = result.metafile as any;
29+
if (metafile) {
30+
metafile['ng-ssr-entry-bundle'] = !!options?.ssrEntryBundle;
31+
metafile['ng-platform-server'] = true;
32+
}
33+
});
34+
},
35+
};
36+
}

0 commit comments

Comments
 (0)