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..f10540433008 100644 --- a/packages/angular/build/src/builders/application/execute-post-bundle.ts +++ b/packages/angular/build/src/builders/application/execute-post-bundle.ts @@ -12,17 +12,25 @@ 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'; +import { loadEsmModule } from '../../utils/load-esm'; import { SERVER_APP_MANIFEST_FILENAME, generateAngularServerAppManifest, } from '../../utils/server-rendering/manifest'; import { prerenderPages } from '../../utils/server-rendering/prerender'; +import { RoutersExtractorWorkerResult as SerializableRouteTreeNode } from '../../utils/server-rendering/routes-extractor-worker'; 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 +51,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 +65,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 +106,7 @@ export async function executePostBundleSteps( } // Create server manifest - if (prerenderOptions || appShellOptions || ssrOptions) { + if (serverEntryPoint) { additionalOutputFiles.push( createOutputFile( SERVER_APP_MANIFEST_FILENAME, @@ -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,24 @@ export async function executePostBundleSteps( ); } - if (ssrOptions) { + const { RenderMode } = await loadEsmModule('@angular/ssr'); + const serializableRouteTreeNodeForManifest: Writeable = []; + + for (const metadata of serializableRouteTreeNode) { + switch (metadata.renderMode) { + case RenderMode.Prerender: + prerenderedRoutes[metadata.route] = { headers: metadata.headers }; + break; + + case RenderMode.Server: + case RenderMode.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 +187,7 @@ export async function executePostBundleSteps( additionalHtmlOutputFiles, outputFiles, optimizationOptions.styles.inlineCritical ?? false, - serializableRouteTreeNode, + serializableRouteTreeNodeForManifest, ), ); } diff --git a/packages/angular/build/src/builders/application/i18n.ts b/packages/angular/build/src/builders/application/i18n.ts index ce5e0c7f27fc..a90c0c4c8464 100644 --- a/packages/angular/build/src/builders/application/i18n.ts +++ b/packages/angular/build/src/builders/application/i18n.ts @@ -9,7 +9,10 @@ import { BuilderContext } from '@angular-devkit/architect'; import { join, posix } 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..f256c7baad88 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.' : '.'; @@ -236,6 +236,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..8823b719aead 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,27 @@ 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 must be omitted when "outputMode" is specified.'); + } + + if (options.appShell) { + context.logger.warn('The "appShell" option must be omitted 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); @@ -317,6 +349,7 @@ export async function normalizeOptions( poll, polyfills, statsJson, + outputMode, stylePreprocessorOptions, subresourceIntegrity, verbose, @@ -328,6 +361,7 @@ export async function normalizeOptions( deployUrl, clearScreen, define, + disableFullServerManifestGeneration = false, } = options; // Return all the normalized options @@ -352,6 +386,7 @@ export async function normalizeOptions( serverEntryPoint, prerenderOptions, appShellOptions, + outputMode, ssrOptions, verbose, watch, @@ -387,6 +422,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/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..2e5416d03516 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[] = []; @@ -49,12 +53,26 @@ export function generateAngularServerAppEngineManifest( entryPointsContent.push(`['/', () => import('./${MAIN_SERVER_OUTPUT_FILENAME}')]`); } + const staticHeaders: string[] = []; + for (const [path, { headers }] of Object.entries(perenderedRoutes)) { + if (!headers) { + continue; + } + + const headersValues = Object.entries(headers).map( + (name, value) => `['${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 +93,8 @@ 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. + + * * @returns A string representing the content of the SSR server manifest for the Node.js * environment. */ diff --git a/packages/angular/build/src/utils/server-rendering/prerender.ts b/packages/angular/build/src/utils/server-rendering/prerender.ts index f6e1b877b819..b450c06863f5 100644 --- a/packages/angular/build/src/utils/server-rendering/prerender.ts +++ b/packages/angular/build/src/utils/server-rendering/prerender.ts @@ -10,8 +10,11 @@ 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 { loadEsmModule } from '../load-esm'; import { urlJoin } from '../url'; import type { RenderWorkerData } from './render-worker'; import type { @@ -19,14 +22,9 @@ import type { RoutersExtractorWorkerResult as 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. @@ -47,18 +45,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 = {}; @@ -67,7 +64,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; } @@ -98,12 +95,7 @@ export async function prerenderPages( } // Get routes to prerender - const { - routes: allRoutes, - warnings: routesWarnings, - errors: routesErrors, - serializableRouteTreeNode, - } = await getAllRoutes( + const serializableRouteTreeNode = await getAllRoutes( workspaceRoot, baseHref, outputFilesForWorker, @@ -111,24 +103,18 @@ export async function prerenderPages( appShellOptions, prerenderOptions, sourcemap, - verbose, - ); + ).catch((err) => { + errors.push(`An error occurred while extracting routes.\n\n${err.stack ?? err.message ?? err}`); - if (routesErrors?.length) { - errors.push(...routesErrors); - } - - if (routesWarnings?.length) { - warnings.push(...routesWarnings); - } + return []; + }); - if (allRoutes.size < 1 || errors.length > 0) { + if (!serializableRouteTreeNode.length || errors.length > 0) { return { errors, warnings, output: {}, serializableRouteTreeNode, - prerenderedRoutes: allRoutes, }; } @@ -136,12 +122,13 @@ export async function prerenderPages( const { errors: renderingErrors, output } = await renderPages( baseHref, sourcemap, - allRoutes, + serializableRouteTreeNode, maxThreads, workspaceRoot, outputFilesForWorker, assetsReversed, appShellOptions, + outputMode, ); errors.push(...renderingErrors); @@ -151,25 +138,19 @@ 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, + outputMode: OutputMode | undefined, ): Promise<{ output: PrerenderOutput; errors: string[]; @@ -189,7 +170,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, @@ -199,24 +180,36 @@ async function renderPages( recordTiming: false, }); + const { RenderMode } = await loadEsmModule('@angular/ssr'); + 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'); + + // If the output mode is static and a redirect occurs, generate a static page with a meta refresh tag. + if (outputMode === OutputMode.Static && typeof redirectTo === 'string') { + output[outPath] = { content: generateRedirectStaticPage(redirectTo), appShellRoute: false }; - const render: Promise = renderWorker.run({ url: route }); + continue; + } + + const isAppShellRoute = + renderMode === RenderMode.AppShell || + // Legacy handling + (renderMode === RenderMode.Server && 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 }; } }) @@ -246,33 +239,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; +): Promise { + const { routesFile, discoverRoutes } = prerenderOptions ?? {}; + const routes: Writeable = []; - if (appShellRoute !== undefined) { - routes.add(urlJoin(baseHref, appShellRoute)); + 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 routes; } const workerExecArgv = [ @@ -297,46 +287,13 @@ async function getAllRoutes( recordTiming: false, }); - const errors: string[] = []; const serializableRouteTreeNode: SerializableRouteTreeNode = 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); - } - } - - 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'), - ); - } - - if (skippedRedirects.length) { - (warnings ??= []).push( - 'The following routes were skipped from prerendering because they contain redirects:\n', - skippedRedirects.join('\n'), - ); - } - } - - return { routes, serializableRouteTreeNode, warnings }; + return [...routes, ...serializableRouteTreeNode]; } function addLeadingSlash(value: string): string { @@ -346,3 +303,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/render-worker.ts b/packages/angular/build/src/utils/server-rendering/render-worker.ts index a8af04be4939..6ffa003a3511 100644 --- a/packages/angular/build/src/utils/server-rendering/render-worker.ts +++ b/packages/angular/build/src/utils/server-rendering/render-worker.ts @@ -30,7 +30,7 @@ async function renderPage({ url }: RenderOptions): Promise { AbortSignal.timeout(30_000), ); - return response ? response.text() : null; + return response?.text() ?? null; } function initialize() { 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/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 }, + }, }); } }