From 6219e189ad7e3e5a67fa0b272aa62a0139b94ea3 Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Tue, 17 Sep 2024 08:11:08 +0000 Subject: [PATCH] feat(@angular/build): introduce `outputMode` option to the application builder The `outputMode` option accepts two values: - **`static`:** Generates a static output (HTML, CSS, JavaScript) suitable for deployment on static hosting services or CDNs. This mode supports both client-side rendering (CSR) and static site generation (SSG). - **`server`:** Generates a server bundle in addition to static assets, enabling server-side rendering (SSR) and hybrid rendering strategies. This output is intended for deployment on a Node.js server or serverless environment. ## Key changes: - **Replaces `appShell` and `prerender`:** The `outputMode` option simplifies the CLI by replacing the `appShell` and `prerender` options when server-side routing is configured. - **Controls Server API Usage:** `outputMode` determines whether the new server API is utilized. In `server` mode, `server.ts` is bundled as a separate entry point, preventing direct references to `main.server.ts` and excluding it from localization. Closes #27356, closes #27403, closes #25726, closes #25718 and closes #27196 --- goldens/circular-deps/packages.json | 8 +- goldens/public-api/angular/build/index.api.md | 9 +- .../src/builders/application/execute-build.ts | 34 ++- .../application/execute-post-bundle.ts | 65 +++-- .../build/src/builders/application/i18n.ts | 33 ++- .../build/src/builders/application/index.ts | 12 +- .../build/src/builders/application/options.ts | 47 +++- .../src/builders/application/schema.json | 5 + .../builders/application/setup-bundling.ts | 19 +- .../application/tests/options/output-mode.ts | 56 ++++ .../src/builders/dev-server/vite-server.ts | 7 +- .../extract-i18n/application-extraction.ts | 7 +- .../tools/esbuild/application-code-bundle.ts | 146 ++++++++++- .../build/src/tools/esbuild/budget-stats.ts | 2 +- .../src/tools/esbuild/bundler-context.ts | 15 +- .../tools/esbuild/bundler-execution-result.ts | 16 +- .../build/src/tools/esbuild/i18n-inliner.ts | 3 +- .../angular/build/src/tools/esbuild/utils.ts | 24 +- .../src/utils/server-rendering/manifest.ts | 46 +++- .../src/utils/server-rendering/models.ts | 36 +++ .../src/utils/server-rendering/prerender.ts | 244 ++++++++++-------- .../routes-extractor-worker.ts | 16 +- packages/angular/ssr/public_api.ts | 2 +- packages/angular/ssr/src/app-engine.ts | 4 +- packages/angular/ssr/src/app.ts | 24 +- packages/angular/ssr/src/manifest.ts | 6 + tests/legacy-cli/e2e.bzl | 1 + .../prerender/discover-routes-standalone.ts | 18 +- tests/legacy-cli/e2e/tests/i18n/setup.ts | 4 +- .../express-engine-csp-nonce.ts | 12 +- .../express-engine-ngmodule.ts | 12 +- .../express-engine-standalone.ts | 12 +- ...outes-output-mode-server-i18n-base-href.ts | 89 +++++++ .../server-routes-output-mode-server-i18n.ts | 101 ++++++++ .../server-routes-output-mode-server.ts | 179 +++++++++++++ .../server-routes-output-mode-static.ts | 89 +++++++ .../e2e/tests/server-rendering/setup.ts | 127 +++++++++ 37 files changed, 1285 insertions(+), 245 deletions(-) create mode 100644 packages/angular/build/src/builders/application/tests/options/output-mode.ts create mode 100644 packages/angular/build/src/utils/server-rendering/models.ts rename tests/legacy-cli/e2e/tests/{build/ssr => server-rendering}/express-engine-csp-nonce.ts (93%) rename tests/legacy-cli/e2e/tests/{build/ssr => server-rendering}/express-engine-ngmodule.ts (94%) rename tests/legacy-cli/e2e/tests/{build/ssr => server-rendering}/express-engine-standalone.ts (92%) create mode 100644 tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-server-i18n-base-href.ts create mode 100644 tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-server-i18n.ts create mode 100644 tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-server.ts create mode 100644 tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-static.ts create mode 100644 tests/legacy-cli/e2e/tests/server-rendering/setup.ts diff --git a/goldens/circular-deps/packages.json b/goldens/circular-deps/packages.json index cca29e550ffe..9289dfc27248 100644 --- a/goldens/circular-deps/packages.json +++ b/goldens/circular-deps/packages.json @@ -10,16 +10,18 @@ [ "packages/angular/build/src/tools/esbuild/bundler-context.ts", "packages/angular/build/src/tools/esbuild/utils.ts", - "packages/angular/build/src/tools/esbuild/bundler-execution-result.ts" + "packages/angular/build/src/utils/server-rendering/manifest.ts" ], [ "packages/angular/build/src/tools/esbuild/bundler-context.ts", "packages/angular/build/src/tools/esbuild/utils.ts", - "packages/angular/build/src/utils/server-rendering/manifest.ts" + "packages/angular/build/src/utils/server-rendering/manifest.ts", + "packages/angular/build/src/tools/esbuild/bundler-execution-result.ts" ], [ "packages/angular/build/src/tools/esbuild/bundler-execution-result.ts", - "packages/angular/build/src/tools/esbuild/utils.ts" + "packages/angular/build/src/tools/esbuild/utils.ts", + "packages/angular/build/src/utils/server-rendering/manifest.ts" ], [ "packages/angular/cli/src/analytics/analytics-collector.ts", diff --git a/goldens/public-api/angular/build/index.api.md b/goldens/public-api/angular/build/index.api.md index c3efe5dda318..587a4cad5bdd 100644 --- a/goldens/public-api/angular/build/index.api.md +++ b/goldens/public-api/angular/build/index.api.md @@ -40,6 +40,7 @@ export interface ApplicationBuilderOptions { namedChunks?: boolean; optimization?: OptimizationUnion; outputHashing?: OutputHashing; + outputMode?: OutputMode; outputPath: OutputPathUnion; poll?: number; polyfills?: string[]; @@ -99,13 +100,15 @@ export interface BuildOutputFile extends OutputFile { // @public (undocumented) export enum BuildOutputFileType { // (undocumented) - Browser = 1, + Browser = 0, // (undocumented) - Media = 2, + Media = 1, // (undocumented) Root = 4, // (undocumented) - Server = 3 + Server = 2, + // (undocumented) + SSRServer = 3 } // @public diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts index 5e77e2c48037..0e93586253d6 100644 --- a/packages/angular/build/src/builders/application/execute-build.ts +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -7,6 +7,7 @@ */ import { BuilderContext } from '@angular-devkit/architect'; +import assert from 'node:assert'; import { SourceFileCache } from '../../tools/esbuild/angular/source-file-cache'; import { generateBudgetStats } from '../../tools/esbuild/budget-stats'; import { BuildOutputFileType, BundlerContext } from '../../tools/esbuild/bundler-context'; @@ -18,13 +19,19 @@ import { calculateEstimatedTransferSizes, logBuildStats } from '../../tools/esbu import { BudgetCalculatorResult, checkBudgets } from '../../utils/bundle-calculator'; import { shouldOptimizeChunks } from '../../utils/environment-options'; import { resolveAssets } from '../../utils/resolve-assets'; +import { + SERVER_APP_ENGINE_MANIFEST_FILENAME, + generateAngularServerAppEngineManifest, +} from '../../utils/server-rendering/manifest'; import { getSupportedBrowsers } from '../../utils/supported-browsers'; import { optimizeChunks } from './chunk-optimizer'; import { executePostBundleSteps } from './execute-post-bundle'; import { inlineI18n, loadActiveTranslations } from './i18n'; import { NormalizedApplicationBuildOptions } from './options'; +import { OutputMode } from './schema'; import { setupBundlerContexts } from './setup-bundling'; +// eslint-disable-next-line max-lines-per-function export async function executeBuild( options: NormalizedApplicationBuildOptions, context: BuilderContext, @@ -36,8 +43,10 @@ export async function executeBuild( i18nOptions, optimizationOptions, assets, + outputMode, cacheOptions, - prerenderOptions, + serverEntryPoint, + baseHref, ssrOptions, verbose, colors, @@ -160,6 +169,15 @@ export async function executeBuild( executionResult.htmlBaseHref = options.baseHref; } + // Create server app engine manifest + if (serverEntryPoint) { + executionResult.addOutputFile( + SERVER_APP_ENGINE_MANIFEST_FILENAME, + generateAngularServerAppEngineManifest(i18nOptions, baseHref, undefined), + BuildOutputFileType.SSRServer, + ); + } + // Perform i18n translation inlining if enabled if (i18nOptions.shouldInline) { const result = await inlineI18n(options, executionResult, initialFiles); @@ -183,8 +201,20 @@ export async function executeBuild( executionResult.assetFiles.push(...result.additionalAssets); } - if (prerenderOptions) { + if (serverEntryPoint) { const prerenderedRoutes = executionResult.prerenderedRoutes; + + // Regenerate the manifest to append prerendered routes data. This is only needed if SSR is enabled. + if (outputMode === OutputMode.Server && Object.keys(prerenderedRoutes).length) { + const manifest = executionResult.outputFiles.find( + (f) => f.path === SERVER_APP_ENGINE_MANIFEST_FILENAME, + ); + assert(manifest, `${SERVER_APP_ENGINE_MANIFEST_FILENAME} was not found in output files.`); + manifest.contents = new TextEncoder().encode( + generateAngularServerAppEngineManifest(i18nOptions, baseHref, prerenderedRoutes), + ); + } + executionResult.addOutputFile( 'prerendered-routes.json', JSON.stringify({ routes: prerenderedRoutes }, null, 2), diff --git a/packages/angular/build/src/builders/application/execute-post-bundle.ts b/packages/angular/build/src/builders/application/execute-post-bundle.ts index d8325c7e27ff..d138bbfd574d 100644 --- a/packages/angular/build/src/builders/application/execute-post-bundle.ts +++ b/packages/angular/build/src/builders/application/execute-post-bundle.ts @@ -12,7 +12,10 @@ import { BuildOutputFileType, InitialFileRecord, } from '../../tools/esbuild/bundler-context'; -import { BuildOutputAsset } from '../../tools/esbuild/bundler-execution-result'; +import { + BuildOutputAsset, + PrerenderedRoutesRecord, +} from '../../tools/esbuild/bundler-execution-result'; import { generateIndexHtml } from '../../tools/esbuild/index-html-generator'; import { createOutputFile } from '../../tools/esbuild/utils'; import { maxWorkers } from '../../utils/environment-options'; @@ -20,9 +23,13 @@ import { SERVER_APP_MANIFEST_FILENAME, generateAngularServerAppManifest, } from '../../utils/server-rendering/manifest'; +import { RouteRenderMode, SerializableRouteTreeNode } from '../../utils/server-rendering/models'; import { prerenderPages } from '../../utils/server-rendering/prerender'; import { augmentAppWithServiceWorkerEsbuild } from '../../utils/service-worker'; import { INDEX_HTML_SERVER, NormalizedApplicationBuildOptions } from './options'; +import { OutputMode } from './schema'; + +type Writeable = T extends readonly (infer U)[] ? U[] : never; /** * Run additional builds steps including SSG, AppShell, Index HTML file and Service worker generation. @@ -43,13 +50,13 @@ export async function executePostBundleSteps( warnings: string[]; additionalOutputFiles: BuildOutputFile[]; additionalAssets: BuildOutputAsset[]; - prerenderedRoutes: string[]; + prerenderedRoutes: PrerenderedRoutesRecord; }> { const additionalAssets: BuildOutputAsset[] = []; const additionalOutputFiles: BuildOutputFile[] = []; const allErrors: string[] = []; const allWarnings: string[] = []; - const prerenderedRoutes: string[] = []; + const prerenderedRoutes: PrerenderedRoutesRecord = {}; const { baseHref = '/', @@ -57,11 +64,12 @@ export async function executePostBundleSteps( indexHtmlOptions, optimizationOptions, sourcemapOptions, - ssrOptions, + outputMode, + serverEntryPoint, prerenderOptions, appShellOptions, workspaceRoot, - verbose, + disableFullServerManifestGeneration, } = options; // Index HTML content without CSS inlining to be used for server rendering (AppShell, SSG and SSR). @@ -97,7 +105,7 @@ export async function executePostBundleSteps( } // Create server manifest - if (prerenderOptions || appShellOptions || ssrOptions) { + if (serverEntryPoint) { additionalOutputFiles.push( createOutputFile( SERVER_APP_MANIFEST_FILENAME, @@ -106,6 +114,7 @@ export async function executePostBundleSteps( outputFiles, optimizationOptions.styles.inlineCritical ?? false, undefined, + locale, ), BuildOutputFileType.Server, ), @@ -114,36 +123,32 @@ export async function executePostBundleSteps( // Pre-render (SSG) and App-shell // If localization is enabled, prerendering is handled in the inlining process. - if ((prerenderOptions || appShellOptions) && !allErrors.length) { + if ( + !disableFullServerManifestGeneration && + (prerenderOptions || appShellOptions || (outputMode && serverEntryPoint)) && + !allErrors.length + ) { assert( indexHtmlOptions, 'The "index" option is required when using the "ssg" or "appShell" options.', ); - const { - output, - warnings, - errors, - prerenderedRoutes: generatedRoutes, - serializableRouteTreeNode, - } = await prerenderPages( + const { output, warnings, errors, serializableRouteTreeNode } = await prerenderPages( workspaceRoot, baseHref, appShellOptions, prerenderOptions, [...outputFiles, ...additionalOutputFiles], assetFiles, + outputMode, sourcemapOptions.scripts, maxWorkers, - verbose, ); allErrors.push(...errors); allWarnings.push(...warnings); - prerenderedRoutes.push(...Array.from(generatedRoutes)); - - const indexHasBeenPrerendered = generatedRoutes.has(indexHtmlOptions.output); + const indexHasBeenPrerendered = output[indexHtmlOptions.output]; for (const [path, { content, appShellRoute }] of Object.entries(output)) { // Update the index contents with the app shell under these conditions: // - Replace 'index.html' with the app shell only if it hasn't been prerendered yet. @@ -155,7 +160,26 @@ export async function executePostBundleSteps( ); } - if (ssrOptions) { + const serializableRouteTreeNodeForManifest: Writeable = []; + + for (const metadata of serializableRouteTreeNode) { + switch (metadata.renderMode) { + case RouteRenderMode.Prerender: + case /* Legacy building mode */ undefined: { + if (!metadata.redirectTo || outputMode === OutputMode.Static) { + prerenderedRoutes[metadata.route] = { headers: metadata.headers }; + } + break; + } + case RouteRenderMode.Server: + case RouteRenderMode.Client: + serializableRouteTreeNodeForManifest.push(metadata); + + break; + } + } + + if (outputMode === OutputMode.Server) { // Regenerate the manifest to append route tree. This is only needed if SSR is enabled. const manifest = additionalOutputFiles.find((f) => f.path === SERVER_APP_MANIFEST_FILENAME); assert(manifest, `${SERVER_APP_MANIFEST_FILENAME} was not found in output files.`); @@ -165,7 +189,8 @@ export async function executePostBundleSteps( additionalHtmlOutputFiles, outputFiles, optimizationOptions.styles.inlineCritical ?? false, - serializableRouteTreeNode, + serializableRouteTreeNodeForManifest, + locale, ), ); } diff --git a/packages/angular/build/src/builders/application/i18n.ts b/packages/angular/build/src/builders/application/i18n.ts index ce5e0c7f27fc..2dff7b9a5fbd 100644 --- a/packages/angular/build/src/builders/application/i18n.ts +++ b/packages/angular/build/src/builders/application/i18n.ts @@ -7,9 +7,12 @@ */ import { BuilderContext } from '@angular-devkit/architect'; -import { join, posix } from 'node:path'; +import { join } from 'node:path'; import { BuildOutputFileType, InitialFileRecord } from '../../tools/esbuild/bundler-context'; -import { ExecutionResult } from '../../tools/esbuild/bundler-execution-result'; +import { + ExecutionResult, + PrerenderedRoutesRecord, +} from '../../tools/esbuild/bundler-execution-result'; import { I18nInliner } from '../../tools/esbuild/i18n-inliner'; import { maxWorkers } from '../../utils/environment-options'; import { loadTranslations } from '../../utils/i18n-options'; @@ -28,7 +31,11 @@ export async function inlineI18n( options: NormalizedApplicationBuildOptions, executionResult: ExecutionResult, initialFiles: Map, -): Promise<{ errors: string[]; warnings: string[]; prerenderedRoutes: string[] }> { +): Promise<{ + errors: string[]; + warnings: string[]; + prerenderedRoutes: PrerenderedRoutesRecord; +}> { // Create the multi-threaded inliner with common options and the files generated from the build. const inliner = new I18nInliner( { @@ -39,10 +46,14 @@ export async function inlineI18n( maxWorkers, ); - const inlineResult: { errors: string[]; warnings: string[]; prerenderedRoutes: string[] } = { + const inlineResult: { + errors: string[]; + warnings: string[]; + prerenderedRoutes: PrerenderedRoutesRecord; + } = { errors: [], warnings: [], - prerenderedRoutes: [], + prerenderedRoutes: {}, }; // For each active locale, use the inliner to process the output files of the build. @@ -95,15 +106,11 @@ export async function inlineI18n( destination: join(locale, assetFile.destination), }); } - - inlineResult.prerenderedRoutes.push( - ...generatedRoutes.map((route) => posix.join('/', locale, route)), - ); } else { - inlineResult.prerenderedRoutes.push(...generatedRoutes); executionResult.assetFiles.push(...additionalAssets); } + inlineResult.prerenderedRoutes = { ...inlineResult.prerenderedRoutes, ...generatedRoutes }; updatedOutputFiles.push(...localeOutputFiles); } } finally { @@ -112,8 +119,10 @@ export async function inlineI18n( // Update the result with all localized files. executionResult.outputFiles = [ - // Root files are not modified. - ...executionResult.outputFiles.filter(({ type }) => type === BuildOutputFileType.Root), + // Root and SSR entry files are not modified. + ...executionResult.outputFiles.filter( + ({ type }) => type === BuildOutputFileType.Root || type === BuildOutputFileType.SSRServer, + ), // Updated files for each locale. ...updatedOutputFiles, ]; diff --git a/packages/angular/build/src/builders/application/index.ts b/packages/angular/build/src/builders/application/index.ts index 8072b091574a..252ab118c2cc 100644 --- a/packages/angular/build/src/builders/application/index.ts +++ b/packages/angular/build/src/builders/application/index.ts @@ -88,7 +88,7 @@ export async function* buildApplicationInternal( yield* runEsBuildBuildAction( async (rebuildState) => { - const { prerenderOptions, jsonLogs } = normalizedOptions; + const { serverEntryPoint, jsonLogs } = normalizedOptions; const startTime = process.hrtime.bigint(); const result = await executeBuild(normalizedOptions, context, rebuildState); @@ -96,8 +96,8 @@ export async function* buildApplicationInternal( if (jsonLogs) { result.addLog(await createJsonBuildManifest(result, normalizedOptions)); } else { - if (prerenderOptions) { - const prerenderedRoutesLength = result.prerenderedRoutes.length; + if (serverEntryPoint) { + const prerenderedRoutesLength = Object.keys(result.prerenderedRoutes).length; let prerenderMsg = `Prerendered ${prerenderedRoutesLength} static route`; prerenderMsg += prerenderedRoutesLength !== 1 ? 's.' : '.'; @@ -225,7 +225,10 @@ export async function* buildApplication( // Writes the output files to disk and ensures the containing directories are present const directoryExists = new Set(); await emitFilesToDisk(Object.entries(result.files), async ([filePath, file]) => { - if (outputOptions.ignoreServer && file.type === BuildOutputFileType.Server) { + if ( + outputOptions.ignoreServer && + (file.type === BuildOutputFileType.Server || file.type === BuildOutputFileType.SSRServer) + ) { return; } @@ -236,6 +239,7 @@ export async function* buildApplication( typeDirectory = outputOptions.browser; break; case BuildOutputFileType.Server: + case BuildOutputFileType.SSRServer: typeDirectory = outputOptions.server; break; case BuildOutputFileType.Root: diff --git a/packages/angular/build/src/builders/application/options.ts b/packages/angular/build/src/builders/application/options.ts index 40d566bedb0d..770f64f9c7b6 100644 --- a/packages/angular/build/src/builders/application/options.ts +++ b/packages/angular/build/src/builders/application/options.ts @@ -29,6 +29,7 @@ import { Schema as ApplicationBuilderOptions, I18NTranslation, OutputHashing, + OutputMode, OutputPathClass, } from './schema'; @@ -79,6 +80,16 @@ interface InternalOptions { * This is only used by the development server which currently only supports a single locale per build. */ forceI18nFlatOutput?: boolean; + + /** + * When set to `true`, disables the generation of a full manifest with routes. + * + * This option is primarily used during development to improve performance, + * as the full manifest is generated at runtime when using the development server. + * + * @default false + */ + disableFullServerManifestGeneration?: boolean; } /** Full set of options for `application` builder. */ @@ -179,6 +190,33 @@ export async function normalizeOptions( } } + // Validate prerender and ssr options when using the outputMode + if (options.outputMode === OutputMode.Server) { + if (!options.server) { + throw new Error('The "server" option is required when "outputMode" is set to "server".'); + } + + if (typeof options.ssr === 'boolean' || !options.ssr?.entry) { + throw new Error('The "ssr.entry" option is required when "outputMode" is set to "server".'); + } + } + + if (options.outputMode) { + if (options.prerender) { + context.logger.warn( + 'The "prerender" option is no longer needed when "outputMode" is specified.', + ); + } else { + options.prerender = !!options.server; + } + + if (options.appShell) { + context.logger.warn( + 'The "appShell" option is no longer needed when "outputMode" is specified.', + ); + } + } + // A configuration file can exist in the project or workspace root const searchDirectories = await generateSearchDirectories([projectRoot, workspaceRoot]); const postcssConfiguration = await loadPostcssConfiguration(searchDirectories); @@ -235,7 +273,10 @@ export async function normalizeOptions( clean: options.deleteOutputPath ?? true, // For app-shell and SSG server files are not required by users. // Omit these when SSR is not enabled. - ignoreServer: ssrOptions === undefined || serverEntryPoint === undefined, + ignoreServer: + ((ssrOptions === undefined || serverEntryPoint === undefined) && + options.outputMode === undefined) || + options.outputMode === OutputMode.Static, }; const outputNames = { @@ -317,6 +358,7 @@ export async function normalizeOptions( poll, polyfills, statsJson, + outputMode, stylePreprocessorOptions, subresourceIntegrity, verbose, @@ -328,6 +370,7 @@ export async function normalizeOptions( deployUrl, clearScreen, define, + disableFullServerManifestGeneration = false, } = options; // Return all the normalized options @@ -352,6 +395,7 @@ export async function normalizeOptions( serverEntryPoint, prerenderOptions, appShellOptions, + outputMode, ssrOptions, verbose, watch, @@ -387,6 +431,7 @@ export async function normalizeOptions( colors: supportColor(), clearScreen, define, + disableFullServerManifestGeneration, }; } diff --git a/packages/angular/build/src/builders/application/schema.json b/packages/angular/build/src/builders/application/schema.json index 6df812386f01..c12f5c707786 100644 --- a/packages/angular/build/src/builders/application/schema.json +++ b/packages/angular/build/src/builders/application/schema.json @@ -528,6 +528,11 @@ "type": "boolean", "description": "Generates an application shell during build time.", "default": false + }, + "outputMode": { + "type": "string", + "description": "Defines the build output target. 'static': Generates a static site for deployment on any static hosting service. 'server': Produces an application designed for deployment on a server that supports server-side rendering (SSR).", + "enum": ["static", "server"] } }, "additionalProperties": false, diff --git a/packages/angular/build/src/builders/application/setup-bundling.ts b/packages/angular/build/src/builders/application/setup-bundling.ts index 814b68a1a1ab..eb4012594f90 100644 --- a/packages/angular/build/src/builders/application/setup-bundling.ts +++ b/packages/angular/build/src/builders/application/setup-bundling.ts @@ -12,6 +12,7 @@ import { createBrowserPolyfillBundleOptions, createServerMainCodeBundleOptions, createServerPolyfillBundleOptions, + createSsrEntryCodeBundleOptions, } from '../../tools/esbuild/application-code-bundle'; import { BundlerContext } from '../../tools/esbuild/bundler-context'; import { createGlobalScriptsBundleOptions } from '../../tools/esbuild/global-scripts'; @@ -36,9 +37,10 @@ export function setupBundlerContexts( codeBundleCache: SourceFileCache, ): BundlerContext[] { const { + outputMode, + serverEntryPoint, appShellOptions, prerenderOptions, - serverEntryPoint, ssrOptions, workspaceRoot, watch = false, @@ -90,9 +92,9 @@ export function setupBundlerContexts( } // Skip server build when none of the features are enabled. - if (serverEntryPoint && (prerenderOptions || appShellOptions || ssrOptions)) { + if (serverEntryPoint && (outputMode || prerenderOptions || appShellOptions || ssrOptions)) { const nodeTargets = [...target, ...getSupportedNodeTargets()]; - // Server application code + bundlerContexts.push( new BundlerContext( workspaceRoot, @@ -101,6 +103,17 @@ export function setupBundlerContexts( ), ); + if (outputMode && ssrOptions?.entry) { + // New behavior introduced: 'server.ts' is now bundled separately from 'main.server.ts'. + bundlerContexts.push( + new BundlerContext( + workspaceRoot, + watch, + createSsrEntryCodeBundleOptions(options, nodeTargets, codeBundleCache), + ), + ); + } + // Server polyfills code const serverPolyfillBundleOptions = createServerPolyfillBundleOptions( options, diff --git a/packages/angular/build/src/builders/application/tests/options/output-mode.ts b/packages/angular/build/src/builders/application/tests/options/output-mode.ts new file mode 100644 index 000000000000..4e6a6eb49a7d --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/output-mode.ts @@ -0,0 +1,56 @@ +/** + * @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 } from '../../schema'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + 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!');`); + }); + + describe('Option: "outputMode"', () => { + it(`should not emit 'server' directory when OutputMode is Static`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + outputMode: OutputMode.Static, + server: 'src/main.server.ts', + ssr: { entry: 'src/server.ts' }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectDirectory('dist/server').toNotExist(); + }); + + it(`should emit 'server' directory when OutputMode is Server`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + outputMode: OutputMode.Server, + server: 'src/main.server.ts', + ssr: { entry: 'src/server.ts' }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/server/main.server.mjs').toExist(); + harness.expectFile('dist/server/server.mjs').toExist(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/dev-server/vite-server.ts b/packages/angular/build/src/builders/dev-server/vite-server.ts index aa6a9cf84e86..b842d72916b1 100644 --- a/packages/angular/build/src/builders/dev-server/vite-server.ts +++ b/packages/angular/build/src/builders/dev-server/vite-server.ts @@ -98,9 +98,6 @@ export async function* serveWithVite( // This is so instead of prerendering all the routes for every change, the page is "prerendered" when it is requested. browserOptions.prerender = false; - // Avoid bundling and processing the ssr entry-point as this is not used by the dev-server. - browserOptions.ssr = true; - // https://nodejs.org/api/process.html#processsetsourcemapsenabledval process.setSourceMapsEnabled(true); } @@ -108,6 +105,10 @@ export async function* serveWithVite( // Set all packages as external to support Vite's prebundle caching browserOptions.externalPackages = serverOptions.prebundle; + // Disable generating a full manifest with routes. + // This is done during runtime when using the dev-server. + browserOptions.disableFullServerManifestGeneration = true; + // The development server currently only supports a single locale when localizing. // This matches the behavior of the Webpack-based development server but could be expanded in the future. if ( diff --git a/packages/angular/build/src/builders/extract-i18n/application-extraction.ts b/packages/angular/build/src/builders/extract-i18n/application-extraction.ts index 7640cba88366..e69ea6785b7e 100644 --- a/packages/angular/build/src/builders/extract-i18n/application-extraction.ts +++ b/packages/angular/build/src/builders/extract-i18n/application-extraction.ts @@ -17,6 +17,7 @@ import type { ApplicationBuilderInternalOptions, } from '../application/options'; import { ResultFile, ResultKind } from '../application/results'; +import { OutputMode } from '../application/schema'; import type { NormalizedExtractI18nOptions } from './options'; export async function extractMessages( @@ -44,10 +45,8 @@ export async function extractMessages( buildOptions.budgets = undefined; buildOptions.index = false; buildOptions.serviceWorker = false; - - buildOptions.ssr = false; - buildOptions.appShell = false; - buildOptions.prerender = false; + buildOptions.outputMode = OutputMode.Static; + buildOptions.server = undefined; // Build the application with the build options const builderResult = await first(buildApplicationInternal(buildOptions, context, extensions)); 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 fa8fa76f1b40..7c5b2968615f 100644 --- a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts +++ b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts @@ -12,7 +12,10 @@ import { createHash } from 'node:crypto'; import { extname, relative } from 'node:path'; import type { NormalizedApplicationBuildOptions } from '../../builders/application/options'; import { allowMangle } from '../../utils/environment-options'; -import { SERVER_APP_MANIFEST_FILENAME } from '../../utils/server-rendering/manifest'; +import { + SERVER_APP_ENGINE_MANIFEST_FILENAME, + SERVER_APP_MANIFEST_FILENAME, +} from '../../utils/server-rendering/manifest'; import { createCompilerPlugin } from './angular/compiler-plugin'; import { SourceFileCache } from './angular/source-file-cache'; import { BundlerOptionsFactory } from './bundler-context'; @@ -220,6 +223,7 @@ export function createServerMainCodeBundleOptions( const { serverEntryPoint: mainServerEntryPoint, workspaceRoot, + outputMode, externalPackages, ssrOptions, polyfills, @@ -245,8 +249,9 @@ export function createServerMainCodeBundleOptions( }; const ssrEntryPoint = ssrOptions?.entry; + const isOldBehaviour = !outputMode; - if (ssrEntryPoint) { + if (ssrEntryPoint && isOldBehaviour) { // Old behavior: 'server.ts' was bundled together with the SSR (Server-Side Rendering) code. // This approach combined server-side logic and rendering into a single bundle. entryPoints['server'] = ssrEntryPoint; @@ -347,6 +352,143 @@ export function createServerMainCodeBundleOptions( return buildOptions; } +export function createSsrEntryCodeBundleOptions( + options: NormalizedApplicationBuildOptions, + target: string[], + sourceFileCache: SourceFileCache, +): BuildOptions { + const { workspaceRoot, ssrOptions, externalPackages } = options; + const serverEntryPoint = ssrOptions?.entry; + assert( + serverEntryPoint, + 'createSsrEntryCodeBundleOptions should not be called without a defined serverEntryPoint.', + ); + + const { pluginOptions, styleOptions } = createCompilerPluginOptions( + options, + target, + sourceFileCache, + ); + + const ssrEntryNamespace = 'angular:ssr-entry'; + const ssrInjectManifestNamespace = 'angular:ssr-entry-inject-manifest'; + const ssrInjectRequireNamespace = 'angular:ssr-entry-inject-require'; + const buildOptions: BuildOptions = { + ...getEsBuildServerCommonOptions(options), + target, + entryPoints: { + // TODO: consider renaming to index + 'server': ssrEntryNamespace, + }, + supported: getFeatureSupport(target, true), + plugins: [ + createSourcemapIgnorelistPlugin(), + createCompilerPlugin( + // JS/TS options + { ...pluginOptions, noopTypeScriptCompilation: true }, + // Component stylesheet options + styleOptions, + ), + ], + inject: [ssrInjectRequireNamespace, ssrInjectManifestNamespace], + }; + + buildOptions.plugins ??= []; + + if (externalPackages) { + buildOptions.packages = 'external'; + } else { + buildOptions.plugins.push(createRxjsEsmResolutionPlugin()); + } + + // Mark manifest file as external. As this will be generated later on. + (buildOptions.external ??= []).push('*/main.server.mjs', ...SERVER_GENERATED_EXTERNALS); + + 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; + } + }); + }, + }, + createVirtualModulePlugin({ + namespace: ssrInjectRequireNamespace, + cache: sourceFileCache?.loadResultCache, + loadContent: () => { + const contents: string[] = [ + // 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);`, + ]; + + return { + contents: contents.join('\n'), + loader: 'js', + resolveDir: workspaceRoot, + }; + }, + }), + createVirtualModulePlugin({ + namespace: ssrInjectManifestNamespace, + cache: sourceFileCache?.loadResultCache, + loadContent: () => { + const contents: string[] = [ + // Configure `@angular/ssr` app engine manifest. + `import manifest from './${SERVER_APP_ENGINE_MANIFEST_FILENAME}';`, + `import { ɵsetAngularAppEngineManifest } from '@angular/ssr';`, + `ɵsetAngularAppEngineManifest(manifest);`, + ]; + + return { + contents: contents.join('\n'), + loader: 'js', + resolveDir: workspaceRoot, + }; + }, + }), + createVirtualModulePlugin({ + namespace: ssrEntryNamespace, + cache: sourceFileCache?.loadResultCache, + loadContent: () => { + const serverEntryPointJsImport = entryFileToWorkspaceRelative( + workspaceRoot, + serverEntryPoint, + ); + const contents: string[] = [ + // Re-export all symbols including default export + `import * as server from '${serverEntryPointJsImport}';`, + `export * from '${serverEntryPointJsImport}';`, + // The below is needed to avoid + // `Import "default" will always be undefined because there is no matching export` warning when no default is present. + `const defaultExportName = 'default';`, + `export default server[defaultExportName]`, + + // Add @angular/ssr exports + `export { AngularAppEngine } from '@angular/ssr';`, + ]; + + return { + contents: contents.join('\n'), + loader: 'js', + resolveDir: workspaceRoot, + }; + }, + }), + ); + + if (options.plugins) { + buildOptions.plugins.push(...options.plugins); + } + + return buildOptions; +} + function getEsBuildServerCommonOptions(options: NormalizedApplicationBuildOptions): BuildOptions { return { ...getEsBuildCommonOptions(options), diff --git a/packages/angular/build/src/tools/esbuild/budget-stats.ts b/packages/angular/build/src/tools/esbuild/budget-stats.ts index 6636c08ba05e..3865f6c274aa 100644 --- a/packages/angular/build/src/tools/esbuild/budget-stats.ts +++ b/packages/angular/build/src/tools/esbuild/budget-stats.ts @@ -39,7 +39,7 @@ export function generateBudgetStats( } // Exclude server bundles - if (type === BuildOutputFileType.Server) { + if (type === BuildOutputFileType.Server || type === BuildOutputFileType.SSRServer) { continue; } diff --git a/packages/angular/build/src/tools/esbuild/bundler-context.ts b/packages/angular/build/src/tools/esbuild/bundler-context.ts index f65c5724e9ac..4e67214b3d6f 100644 --- a/packages/angular/build/src/tools/esbuild/bundler-context.ts +++ b/packages/angular/build/src/tools/esbuild/bundler-context.ts @@ -47,10 +47,11 @@ export interface InitialFileRecord { } export enum BuildOutputFileType { - Browser = 1, - Media = 2, - Server = 3, - Root = 4, + Browser, + Media, + Server, + SSRServer, + Root, } export interface BuildOutputFile extends OutputFile { @@ -147,6 +148,7 @@ export class BundlerContext { } result.initialFiles.forEach((value, key) => initialFiles.set(key, value)); + outputFiles.push(...result.outputFiles); result.externalImports.browser?.forEach((value) => externalImportsBrowser.add(value)); result.externalImports.server?.forEach((value) => externalImportsServer.add(value)); @@ -370,7 +372,10 @@ export class BundlerContext { if (!/\.([cm]?js|css|wasm)(\.map)?$/i.test(file.path)) { fileType = BuildOutputFileType.Media; } else if (this.#platformIsServer) { - fileType = BuildOutputFileType.Server; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fileType = (result.metafile as any)['ng-ssr-entry-bundle'] + ? BuildOutputFileType.SSRServer + : BuildOutputFileType.Server; } else { fileType = BuildOutputFileType.Browser; } diff --git a/packages/angular/build/src/tools/esbuild/bundler-execution-result.ts b/packages/angular/build/src/tools/esbuild/bundler-execution-result.ts index ae2642e51d7d..10f285e795c9 100644 --- a/packages/angular/build/src/tools/esbuild/bundler-execution-result.ts +++ b/packages/angular/build/src/tools/esbuild/bundler-execution-result.ts @@ -31,6 +31,8 @@ export interface ExternalResultMetadata { explicit: string[]; } +export type PrerenderedRoutesRecord = Record }>; + /** * Represents the result of a single builder execute call. */ @@ -38,7 +40,7 @@ export class ExecutionResult { outputFiles: BuildOutputFile[] = []; assetFiles: BuildOutputAsset[] = []; errors: (Message | PartialMessage)[] = []; - prerenderedRoutes: string[] = []; + prerenderedRoutes: PrerenderedRoutesRecord = {}; warnings: (Message | PartialMessage)[] = []; logs: string[] = []; externalMetadata?: ExternalResultMetadata; @@ -77,10 +79,16 @@ export class ExecutionResult { } } - addPrerenderedRoutes(routes: string[]): void { - this.prerenderedRoutes.push(...routes); + addPrerenderedRoutes(routes: PrerenderedRoutesRecord): void { + Object.assign(this.prerenderedRoutes, routes); + // Sort the prerendered routes. - this.prerenderedRoutes.sort((a, b) => a.localeCompare(b)); + const sortedObj: PrerenderedRoutesRecord = {}; + for (const key of Object.keys(this.prerenderedRoutes).sort()) { + sortedObj[key] = this.prerenderedRoutes[key]; + } + + this.prerenderedRoutes = sortedObj; } addWarning(error: PartialMessage | string): void { diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts index b1714efd459f..e35a4dc46f95 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts @@ -44,7 +44,8 @@ export class I18nInliner { const files = new Map(); const pendingMaps = []; for (const file of options.outputFiles) { - if (file.type === BuildOutputFileType.Root) { + if (file.type === BuildOutputFileType.Root || file.type === BuildOutputFileType.SSRServer) { + // Skip also the server entry-point. // Skip stats and similar files. continue; } diff --git a/packages/angular/build/src/tools/esbuild/utils.ts b/packages/angular/build/src/tools/esbuild/utils.ts index 8b8e744d5e5a..3f521ade31ac 100644 --- a/packages/angular/build/src/tools/esbuild/utils.ts +++ b/packages/angular/build/src/tools/esbuild/utils.ts @@ -15,11 +15,19 @@ import { pathToFileURL } from 'node:url'; import { brotliCompress } from 'node:zlib'; import { coerce } from 'semver'; import { NormalizedApplicationBuildOptions } from '../../builders/application/options'; +import { OutputMode } from '../../builders/application/schema'; import { BudgetCalculatorResult } from '../../utils/bundle-calculator'; -import { SERVER_APP_MANIFEST_FILENAME } from '../../utils/server-rendering/manifest'; +import { + SERVER_APP_ENGINE_MANIFEST_FILENAME, + SERVER_APP_MANIFEST_FILENAME, +} from '../../utils/server-rendering/manifest'; import { BundleStats, generateEsbuildBuildStatsTable } from '../../utils/stats-table'; import { BuildOutputFile, BuildOutputFileType, InitialFileRecord } from './bundler-context'; -import { BuildOutputAsset, ExecutionResult } from './bundler-execution-result'; +import { + BuildOutputAsset, + ExecutionResult, + PrerenderedRoutesRecord, +} from './bundler-execution-result'; export function logBuildStats( metafile: Metafile, @@ -48,7 +56,8 @@ export function logBuildStats( continue; } - const isPlatformServer = type === BuildOutputFileType.Server; + const isPlatformServer = + type === BuildOutputFileType.Server || type === BuildOutputFileType.SSRServer; if (isPlatformServer && !ssrOutputEnabled) { // Only log server build stats when SSR is enabled. continue; @@ -412,7 +421,7 @@ interface BuildManifest { server?: URL | undefined; browser: URL; }; - prerenderedRoutes?: string[]; + prerenderedRoutes: PrerenderedRoutesRecord; } export async function createJsonBuildManifest( @@ -423,6 +432,7 @@ export async function createJsonBuildManifest( colors: color, outputOptions: { base, server, browser }, ssrOptions, + outputMode, } = normalizedOptions; const { warnings, errors, prerenderedRoutes } = result; @@ -433,7 +443,10 @@ export async function createJsonBuildManifest( outputPaths: { root: pathToFileURL(base), browser: pathToFileURL(join(base, browser)), - server: ssrOptions ? pathToFileURL(join(base, server)) : undefined, + server: + outputMode !== OutputMode.Static && ssrOptions + ? pathToFileURL(join(base, server)) + : undefined, }, prerenderedRoutes, }; @@ -495,4 +508,5 @@ export function getEntryPointName(entryPoint: string): string { export const SERVER_GENERATED_EXTERNALS = new Set([ './polyfills.server.mjs', './' + SERVER_APP_MANIFEST_FILENAME, + './' + SERVER_APP_ENGINE_MANIFEST_FILENAME, ]); diff --git a/packages/angular/build/src/utils/server-rendering/manifest.ts b/packages/angular/build/src/utils/server-rendering/manifest.ts index 89a6f92f3387..2977276e44fe 100644 --- a/packages/angular/build/src/utils/server-rendering/manifest.ts +++ b/packages/angular/build/src/utils/server-rendering/manifest.ts @@ -13,8 +13,10 @@ import { getLocaleBaseHref, } from '../../builders/application/options'; import type { BuildOutputFile } from '../../tools/esbuild/bundler-context'; +import { PrerenderedRoutesRecord } from '../../tools/esbuild/bundler-execution-result'; export const SERVER_APP_MANIFEST_FILENAME = 'angular-app-manifest.mjs'; +export const SERVER_APP_ENGINE_MANIFEST_FILENAME = 'angular-app-engine-manifest.mjs'; const MAIN_SERVER_OUTPUT_FILENAME = 'main.server.mjs'; @@ -29,11 +31,13 @@ const MAIN_SERVER_OUTPUT_FILENAME = 'main.server.mjs'; * includes settings for inlining locales and determining the output structure. * @param baseHref - The base HREF for the application. This is used to set the base URL * for all relative URLs in the application. + * @param perenderedRoutes - A record mapping static paths to their associated data. * @returns A string representing the content of the SSR server manifest for App Engine. */ export function generateAngularServerAppEngineManifest( i18nOptions: NormalizedApplicationBuildOptions['i18nOptions'], baseHref: string | undefined, + perenderedRoutes: PrerenderedRoutesRecord | undefined = {}, ): string { const entryPointsContent: string[] = []; @@ -42,19 +46,40 @@ export function generateAngularServerAppEngineManifest( const importPath = './' + (i18nOptions.flatOutput ? '' : locale + '/') + MAIN_SERVER_OUTPUT_FILENAME; - const localWithBaseHref = getLocaleBaseHref('', i18nOptions, locale) || '/'; - entryPointsContent.push(`['${localWithBaseHref}', () => import('${importPath}')]`); + let localeWithBaseHref = getLocaleBaseHref('', i18nOptions, locale) || '/'; + + // Remove leading and trailing slashes. + const start = localeWithBaseHref[0] === '/' ? 1 : 0; + const end = localeWithBaseHref[localeWithBaseHref.length - 1] === '/' ? -1 : undefined; + localeWithBaseHref = localeWithBaseHref.slice(start, end); + + entryPointsContent.push(`['${localeWithBaseHref}', () => import('${importPath}')]`); } } else { - entryPointsContent.push(`['/', () => import('./${MAIN_SERVER_OUTPUT_FILENAME}')]`); + entryPointsContent.push(`['', () => import('./${MAIN_SERVER_OUTPUT_FILENAME}')]`); } - const manifestContent = ` - { - basePath: '${baseHref ?? '/'}', - entryPoints: new Map([${entryPointsContent.join(', \n')}]), + const staticHeaders: string[] = []; + for (const [path, { headers }] of Object.entries(perenderedRoutes)) { + if (!headers) { + continue; + } + + const headersValues: string[] = []; + for (const [name, value] of Object.entries(headers)) { + headersValues.push(`['${name}', '${encodeURIComponent(value)}']`); + } + + staticHeaders.push(`['${path}', [${headersValues.join(', ')}]]`); } -`; + + const manifestContent = ` +export default { + basePath: '${baseHref ?? '/'}', + entryPoints: new Map([${entryPointsContent.join(', \n')}]), + staticPathsHeaders: new Map([${staticHeaders.join(', \n')}]), +}; + `; return manifestContent; } @@ -75,6 +100,9 @@ export function generateAngularServerAppEngineManifest( * in the server-side rendered pages. * @param routes - An optional array of route definitions for the application, used for * server-side rendering and routing. + * @param locale - An optional string representing the locale or language code to be used for + * the application, helping with localization and rendering content specific to the locale. + * * @returns A string representing the content of the SSR server manifest for the Node.js * environment. */ @@ -83,6 +111,7 @@ export function generateAngularServerAppManifest( outputFiles: BuildOutputFile[], inlineCriticalCss: boolean, routes: readonly unknown[] | undefined, + locale: string | undefined, ): string { const serverAssetsContent: string[] = []; for (const file of [...additionalHtmlOutputFiles.values(), ...outputFiles]) { @@ -101,6 +130,7 @@ export default { inlineCriticalCss: ${inlineCriticalCss}, routes: ${JSON.stringify(routes, undefined, 2)}, assets: new Map([${serverAssetsContent.join(', \n')}]), + locale: ${locale !== undefined ? `'${locale}'` : undefined}, }; `; diff --git a/packages/angular/build/src/utils/server-rendering/models.ts b/packages/angular/build/src/utils/server-rendering/models.ts new file mode 100644 index 000000000000..6b67d707b108 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/models.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 { RenderMode, ɵextractRoutesAndCreateRouteTree } from '@angular/ssr'; +import { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks'; + +export interface RoutesExtractorWorkerData extends ESMInMemoryFileLoaderWorkerData { + assetFiles: Record; +} + +export type SerializableRouteTreeNode = ReturnType< + Awaited>['routeTree']['toObject'] +>; + +export interface RoutersExtractorWorkerResult { + serializedRouteTree: SerializableRouteTreeNode; + errors: string[]; +} + +/** + * Local copy of `RenderMode` exported from `@angular/ssr`. + * This constant is needed to handle interop between CommonJS (CJS) and ES Modules (ESM) formats. + * + * It maps `RenderMode` enum values to their corresponding numeric identifiers. + */ +export const RouteRenderMode: Record = { + AppShell: 0, + Server: 1, + Client: 2, + Prerender: 3, +}; diff --git a/packages/angular/build/src/utils/server-rendering/prerender.ts b/packages/angular/build/src/utils/server-rendering/prerender.ts index 1b53fd9ca6d9..301108c99f0f 100644 --- a/packages/angular/build/src/utils/server-rendering/prerender.ts +++ b/packages/angular/build/src/utils/server-rendering/prerender.ts @@ -10,24 +10,23 @@ import { readFile } from 'node:fs/promises'; import { extname, join, posix } from 'node:path'; import { pathToFileURL } from 'node:url'; import Piscina from 'piscina'; +import { NormalizedApplicationBuildOptions } from '../../builders/application/options'; +import { OutputMode } from '../../builders/application/schema'; import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-context'; import { BuildOutputAsset } from '../../tools/esbuild/bundler-execution-result'; +import { assertIsError } from '../error'; import { urlJoin } from '../url'; +import { + RouteRenderMode, + type RoutersExtractorWorkerResult, + type RoutesExtractorWorkerData, + type SerializableRouteTreeNode, +} from './models'; import type { RenderWorkerData } from './render-worker'; -import type { - RoutersExtractorWorkerResult, - RoutesExtractorWorkerData, - SerializableRouteTreeNode, -} from './routes-extractor-worker'; - -interface PrerenderOptions { - routesFile?: string; - discoverRoutes?: boolean; -} -interface AppShellOptions { - route?: string; -} +type PrerenderOptions = NormalizedApplicationBuildOptions['prerenderOptions']; +type AppShellOptions = NormalizedApplicationBuildOptions['appShellOptions']; +type Writeable = T extends readonly (infer U)[] ? U[] : never; /** * Represents the output of a prerendering process. @@ -48,18 +47,17 @@ type PrerenderOutput = Record, assets: Readonly, + outputMode: OutputMode | undefined, sourcemap = false, maxThreads = 1, - verbose = false, ): Promise<{ output: PrerenderOutput; warnings: string[]; errors: string[]; - prerenderedRoutes: Set; serializableRouteTreeNode: SerializableRouteTreeNode; }> { const outputFilesForWorker: Record = {}; @@ -68,7 +66,7 @@ export async function prerenderPages( const errors: string[] = []; for (const { text, path, type } of outputFiles) { - if (type !== BuildOutputFileType.Server) { + if (type !== BuildOutputFileType.Server && type !== BuildOutputFileType.SSRServer) { continue; } @@ -99,37 +97,58 @@ export async function prerenderPages( } // Get routes to prerender - const { - routes: allRoutes, - warnings: routesWarnings, - errors: routesErrors, - serializableRouteTreeNode, - } = await getAllRoutes( - workspaceRoot, - baseHref, - outputFilesForWorker, - assetsReversed, - appShellOptions, - prerenderOptions, - sourcemap, - verbose, - ); - - if (routesErrors?.length) { - errors.push(...routesErrors); - } + const { errors: extractionErrors, serializedRouteTree: serializableRouteTreeNode } = + await getAllRoutes( + workspaceRoot, + baseHref, + outputFilesForWorker, + assetsReversed, + appShellOptions, + prerenderOptions, + sourcemap, + ).catch((err) => { + return { + errors: [ + `An error occurred while extracting routes.\n\n${err.stack ?? err.message ?? err}`, + ], + serializedRouteTree: [], + }; + }); + + errors.push(...extractionErrors); + + const serializableRouteTreeNodeForPrerender: Writeable = []; + for (const metadata of serializableRouteTreeNode) { + const renderMode = metadata.renderMode; + // TODO(alanagius): land this as a seperate PR. + // Currently, this is broken as fallback routes are included. + // if (outputMode === OutputMode.Static && renderMode === /* RenderMode.Server */ 1) { + // errors.push( + // `Route '${metadata.route}' is configured with Server render mode, but the build outputMode is set to Static.`, + // ); + + // continue; + // } + + if (outputMode !== OutputMode.Static && metadata.redirectTo) { + continue; + } - if (routesWarnings?.length) { - warnings.push(...routesWarnings); + if ( + renderMode === /* Legacy building mode */ undefined || + renderMode === RouteRenderMode.Prerender || + renderMode === RouteRenderMode.AppShell + ) { + serializableRouteTreeNodeForPrerender.push(metadata); + } } - if (allRoutes.size < 1 || errors.length > 0) { + if (!serializableRouteTreeNodeForPrerender.length || errors.length > 0) { return { errors, warnings, output: {}, serializableRouteTreeNode, - prerenderedRoutes: allRoutes, }; } @@ -137,7 +156,7 @@ export async function prerenderPages( const { errors: renderingErrors, output } = await renderPages( baseHref, sourcemap, - allRoutes, + serializableRouteTreeNodeForPrerender, maxThreads, workspaceRoot, outputFilesForWorker, @@ -152,25 +171,18 @@ export async function prerenderPages( warnings, output, serializableRouteTreeNode, - prerenderedRoutes: allRoutes, }; } -class RoutesSet extends Set { - override add(value: string): this { - return super.add(addLeadingSlash(value)); - } -} - async function renderPages( baseHref: string, sourcemap: boolean, - allRoutes: Set, + serializableRouteTreeNode: SerializableRouteTreeNode, maxThreads: number, workspaceRoot: string, outputFilesForWorker: Record, assetFilesForWorker: Record, - appShellOptions: AppShellOptions, + appShellOptions: AppShellOptions | undefined, ): Promise<{ output: PrerenderOutput; errors: string[]; @@ -190,7 +202,7 @@ async function renderPages( const renderWorker = new Piscina({ filename: require.resolve('./render-worker'), - maxThreads: Math.min(allRoutes.size, maxThreads), + maxThreads: Math.min(serializableRouteTreeNode.length, maxThreads), workerData: { workspaceRoot, outputFiles: outputFilesForWorker, @@ -202,22 +214,31 @@ async function renderPages( try { const renderingPromises: Promise[] = []; - const appShellRoute = appShellOptions.route && addLeadingSlash(appShellOptions.route); + const appShellRoute = appShellOptions && addLeadingSlash(appShellOptions.route); const baseHrefWithLeadingSlash = addLeadingSlash(baseHref); - for (const route of allRoutes) { + for (const { route, redirectTo, renderMode } of serializableRouteTreeNode) { // Remove base href from file output path. const routeWithoutBaseHref = addLeadingSlash( route.slice(baseHrefWithLeadingSlash.length - 1), ); + const outPath = posix.join(removeLeadingSlash(routeWithoutBaseHref), 'index.html'); - const render: Promise = renderWorker.run({ url: route }); + if (typeof redirectTo === 'string') { + output[outPath] = { content: generateRedirectStaticPage(redirectTo), appShellRoute: false }; + + continue; + } + + const isAppShellRoute = + renderMode === RouteRenderMode.AppShell || + // Legacy handling + (renderMode === undefined && appShellRoute === routeWithoutBaseHref); + + const render: Promise = renderWorker.run({ url: route, isAppShellRoute }); const renderResult: Promise = render .then((content) => { if (content !== null) { - const outPath = posix.join(removeLeadingSlash(routeWithoutBaseHref), 'index.html'); - const isAppShellRoute = appShellRoute === routeWithoutBaseHref; - output[outPath] = { content, appShellRoute: isAppShellRoute }; } }) @@ -247,33 +268,30 @@ async function getAllRoutes( baseHref: string, outputFilesForWorker: Record, assetFilesForWorker: Record, - appShellOptions: AppShellOptions, - prerenderOptions: PrerenderOptions, + appShellOptions: AppShellOptions | undefined, + prerenderOptions: PrerenderOptions | undefined, sourcemap: boolean, - verbose: boolean, -): Promise<{ - routes: Set; - warnings?: string[]; - errors?: string[]; - serializableRouteTreeNode: SerializableRouteTreeNode; -}> { - const { routesFile, discoverRoutes } = prerenderOptions; - const routes = new RoutesSet(); - const { route: appShellRoute } = appShellOptions; - - if (appShellRoute !== undefined) { - routes.add(urlJoin(baseHref, appShellRoute)); +): Promise<{ serializedRouteTree: SerializableRouteTreeNode; errors: string[] }> { + const { routesFile, discoverRoutes } = prerenderOptions ?? {}; + const routes: Writeable = []; + + if (appShellOptions) { + routes.push({ + route: urlJoin(baseHref, appShellOptions.route), + }); } if (routesFile) { const routesFromFile = (await readFile(routesFile, 'utf8')).split(/\r?\n/); for (const route of routesFromFile) { - routes.add(urlJoin(baseHref, route.trim())); + routes.push({ + route: urlJoin(baseHref, route.trim()), + }); } } if (!discoverRoutes) { - return { routes, serializableRouteTreeNode: [] }; + return { errors: [], serializedRouteTree: routes }; } const workerExecArgv = [ @@ -298,47 +316,22 @@ async function getAllRoutes( recordTiming: false, }); - const errors: string[] = []; - const { serializedRouteTree: serializableRouteTreeNode }: RoutersExtractorWorkerResult = - await renderWorker - .run({}) - .catch((err) => { - errors.push(`An error occurred while extracting routes.\n\n${err.stack}`); - }) - .finally(() => { - void renderWorker.destroy(); - }); - - const skippedRedirects: string[] = []; - const skippedOthers: string[] = []; - for (const { route, redirectTo } of serializableRouteTreeNode) { - if (redirectTo) { - skippedRedirects.push(route); - } else if (route.includes('*')) { - skippedOthers.push(route); - } else { - routes.add(route); - } - } + try { + const { serializedRouteTree, errors }: RoutersExtractorWorkerResult = await renderWorker.run( + {}, + ); - let warnings: string[] | undefined; - if (verbose) { - if (skippedOthers.length) { - (warnings ??= []).push( - 'The following routes were skipped from prerendering because they contain routes with dynamic parameters:\n' + - skippedOthers.join('\n'), - ); - } + return { errors, serializedRouteTree: [...routes, ...serializedRouteTree] }; + } catch (err) { + assertIsError(err); - if (skippedRedirects.length) { - (warnings ??= []).push( - 'The following routes were skipped from prerendering because they contain redirects:\n', - skippedRedirects.join('\n'), - ); - } + return { + errors: [`An error occurred while extracting routes.\n\n${err.stack}`], + serializedRouteTree: [], + }; + } finally { + void renderWorker.destroy(); } - - return { routes, serializableRouteTreeNode, warnings }; } function addLeadingSlash(value: string): string { @@ -348,3 +341,28 @@ function addLeadingSlash(value: string): string { function removeLeadingSlash(value: string): string { return value.charAt(0) === '/' ? value.slice(1) : value; } + +/** + * Generates a static HTML page with a meta refresh tag to redirect the user to a specified URL. + * + * This function creates a simple HTML page that performs a redirect using a meta tag. + * It includes a fallback link in case the meta-refresh doesn't work. + * + * @param url - The URL to which the page should redirect. + * @returns The HTML content of the static redirect page. + */ +function generateRedirectStaticPage(url: string): string { + return ` + + + + + Redirecting + + + +
Redirecting to ${url}
+ + +`.trim(); +} diff --git a/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts b/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts index f62f30649491..0ec97408aa66 100644 --- a/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts +++ b/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts @@ -6,23 +6,9 @@ * found in the LICENSE file at https://angular.dev/license */ -import type { ɵextractRoutesAndCreateRouteTree } from '@angular/ssr'; -import type { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks'; import { patchFetchToLoadInMemoryAssets } from './fetch-patch'; import { loadEsmModuleFromMemory } from './load-esm-from-memory'; - -export interface RoutesExtractorWorkerData extends ESMInMemoryFileLoaderWorkerData { - assetFiles: Record; -} - -export type SerializableRouteTreeNode = ReturnType< - Awaited>['routeTree']['toObject'] ->; - -export interface RoutersExtractorWorkerResult { - serializedRouteTree: SerializableRouteTreeNode; - errors: string[]; -} +import { RoutersExtractorWorkerResult } from './models'; /** Renders an application based on a provided options. */ async function extractRoutes(): Promise { diff --git a/packages/angular/ssr/public_api.ts b/packages/angular/ssr/public_api.ts index 3b6f53e4f292..86856b065309 100644 --- a/packages/angular/ssr/public_api.ts +++ b/packages/angular/ssr/public_api.ts @@ -12,7 +12,7 @@ export { AngularAppEngine } from './src/app-engine'; export { type PrerenderFallback, - type RenderMode, type ServerRoute, provideServerRoutesConfig, + RenderMode, } from './src/routes/route-config'; diff --git a/packages/angular/ssr/src/app-engine.ts b/packages/angular/ssr/src/app-engine.ts index 93e1653fa8c5..623bbae8bc4d 100644 --- a/packages/angular/ssr/src/app-engine.ts +++ b/packages/angular/ssr/src/app-engine.ts @@ -10,7 +10,7 @@ import type { AngularServerApp } from './app'; import { Hooks } from './hooks'; import { getPotentialLocaleIdFromUrl } from './i18n'; import { EntryPointExports, getAngularAppEngineManifest } from './manifest'; -import { stripIndexHtmlFromURL } from './utils/url'; +import { stripIndexHtmlFromURL, stripTrailingSlash } from './utils/url'; /** * Angular server application engine. @@ -116,7 +116,7 @@ export class AngularAppEngine { } const { pathname } = stripIndexHtmlFromURL(new URL(request.url)); - const headers = this.manifest.staticPathsHeaders.get(pathname); + const headers = this.manifest.staticPathsHeaders.get(stripTrailingSlash(pathname)); return new Map(headers); } diff --git a/packages/angular/ssr/src/app.ts b/packages/angular/ssr/src/app.ts index 06ff1f7e5c2b..c314fd2db255 100644 --- a/packages/angular/ssr/src/app.ts +++ b/packages/angular/ssr/src/app.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import { StaticProvider, ɵresetCompiledComponents } from '@angular/core'; +import { LOCALE_ID, StaticProvider, ɵresetCompiledComponents } from '@angular/core'; import { ServerAssets } from './assets'; import { Hooks } from './hooks'; import { getAngularAppManifest } from './manifest'; @@ -172,7 +172,10 @@ export class AngularServerApp { // Initialize the response with status and headers if available. responseInit = { status, - headers: headers ? new Headers(headers) : undefined, + headers: new Headers({ + 'Content-Type': 'text/plain;charset=UTF-8', + ...headers, + }), }; if (renderMode === RenderMode.Client) { @@ -196,7 +199,18 @@ export class AngularServerApp { ); } - const { manifest, hooks, assets } = this; + const { + manifest: { bootstrap, inlineCriticalCss, locale }, + hooks, + assets, + } = this; + + if (locale !== undefined) { + platformProviders.push({ + provide: LOCALE_ID, + useValue: locale, + }); + } let html = await assets.getIndexServerHtml(); // Skip extra microtask if there are no pre hooks. @@ -204,7 +218,7 @@ export class AngularServerApp { html = await hooks.run('html:transform:pre', { html }); } - this.boostrap ??= await manifest.bootstrap(); + this.boostrap ??= await bootstrap(); html = await renderAngular( html, @@ -214,7 +228,7 @@ export class AngularServerApp { SERVER_CONTEXT_VALUE[renderMode], ); - if (manifest.inlineCriticalCss) { + if (inlineCriticalCss) { // Optionally inline critical CSS. this.inlineCriticalCssProcessor ??= new InlineCriticalCssProcessor((path: string) => { const fileName = path.split('/').pop() ?? path; diff --git a/packages/angular/ssr/src/manifest.ts b/packages/angular/ssr/src/manifest.ts index b9e371171e23..f20a04581c3b 100644 --- a/packages/angular/ssr/src/manifest.ts +++ b/packages/angular/ssr/src/manifest.ts @@ -89,6 +89,12 @@ export interface AngularAppManifest { * It is used for route matching and navigation within the server application. */ readonly routes?: SerializableRouteTreeNode; + + /** + * An optional string representing the locale or language code to be used for + * the application, aiding with localization and rendering content specific to the locale. + */ + readonly locale?: string; } /** diff --git a/tests/legacy-cli/e2e.bzl b/tests/legacy-cli/e2e.bzl index e1a45d7fdf80..42433f67edbc 100644 --- a/tests/legacy-cli/e2e.bzl +++ b/tests/legacy-cli/e2e.bzl @@ -42,6 +42,7 @@ ESBUILD_TESTS = [ WEBPACK_IGNORE_TESTS = [ "tests/vite/**", + "tests/server-rendering/server-routes-*", "tests/commands/serve/ssr-http-requests-assets.js", "tests/build/prerender/http-requests-assets.js", "tests/build/prerender/error-with-sourcemaps.js", diff --git a/tests/legacy-cli/e2e/tests/build/prerender/discover-routes-standalone.ts b/tests/legacy-cli/e2e/tests/build/prerender/discover-routes-standalone.ts index 75cb255ff9ae..ad7e6d6cc32f 100644 --- a/tests/legacy-cli/e2e/tests/build/prerender/discover-routes-standalone.ts +++ b/tests/legacy-cli/e2e/tests/build/prerender/discover-routes-standalone.ts @@ -111,15 +111,15 @@ export default async function () { // prerendered-routes.json file is only generated when using esbuild. const generatedRoutesStats = await readFile('dist/test-project/prerendered-routes.json'); deepStrictEqual(JSON.parse(generatedRoutesStats), { - routes: [ - '/', - '/lazy-one', - '/lazy-one/lazy-one-child', - '/lazy-two', - '/two', - '/two/two-child-one', - '/two/two-child-two', - ], + routes: { + '/': { headers: undefined }, + '/lazy-one': { headers: undefined }, + '/lazy-one/lazy-one-child': { headers: undefined }, + '/lazy-two': { headers: undefined }, + '/two': { headers: undefined }, + '/two/two-child-one': { headers: undefined }, + '/two/two-child-two': { headers: undefined }, + }, }); } } diff --git a/tests/legacy-cli/e2e/tests/i18n/setup.ts b/tests/legacy-cli/e2e/tests/i18n/setup.ts index ce673387fdbd..668f51533177 100644 --- a/tests/legacy-cli/e2e/tests/i18n/setup.ts +++ b/tests/legacy-cli/e2e/tests/i18n/setup.ts @@ -102,10 +102,11 @@ export async function setupI18nConfig() { ` import { Component, Inject, LOCALE_ID } from '@angular/core'; import { DatePipe } from '@angular/common'; + import { RouterOutlet } from '@angular/router'; @Component({ selector: 'app-root', - imports: [DatePipe], + imports: [DatePipe, RouterOutlet], standalone: true, templateUrl: './app.component.html' }) @@ -124,6 +125,7 @@ export async function setupI18nConfig() {

{{ locale }}

{{ jan | date : 'LLLL' }}

Updated {minutes, plural, =0 {just now} =1 {one minute ago} other {{{minutes}} minutes ago}}

+ `, ); diff --git a/tests/legacy-cli/e2e/tests/build/ssr/express-engine-csp-nonce.ts b/tests/legacy-cli/e2e/tests/server-rendering/express-engine-csp-nonce.ts similarity index 93% rename from tests/legacy-cli/e2e/tests/build/ssr/express-engine-csp-nonce.ts rename to tests/legacy-cli/e2e/tests/server-rendering/express-engine-csp-nonce.ts index 612407cbfb11..a1958399d490 100644 --- a/tests/legacy-cli/e2e/tests/build/ssr/express-engine-csp-nonce.ts +++ b/tests/legacy-cli/e2e/tests/server-rendering/express-engine-csp-nonce.ts @@ -1,9 +1,9 @@ -import { getGlobalVariable } from '../../../utils/env'; -import { rimraf, writeMultipleFiles } from '../../../utils/fs'; -import { findFreePort } from '../../../utils/network'; -import { installWorkspacePackages } from '../../../utils/packages'; -import { execAndWaitForOutputToMatch, ng } from '../../../utils/process'; -import { updateJsonFile, useSha } from '../../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; +import { rimraf, writeMultipleFiles } from '../../utils/fs'; +import { findFreePort } from '../../utils/network'; +import { installWorkspacePackages } from '../../utils/packages'; +import { execAndWaitForOutputToMatch, ng } from '../../utils/process'; +import { updateJsonFile, useSha } from '../../utils/project'; export default async function () { const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; diff --git a/tests/legacy-cli/e2e/tests/build/ssr/express-engine-ngmodule.ts b/tests/legacy-cli/e2e/tests/server-rendering/express-engine-ngmodule.ts similarity index 94% rename from tests/legacy-cli/e2e/tests/build/ssr/express-engine-ngmodule.ts rename to tests/legacy-cli/e2e/tests/server-rendering/express-engine-ngmodule.ts index e49497f4ea69..42c8d735d528 100644 --- a/tests/legacy-cli/e2e/tests/build/ssr/express-engine-ngmodule.ts +++ b/tests/legacy-cli/e2e/tests/server-rendering/express-engine-ngmodule.ts @@ -1,9 +1,9 @@ -import { getGlobalVariable } from '../../../utils/env'; -import { rimraf, writeMultipleFiles } from '../../../utils/fs'; -import { findFreePort } from '../../../utils/network'; -import { installWorkspacePackages } from '../../../utils/packages'; -import { execAndWaitForOutputToMatch, ng } from '../../../utils/process'; -import { updateJsonFile, useCIChrome, useCIDefaults, useSha } from '../../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; +import { rimraf, writeMultipleFiles } from '../../utils/fs'; +import { findFreePort } from '../../utils/network'; +import { installWorkspacePackages } from '../../utils/packages'; +import { execAndWaitForOutputToMatch, ng } from '../../utils/process'; +import { updateJsonFile, useCIChrome, useCIDefaults, useSha } from '../../utils/project'; export default async function () { // forcibly remove in case another test doesn't clean itself up diff --git a/tests/legacy-cli/e2e/tests/build/ssr/express-engine-standalone.ts b/tests/legacy-cli/e2e/tests/server-rendering/express-engine-standalone.ts similarity index 92% rename from tests/legacy-cli/e2e/tests/build/ssr/express-engine-standalone.ts rename to tests/legacy-cli/e2e/tests/server-rendering/express-engine-standalone.ts index cb51de4e6ac8..0922e8800a8e 100644 --- a/tests/legacy-cli/e2e/tests/build/ssr/express-engine-standalone.ts +++ b/tests/legacy-cli/e2e/tests/server-rendering/express-engine-standalone.ts @@ -1,9 +1,9 @@ -import { getGlobalVariable } from '../../../utils/env'; -import { rimraf, writeMultipleFiles } from '../../../utils/fs'; -import { findFreePort } from '../../../utils/network'; -import { installWorkspacePackages } from '../../../utils/packages'; -import { execAndWaitForOutputToMatch, ng } from '../../../utils/process'; -import { updateJsonFile, useSha } from '../../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; +import { rimraf, writeMultipleFiles } from '../../utils/fs'; +import { findFreePort } from '../../utils/network'; +import { installWorkspacePackages } from '../../utils/packages'; +import { execAndWaitForOutputToMatch, ng } from '../../utils/process'; +import { updateJsonFile, useSha } from '../../utils/project'; export default async function () { // forcibly remove in case another test doesn't clean itself up diff --git a/tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-server-i18n-base-href.ts b/tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-server-i18n-base-href.ts new file mode 100644 index 000000000000..786978748beb --- /dev/null +++ b/tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-server-i18n-base-href.ts @@ -0,0 +1,89 @@ +import { join } from 'node:path'; +import assert from 'node:assert'; +import { expectFileToMatch, writeFile } from '../../utils/fs'; +import { noSilentNg, silentNg } from '../../utils/process'; +import { setupProjectWithSSRAppEngine, spawnServer } from './setup'; +import { langTranslations, setupI18nConfig } from '../i18n/setup'; + +export default async function () { + // Setup project + await setupI18nConfig(); + await setupProjectWithSSRAppEngine(); + + // Add routes + await writeFile( + 'src/app/app.routes.ts', + ` + import { Routes } from '@angular/router'; + import { HomeComponent } from './home/home.component'; + import { SsrComponent } from './ssr/ssr.component'; + import { SsgComponent } from './ssg/ssg.component'; + + export const routes: Routes = [ + { + path: '', + component: HomeComponent, + }, + { + path: 'ssg', + component: SsgComponent, + }, + { + path: 'ssr', + component: SsrComponent, + }, + ]; + `, + ); + + // Add server routing + await writeFile( + 'src/app/app.routes.server.ts', + ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const routes: ServerRoute[] = [ + { + path: '', + renderMode: RenderMode.Prerender, + }, + { + path: 'ssg', + renderMode: RenderMode.Prerender, + }, + { + path: '**', + renderMode: RenderMode.Server, + }, + ]; + `, + ); + + // Generate components for the above routes + const componentNames: string[] = ['home', 'ssg', 'csr', 'ssr']; + for (const componentName of componentNames) { + await silentNg('generate', 'component', componentName); + } + + await noSilentNg('build', '--output-mode=server', '--base-href=/base/'); + + for (const { lang, outputPath } of langTranslations) { + await expectFileToMatch(join(outputPath, 'index.html'), `

