diff --git a/packages/angular/build/src/builders/application/options.ts b/packages/angular/build/src/builders/application/options.ts index 1a7502c9bd43..a454e88e375c 100644 --- a/packages/angular/build/src/builders/application/options.ts +++ b/packages/angular/build/src/builders/application/options.ts @@ -27,6 +27,7 @@ import { import { urlJoin } from '../../utils/url'; import { Schema as ApplicationBuilderOptions, + ExperimentalPlatform, I18NTranslation, OutputHashing, OutputMode, @@ -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, }; } diff --git a/packages/angular/build/src/builders/application/schema.json b/packages/angular/build/src/builders/application/schema.json index c12f5c707786..60e725568eb0 100644 --- a/packages/angular/build/src/builders/application/schema.json +++ b/packages/angular/build/src/builders/application/schema.json @@ -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 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 7c5b2968615f..e76db46d685d 100644 --- a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts +++ b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts @@ -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, @@ -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'; @@ -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 ( @@ -190,7 +194,7 @@ 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 @@ -198,20 +202,25 @@ export function createServerPolyfillBundleOptions( // 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; } @@ -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, @@ -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, @@ -390,7 +415,7 @@ export function createSsrEntryCodeBundleOptions( styleOptions, ), ], - inject: [ssrInjectRequireNamespace, ssrInjectManifestNamespace], + inject, }; buildOptions.plugins ??= []; @@ -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, @@ -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 diff --git a/packages/angular/build/src/tools/esbuild/bundler-context.ts b/packages/angular/build/src/tools/esbuild/bundler-context.ts index 85d2829d044d..d1529ef25d1d 100644 --- a/packages/angular/build/src/tools/esbuild/bundler-context.ts +++ b/packages/angular/build/src/tools/esbuild/bundler-context.ts @@ -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(); for (const outputFile of result.outputFiles) { @@ -299,7 +307,7 @@ export class BundlerContext { name, type, entrypoint: true, - serverFile: this.#platformIsServer, + serverFile: isPlatformServer, depth: 0, }; @@ -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, }; @@ -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 { @@ -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), ); @@ -400,7 +407,7 @@ export class BundlerContext { outputFiles, initialFiles, externalImports: { - [this.#platformIsServer ? 'server' : 'browser']: externalImports, + [isPlatformServer ? 'server' : 'browser']: externalImports, }, externalConfiguration, errors: undefined, @@ -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. diff --git a/packages/angular/build/src/tools/esbuild/server-bundle-metadata-plugin.ts b/packages/angular/build/src/tools/esbuild/server-bundle-metadata-plugin.ts new file mode 100644 index 000000000000..e61e652e6f4b --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/server-bundle-metadata-plugin.ts @@ -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; + } + }); + }, + }; +} diff --git a/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-output-mode-server-platform-neutral.ts b/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-output-mode-server-platform-neutral.ts new file mode 100644 index 000000000000..f323a65c9e3b --- /dev/null +++ b/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-output-mode-server-platform-neutral.ts @@ -0,0 +1,133 @@ +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; +import assert from 'node:assert'; +import { expectFileToMatch, writeMultipleFiles } from '../../../utils/fs'; +import { execAndWaitForOutputToMatch, ng, noSilentNg, silentNg } from '../../../utils/process'; +import { + installPackage, + installWorkspacePackages, + uninstallPackage, +} from '../../../utils/packages'; +import { updateJsonFile, useSha } from '../../../utils/project'; +import { getGlobalVariable } from '../../../utils/env'; +import { findFreePort } from '../../../utils/network'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + await installPackage('h3@1'); + + await writeMultipleFiles({ + // Replace the template of app.component.html as it makes it harder to debug + 'src/app/app.component.html': '', + 'src/app/app.routes.ts': ` + import { Routes } from '@angular/router'; + import { HomeComponent } from './home/home.component'; + import { SsrComponent } from './ssr/ssr.component'; + import { SsgWithParamsComponent } from './ssg-with-params/ssg-with-params.component'; + + export const routes: Routes = [ + { + path: '', + component: HomeComponent, + }, + { + path: 'ssr', + component: SsrComponent, + }, + { + path: 'ssg/:id', + component: SsgWithParamsComponent, + }, + ]; + `, + 'src/app/app.routes.server.ts': ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const serverRoutes: ServerRoute[] = [ + { + path: 'ssg/:id', + renderMode: RenderMode.Prerender, + getPrerenderParams: async() => [{id: 'one'}, {id: 'two'}], + }, + { + path: 'ssr', + renderMode: RenderMode.Server, + }, + { + path: '**', + renderMode: RenderMode.Prerender, + }, + ]; + `, + 'src/server.ts': ` + import { AngularAppEngine, createRequestHandler } from '@angular/ssr'; + import { createApp, createRouter, toWebHandler, defineEventHandler, toWebRequest } from 'h3'; + + export const app = createApp(); + const router = createRouter(); + const angularAppEngine = new AngularAppEngine(); + + router.use( + '/**', + defineEventHandler((event) => angularAppEngine.render(toWebRequest(event))), + ); + + app.use(router); + + const handler = toWebHandler(app); + export default createRequestHandler(handler); + `, + }); + // Generate components for the above routes + const componentNames: string[] = ['home', 'ssr', 'ssg-with-params']; + + for (const componentName of componentNames) { + await silentNg('generate', 'component', componentName); + } + + await updateJsonFile('angular.json', (json) => { + const buildTarget = json['projects']['test-project']['architect']['build']; + const options = buildTarget['options']; + options['ssr']['experimentalPlatform'] = 'neutral'; + options['outputMode'] = 'server'; + }); + + await noSilentNg('build'); + + // Valid SSG pages work + const expects: Record = { + 'index.html': 'home works!', + 'ssg/one/index.html': 'ssg-with-params works!', + 'ssg/two/index.html': 'ssg-with-params works!', + }; + + for (const [filePath, fileMatch] of Object.entries(expects)) { + await expectFileToMatch(join('dist/test-project/browser', filePath), fileMatch); + } + + const filesDoNotExist: string[] = ['csr/index.html', 'ssr/index.html', 'redirect/index.html']; + for (const filePath of filesDoNotExist) { + const file = join('dist/test-project/browser', filePath); + assert.equal(existsSync(file), false, `Expected '${file}' to not exist.`); + } + + const port = await findFreePort(); + await execAndWaitForOutputToMatch( + 'npx', + ['-y', 'listhen@1', './dist/test-project/server/server.mjs', `--port=${port}`], + /Server initialized/, + ); + + const res = await fetch(`http://localhost:${port}/ssr`); + const text = await res.text(); + assert.match(text, new RegExp('ssr works!')); +}