Skip to content

Commit

Permalink
feat(@angular/build): introduce ssr.experimentalPlatform option
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
alan-agius4 committed Oct 9, 2024
1 parent accaa57 commit 7d883a1
Show file tree
Hide file tree
Showing 6 changed files with 238 additions and 35 deletions.
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

0 comments on commit 7d883a1

Please sign in to comment.