${lang}

`); + await expectFileToMatch(join(outputPath, 'ssg/index.html'), `

${lang}

`); + } + + // Tests responses + const port = await spawnServer(); + const pathname = '/ssr'; + + for (const { lang } of langTranslations) { + const res = await fetch(`http://localhost:${port}/base/${lang}${pathname}`); + const text = await res.text(); + + assert.match( + text, + new RegExp(`

${lang}

`), + `Response for '${lang}${pathname}': '

${lang}

' was not matched in content.`, + ); + } +} diff --git a/tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-server-i18n.ts b/tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-server-i18n.ts new file mode 100644 index 000000000000..e06844f3072e --- /dev/null +++ b/tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-server-i18n.ts @@ -0,0 +1,101 @@ +import { join } from 'node:path'; +import assert from 'node:assert'; +import { expectFileToMatch, writeFile } from '../../utils/fs'; +import { noSilentNg, silentNg } from '../../utils/process'; +import { setupProjectWithSSRAppEngine, spawnServer } from './setup'; +import { langTranslations, setupI18nConfig } from '../i18n/setup'; + +export default async function () { + // Setup project + await setupI18nConfig(); + await setupProjectWithSSRAppEngine(); + + // Add routes + await writeFile( + 'src/app/app.routes.ts', + ` + import { Routes } from '@angular/router'; + import { HomeComponent } from './home/home.component'; + import { SsrComponent } from './ssr/ssr.component'; + import { SsgComponent } from './ssg/ssg.component'; + + export const routes: Routes = [ + { + path: '', + component: HomeComponent, + }, + { + path: 'ssg', + component: SsgComponent, + }, + { + path: 'ssr', + component: SsrComponent, + }, + ]; + `, + ); + + // Add server routing + await writeFile( + 'src/app/app.routes.server.ts', + ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const routes: ServerRoute[] = [ + { + path: '', + renderMode: RenderMode.Prerender, + }, + { + path: 'ssg', + renderMode: RenderMode.Prerender, + }, + { + path: '**', + renderMode: RenderMode.Server, + }, + ]; + `, + ); + + // Generate components for the above routes + const componentNames: string[] = ['home', 'ssg', 'ssr']; + for (const componentName of componentNames) { + await silentNg('generate', 'component', componentName); + } + + await noSilentNg('build', '--output-mode=server'); + + const expects: Record = { + 'index.html': 'home works!', + 'ssg/index.html': 'ssg works!', + }; + + for (const { lang, outputPath } of langTranslations) { + for (const [filePath, fileMatch] of Object.entries(expects)) { + await expectFileToMatch(join(outputPath, filePath), `

${lang}

`); + await expectFileToMatch(join(outputPath, filePath), fileMatch); + } + } + + // Tests responses + const port = await spawnServer(); + const pathname = '/ssr'; + + // We run the tests twice to ensure that the locale ID is set correctly. + for (const iteration of [1, 2]) { + for (const { lang, translation } of langTranslations) { + const res = await fetch(`http://localhost:${port}/${lang}${pathname}`); + const text = await res.text(); + + for (const match of [`

${translation.date}

`, `

${lang}

`]) { + assert.match( + text, + new RegExp(match), + `Response for '${lang}${pathname}': '${match}' was not matched in content. Iteration: ${iteration}.`, + ); + } + } + } +} diff --git a/tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-server.ts b/tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-server.ts new file mode 100644 index 000000000000..f5d38b2a09ce --- /dev/null +++ b/tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-server.ts @@ -0,0 +1,179 @@ +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; +import assert from 'node:assert'; +import { expectFileToMatch, writeFile } from '../../utils/fs'; +import { noSilentNg, silentNg } from '../../utils/process'; +import { setupProjectWithSSRAppEngine, spawnServer } from './setup'; + +export default async function () { + // Setup project + await setupProjectWithSSRAppEngine(); + + // Add routes + await writeFile( + 'src/app/app.routes.ts', + ` + import { Routes } from '@angular/router'; + import { HomeComponent } from './home/home.component'; + import { CsrComponent } from './csr/csr.component'; + import { SsrComponent } from './ssr/ssr.component'; + import { SsgComponent } from './ssg/ssg.component'; + import { AppShellComponent } from './app-shell/app-shell.component'; + import { SsgWithParamsComponent } from './ssg-with-params/ssg-with-params.component'; + + export const routes: Routes = [ + { + path: 'app-shell', + component: AppShellComponent + }, + { + path: '', + component: HomeComponent, + }, + { + path: 'ssg', + component: SsgComponent, + }, + { + path: 'ssr', + component: SsrComponent, + }, + { + path: 'csr', + component: CsrComponent, + }, + { + path: 'redirect', + redirectTo: 'ssg' + }, + { + path: 'ssg/:id', + component: SsgWithParamsComponent, + }, + ]; + `, + ); + + // Add server routing + await writeFile( + 'src/app/app.routes.server.ts', + ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const routes: ServerRoute[] = [ + { + path: 'ssg/:id', + renderMode: RenderMode.Prerender, + headers: { 'x-custom': 'ssg-with-params' }, + getPrerenderParams: async() => [{id: 'one'}, {id: 'two'}], + }, + { + path: 'ssr', + renderMode: RenderMode.Server, + headers: { 'x-custom': 'ssr' }, + }, + { + path: 'csr', + renderMode: RenderMode.Client, + headers: { 'x-custom': 'csr' }, + }, + { + path: 'app-shell', + renderMode: RenderMode.AppShell, + }, + { + path: '**', + renderMode: RenderMode.Prerender, + headers: { 'x-custom': 'ssg' }, + }, + ]; + `, + ); + + // Generate components for the above routes + const componentNames: string[] = ['home', 'ssg', 'ssg-with-params', 'csr', 'ssr', 'app-shell']; + + for (const componentName of componentNames) { + await silentNg('generate', 'component', componentName); + } + + await noSilentNg('build', '--output-mode=server'); + + const expects: Record = { + 'index.html': 'home works!', + 'ssg/index.html': 'ssg 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.`); + } + + // Tests responses + const responseExpects: Record< + string, + { headers: Record; content: string; serverContext: string } + > = { + '/': { + content: 'home works', + serverContext: 'ng-server-context="ssg"', + headers: { + 'x-custom': 'ssg', + }, + }, + '/ssg': { + content: 'ssg works!', + serverContext: 'ng-server-context="ssg"', + headers: { + 'x-custom': 'ssg', + }, + }, + '/ssr': { + content: 'ssr works!', + serverContext: 'ng-server-context="ssr"', + headers: { + 'x-custom': 'ssr', + }, + }, + '/csr': { + content: 'app-shell works', + serverContext: 'ng-server-context="app-shell"', + headers: { + 'x-custom': 'csr', + }, + }, + }; + + const port = await spawnServer(); + for (const [pathname, { content, headers, serverContext }] of Object.entries(responseExpects)) { + const res = await fetch(`http://localhost:${port}${pathname}`); + const text = await res.text(); + + assert.match( + text, + new RegExp(content), + `Response for '${pathname}': ${content} was not matched in content.`, + ); + + assert.match( + text, + new RegExp(serverContext), + `Response for '${pathname}': ${serverContext} was not matched in content.`, + ); + + for (const [name, value] of Object.entries(headers)) { + assert.equal( + res.headers.get(name), + value, + `Response for '${pathname}': ${name} header value did not match expected.`, + ); + } + } +} diff --git a/tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-static.ts b/tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-static.ts new file mode 100644 index 000000000000..ae322eaa7e2e --- /dev/null +++ b/tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-static.ts @@ -0,0 +1,89 @@ +import { join } from 'node:path'; +import { expectFileToMatch, writeFile } from '../../utils/fs'; +import { noSilentNg, silentNg } from '../../utils/process'; +import { setupProjectWithSSRAppEngine } from './setup'; +import { existsSync } from 'node:fs'; +import { assert } from 'node:console'; + +export default async function () { + // Setup project + await setupProjectWithSSRAppEngine(); + + // Add routes + await writeFile( + 'src/app/app.routes.ts', + ` + import { Routes } from '@angular/router'; + import { HomeComponent } from './home/home.component'; + import { SsgComponent } from './ssg/ssg.component'; + import { SsgWithParamsComponent } from './ssg-with-params/ssg-with-params.component'; + + export const routes: Routes = [ + { + path: '', + component: HomeComponent, + }, + { + path: 'ssg', + component: SsgComponent, + }, + { + path: 'ssg-redirect', + redirectTo: 'ssg' + }, + { + path: 'ssg/:id', + component: SsgWithParamsComponent, + }, + ]; + `, + ); + + // Add server routing + await writeFile( + 'src/app/app.routes.server.ts', + ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const routes: ServerRoute[] = [ + { + path: 'ssg/:id', + renderMode: RenderMode.Prerender, + getPrerenderParams: async() => [{id: 'one'}, {id: 'two'}], + }, + { + path: '**', + renderMode: RenderMode.Prerender, + }, + ]; + `, + ); + + // Generate components for the above routes + const componentNames: string[] = ['home', 'ssg', 'ssg-with-params']; + + for (const componentName of componentNames) { + await silentNg('generate', 'component', componentName); + } + + await noSilentNg('build', '--output-mode=static'); + + const expects: Record = { + 'index.html': 'home works!', + 'ssg/index.html': 'ssg works!', + 'ssg/one/index.html': 'ssg-with-params works!', + 'ssg/two/index.html': 'ssg-with-params works!', + // When static redirects as generated as meta tags. + 'ssg-redirect/index.html': '', + }; + + for (const [filePath, fileMatch] of Object.entries(expects)) { + await expectFileToMatch(join('dist/test-project/browser', filePath), fileMatch); + } + + // Check that server directory does not exist + assert( + !existsSync('dist/test-project/server'), + 'Server directory should not exist when output-mode is static', + ); +} diff --git a/tests/legacy-cli/e2e/tests/server-rendering/setup.ts b/tests/legacy-cli/e2e/tests/server-rendering/setup.ts new file mode 100644 index 000000000000..cc4a9af07102 --- /dev/null +++ b/tests/legacy-cli/e2e/tests/server-rendering/setup.ts @@ -0,0 +1,127 @@ +import { getGlobalVariable } from '../../utils/env'; +import { rimraf, writeFile } from '../../utils/fs'; +import { findFreePort } from '../../utils/network'; +import { installWorkspacePackages } from '../../utils/packages'; +import { execAndWaitForOutputToMatch, ng } from '../../utils/process'; +import { updateJsonFile, useSha } from '../../utils/project'; +import assert from 'node:assert'; + +export async function spawnServer(): Promise { + const port = await findFreePort(); + await execAndWaitForOutputToMatch( + 'npm', + ['run', 'serve:ssr:test-project'], + /Node Express server listening on/, + { + 'PORT': String(port), + }, + ); + + return port; +} + +export async function setupProjectWithSSRAppEngine(): Promise { + 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 rimraf('node_modules/@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + + await useSha(); + await installWorkspacePackages(); + + // Add server config + await writeFile( + 'src/app/app.config.server.ts', + ` + import { mergeApplicationConfig, ApplicationConfig } from '@angular/core'; + import { provideServerRendering } from '@angular/platform-server'; + import { provideServerRoutesConfig } from '@angular/ssr'; + import { routes } from './app.routes.server'; + import { appConfig } from './app.config'; + + const serverConfig: ApplicationConfig = { + providers: [ + provideServerRoutesConfig(routes), + provideServerRendering() + ] + }; + + export const config = mergeApplicationConfig(appConfig, serverConfig); + `, + ); + + // Update server.ts + await writeFile( + 'server.ts', + ` + import { AngularNodeAppEngine, writeResponseToNodeResponse } from '@angular/ssr/node'; + import express from 'express'; + import { fileURLToPath } from 'node:url'; + import { dirname, resolve } from 'node:path'; + + // The Express app is exported so that it can be used by serverless Functions. + export function app(): express.Express { + const server = express(); + const serverDistFolder = dirname(fileURLToPath(import.meta.url)); + const browserDistFolder = resolve(serverDistFolder, '../browser'); + + const angularNodeAppEngine = new AngularNodeAppEngine(); + + server.set('view engine', 'html'); + server.set('views', browserDistFolder); + + server.get('**', express.static(browserDistFolder, { + maxAge: '1y', + index: 'index.html', + setHeaders: (res, path) => { + const headers = angularNodeAppEngine.getPrerenderHeaders(res.req); + for (const [key, value] of headers) { + res.setHeader(key, value); + } + } + })); + + // All regular routes use the Angular engine + server.get('**', (req, res, next) => { + angularNodeAppEngine + .render(req) + .then((response) => { + if (response) { + return writeResponseToNodeResponse(response, res); + } + + return next(); + }) + .catch((err) => next(err)); + }); + + return server; + } + + function run(): void { + const port = process.env['PORT'] || 4000; + + // Start up the Node server + const server = app(); + server.listen(port, () => { + console.log(\`Node Express server listening on http://localhost:\${port}\`); + }); + } + + run(); +`, + ); + + // Update angular.json + await updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + const options = appArchitect.build.options; + + delete options.prerender; + delete options.appShell; + }); +}