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..1be46d5f16d4 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 + ServerApplication = 2, + // (undocumented) + ServerRoot = 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..ef3bb68d4184 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.ServerRoot, + ); + } + // 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..5ed286b177d9 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,14 @@ import { SERVER_APP_MANIFEST_FILENAME, generateAngularServerAppManifest, } from '../../utils/server-rendering/manifest'; +import { + RouteRenderMode, + WritableSerializableRouteTreeNode, +} 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'; /** * 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). @@ -91,13 +100,13 @@ export async function executePostBundleSteps( if (ssrContent) { additionalHtmlOutputFiles.set( INDEX_HTML_SERVER, - createOutputFile(INDEX_HTML_SERVER, ssrContent, BuildOutputFileType.Server), + createOutputFile(INDEX_HTML_SERVER, ssrContent, BuildOutputFileType.ServerApplication), ); } } // Create server manifest - if (prerenderOptions || appShellOptions || ssrOptions) { + if (serverEntryPoint) { additionalOutputFiles.push( createOutputFile( SERVER_APP_MANIFEST_FILENAME, @@ -106,44 +115,41 @@ export async function executePostBundleSteps( outputFiles, optimizationOptions.styles.inlineCritical ?? false, undefined, + locale, ), - BuildOutputFileType.Server, + BuildOutputFileType.ServerApplication, ), ); } // 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 +161,26 @@ export async function executePostBundleSteps( ); } - if (ssrOptions) { + const serializableRouteTreeNodeForManifest: WritableSerializableRouteTreeNode = []; + + 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 +190,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..cfb044f0e34f 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.ServerRoot, + ), // 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..b99a19e9402f 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,11 @@ 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.ServerApplication || + file.type === BuildOutputFileType.ServerRoot) + ) { return; } @@ -235,7 +239,8 @@ export async function* buildApplication( case BuildOutputFileType.Media: typeDirectory = outputOptions.browser; break; - case BuildOutputFileType.Server: + case BuildOutputFileType.ServerApplication: + case BuildOutputFileType.ServerRoot: 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..b3f2c0ddcba8 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,37 @@ 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.server) { + options.ssr = false; + } + + 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 +277,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 +362,7 @@ export async function normalizeOptions( poll, polyfills, statsJson, + outputMode, stylePreprocessorOptions, subresourceIntegrity, verbose, @@ -328,6 +374,7 @@ export async function normalizeOptions( deployUrl, clearScreen, define, + disableFullServerManifestGeneration = false, } = options; // Return all the normalized options @@ -352,6 +399,7 @@ export async function normalizeOptions( serverEntryPoint, prerenderOptions, appShellOptions, + outputMode, ssrOptions, verbose, watch, @@ -387,6 +435,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..36667323251b 100644 --- a/packages/angular/build/src/builders/dev-server/vite-server.ts +++ b/packages/angular/build/src/builders/dev-server/vite-server.ts @@ -11,7 +11,7 @@ import type { BuilderContext } from '@angular-devkit/architect'; import type { Plugin } from 'esbuild'; import assert from 'node:assert'; import { readFile } from 'node:fs/promises'; -import { builtinModules } from 'node:module'; +import { builtinModules, isBuiltin } from 'node:module'; import { join } from 'node:path'; import type { Connect, DepOptimizationConfig, InlineConfig, ViteDevServer } from 'vite'; import { createAngularMemoryPlugin } from '../../tools/vite/angular-memory-plugin'; @@ -93,21 +93,19 @@ export async function* serveWithVite( builderName, )) as unknown as ApplicationBuilderInternalOptions; - if (browserOptions.prerender || browserOptions.ssr) { + if (browserOptions.prerender) { // Disable prerendering if enabled and force SSR. // 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); } // 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 ( @@ -123,7 +121,14 @@ export async function* serveWithVite( browserOptions.forceI18nFlatOutput = true; } - const { vendor: thirdPartySourcemaps } = normalizeSourceMaps(browserOptions.sourceMap ?? false); + const { vendor: thirdPartySourcemaps, scripts: scriptsSourcemaps } = normalizeSourceMaps( + browserOptions.sourceMap ?? false, + ); + + if (scriptsSourcemaps && browserOptions.server) { + // https://nodejs.org/api/process.html#processsetsourcemapsenabledval + process.setSourceMapsEnabled(true); + } // Setup the prebundling transformer that will be shared across Vite prebundling requests const prebundleTransformer = new JavaScriptTransformer( @@ -229,9 +234,9 @@ export async function* serveWithVite( 'externalMetadata' ] as ExternalResultMetadata; const implicitServerFiltered = implicitServer.filter( - (m) => removeNodeJsBuiltinModules(m) && removeAbsoluteUrls(m), + (m) => !isBuiltin(m) && !isAbsoluteUrl(m), ); - const implicitBrowserFiltered = implicitBrowser.filter(removeAbsoluteUrls); + const implicitBrowserFiltered = implicitBrowser.filter((m) => !isAbsoluteUrl(m)); if (browserOptions.ssr && serverOptions.prebundle !== false) { const previousImplicitServer = new Set(externalMetadata.implicitServer); @@ -249,7 +254,7 @@ export async function* serveWithVite( externalMetadata.implicitBrowser.length = 0; externalMetadata.explicitBrowser.push(...explicit); - externalMetadata.explicitServer.push(...explicit, ...nodeJsBuiltinModules); + externalMetadata.explicitServer.push(...explicit, ...builtinModules); externalMetadata.implicitServer.push(...implicitServerFiltered); externalMetadata.implicitBrowser.push(...implicitBrowserFiltered); @@ -386,7 +391,7 @@ async function handleUpdate( for (const [file, record] of generatedFiles) { if (record.updated) { updatedFiles.push(file); - isServerFileUpdated ||= record.type === BuildOutputFileType.Server; + isServerFileUpdated ||= record.type === BuildOutputFileType.ServerApplication; const updatedModules = server.moduleGraph.getModulesByFile( normalizePath(join(server.config.root, file)), @@ -744,14 +749,14 @@ function getDepOptimizationConfig({ }; } -const nodeJsBuiltinModules = new Set(builtinModules); - -/** Remove any Node.js builtin modules to avoid Vite's prebundling from processing them as files. */ -function removeNodeJsBuiltinModules(value: string): boolean { - return !nodeJsBuiltinModules.has(value); -} - -/** Remove any absolute URLs (http://, https://, //) to avoid Vite's prebundling from processing them as files. */ -function removeAbsoluteUrls(value: string): boolean { - return !/^(?:https?:)?\/\//.test(value); +/** + * Checks if the given value is an absolute URL. + * + * This function helps in avoiding Vite's prebundling from processing absolute URLs (http://, https://, //) as files. + * + * @param value - The URL or path to check. + * @returns `true` if the value is not an absolute URL; otherwise, `false`. + */ +function isAbsoluteUrl(value: string): boolean { + return /^(?:https?:)?\/\//.test(value); } 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..ee9b6b3b6dde 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.ServerApplication || type === BuildOutputFileType.ServerRoot) { 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..85d2829d044d 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, + ServerApplication, + ServerRoot, + 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.ServerRoot + : BuildOutputFileType.ServerApplication; } 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 3075595aee87..2fcb8e6dbd55 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.ServerRoot) { + // 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..11c2c64b26e5 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.ServerApplication || type === BuildOutputFileType.ServerRoot; 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..bafcaa6b320c --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/models.ts @@ -0,0 +1,40 @@ +/** + * @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'; + +type Writeable = T extends readonly (infer U)[] ? U[] : never; + +export interface RoutesExtractorWorkerData extends ESMInMemoryFileLoaderWorkerData { + assetFiles: Record; +} + +export type SerializableRouteTreeNode = ReturnType< + Awaited>['routeTree']['toObject'] +>; + +export type WritableSerializableRouteTreeNode = Writeable; + +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 55fec73b5210..dd26395e4229 100644 --- a/packages/angular/build/src/utils/server-rendering/prerender.ts +++ b/packages/angular/build/src/utils/server-rendering/prerender.ts @@ -9,25 +9,24 @@ import { readFile } from 'node:fs/promises'; import { extname, join, posix } from 'node:path'; import { pathToFileURL } from 'node:url'; +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 { WorkerPool } from '../worker-pool'; -import type { RenderWorkerData } from './render-worker'; -import type { +import { + RouteRenderMode, RoutersExtractorWorkerResult, RoutesExtractorWorkerData, SerializableRouteTreeNode, -} from './routes-extractor-worker'; + WritableSerializableRouteTreeNode, +} from './models'; +import type { RenderWorkerData } from './render-worker'; -interface PrerenderOptions { - routesFile?: string; - discoverRoutes?: boolean; -} - -interface AppShellOptions { - route?: string; -} +type PrerenderOptions = NormalizedApplicationBuildOptions['prerenderOptions']; +type AppShellOptions = NormalizedApplicationBuildOptions['appShellOptions']; /** * 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.ServerApplication && type !== BuildOutputFileType.ServerRoot) { continue; } @@ -99,37 +97,56 @@ 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, - ); + const { errors: extractionErrors, serializedRouteTree: serializableRouteTreeNode } = + await getAllRoutes( + workspaceRoot, + baseHref, + outputFilesForWorker, + assetsReversed, + appShellOptions, + prerenderOptions, + sourcemap, + outputMode, + ).catch((err) => { + return { + errors: [ + `An error occurred while extracting routes.\n\n${err.stack ?? err.message ?? err}`, + ], + serializedRouteTree: [], + }; + }); + + errors.push(...extractionErrors); + + const serializableRouteTreeNodeForPrerender: WritableSerializableRouteTreeNode = []; + for (const metadata of serializableRouteTreeNode) { + if (outputMode !== OutputMode.Static && metadata.redirectTo) { + continue; + } - if (routesErrors?.length) { - errors.push(...routesErrors); - } + switch (metadata.renderMode) { + case undefined: /* Legacy building mode */ + case RouteRenderMode.Prerender: + case RouteRenderMode.AppShell: + serializableRouteTreeNodeForPrerender.push(metadata); - if (routesWarnings?.length) { - warnings.push(...routesWarnings); + break; + case RouteRenderMode.Server: + if (outputMode === OutputMode.Static) { + errors.push( + `Route '${metadata.route}' is configured with server render mode, but the build 'outputMode' is set to 'static'.`, + ); + } + break; + } } - if (allRoutes.size < 1 || errors.length > 0) { + if (!serializableRouteTreeNodeForPrerender.length || errors.length > 0) { return { errors, warnings, output: {}, serializableRouteTreeNode, - prerenderedRoutes: allRoutes, }; } @@ -137,7 +154,7 @@ export async function prerenderPages( const { errors: renderingErrors, output } = await renderPages( baseHref, sourcemap, - allRoutes, + serializableRouteTreeNodeForPrerender, maxThreads, workspaceRoot, outputFilesForWorker, @@ -152,25 +169,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 +200,7 @@ async function renderPages( const renderWorker = new WorkerPool({ filename: require.resolve('./render-worker'), - maxThreads: Math.min(allRoutes.size, maxThreads), + maxThreads: Math.min(serializableRouteTreeNode.length, maxThreads), workerData: { workspaceRoot, outputFiles: outputFilesForWorker, @@ -201,22 +211,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'); + + if (typeof redirectTo === 'string') { + output[outPath] = { content: generateRedirectStaticPage(redirectTo), appShellRoute: false }; - const render: Promise = renderWorker.run({ url: route }); + 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 }; } }) @@ -246,33 +265,31 @@ 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)); + outputMode: OutputMode | undefined, +): Promise<{ serializedRouteTree: SerializableRouteTreeNode; errors: string[] }> { + const { routesFile, discoverRoutes } = prerenderOptions ?? {}; + const routes: WritableSerializableRouteTreeNode = []; + + 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 = [ @@ -296,47 +313,22 @@ async function getAllRoutes( execArgv: workerExecArgv, }); - 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({ + outputMode, + }); - 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 { @@ -346,3 +338,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..ab1994ae3bfd 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,33 +6,27 @@ * 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 { OutputMode } from '../../builders/application/schema'; import { patchFetchToLoadInMemoryAssets } from './fetch-patch'; import { loadEsmModuleFromMemory } from './load-esm-from-memory'; +import { RoutersExtractorWorkerResult } from './models'; -export interface RoutesExtractorWorkerData extends ESMInMemoryFileLoaderWorkerData { - assetFiles: Record; -} - -export type SerializableRouteTreeNode = ReturnType< - Awaited>['routeTree']['toObject'] ->; - -export interface RoutersExtractorWorkerResult { - serializedRouteTree: SerializableRouteTreeNode; - errors: string[]; +export interface ExtractRoutesOptions { + outputMode?: OutputMode; } /** Renders an application based on a provided options. */ -async function extractRoutes(): Promise { +async function extractRoutes({ + outputMode, +}: ExtractRoutesOptions): Promise { const { ɵextractRoutesAndCreateRouteTree: extractRoutesAndCreateRouteTree } = await loadEsmModuleFromMemory('./main.server.mjs'); const { routeTree, errors } = await extractRoutesAndCreateRouteTree( new URL('http://local-angular-prerender/'), - /** manifest */ undefined, - /** invokeGetPrerenderParams */ true, + undefined /** manifest */, + true /** invokeGetPrerenderParams */, + outputMode === OutputMode.Server /** includePrerenderFallbackRoutes */, ); return { 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..d01b51bc4f60 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/html;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/packages/angular/ssr/test/app_spec.ts b/packages/angular/ssr/test/app_spec.ts index 7e2df85fffa7..58873aed8690 100644 --- a/packages/angular/ssr/test/app_spec.ts +++ b/packages/angular/ssr/test/app_spec.ts @@ -114,7 +114,7 @@ describe('AngularServerApp', () => { expect(Object.fromEntries(headers)).toEqual({ 'cache-control': 'no-cache', 'x-some-header': 'value', - 'content-type': 'text/plain;charset=UTF-8', + 'content-type': 'text/html;charset=UTF-8', }); }); @@ -122,7 +122,7 @@ describe('AngularServerApp', () => { const response = await app.render(new Request('http://localhost/home')); const headers = response?.headers.entries() ?? []; expect(Object.fromEntries(headers)).toEqual({ - 'content-type': 'text/plain;charset=UTF-8', // default header + 'content-type': 'text/html;charset=UTF-8', // default header }); }); 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..9f5d0de3259f 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: { + '/': {}, + '/lazy-one': {}, + '/lazy-one/lazy-one-child': {}, + '/lazy-two': {}, + '/two': {}, + '/two/two-child-one': {}, + '/two/two-child-two': {}, + }, }); } } 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..16504591fc14 --- /dev/null +++ b/tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-static.ts @@ -0,0 +1,103 @@ +import { join } from 'node:path'; +import { expectFileToMatch, replaceInFile, writeFile } from '../../utils/fs'; +import { noSilentNg, silentNg } from '../../utils/process'; +import { setupProjectWithSSRAppEngine } from './setup'; +import { existsSync } from 'node:fs'; +import { expectToFail } from '../../utils/utils'; +import assert from 'node:assert'; + +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.Server, + }, + ]; + `, + ); + + // Generate components for the above routes + const componentNames: string[] = ['home', 'ssg', 'ssg-with-params']; + + for (const componentName of componentNames) { + await silentNg('generate', 'component', componentName); + } + + // Should error as above we set `RenderMode.Server` + const { message: errorMessage } = await expectToFail(() => + noSilentNg('build', '--output-mode=static'), + ); + assert.match( + errorMessage, + new RegExp( + `Route '/' is configured with server render mode, but the build 'outputMode' is set to 'static'.`, + ), + ); + + // Fix the error + await replaceInFile('src/app/app.routes.server.ts', 'RenderMode.Server', 'RenderMode.Prerender'); + 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..1dfc3d6a8222 --- /dev/null +++ b/tests/legacy-cli/e2e/tests/server-rendering/setup.ts @@ -0,0 +1,127 @@ +import { getGlobalVariable } from '../../utils/env'; +import { writeFile } from '../../utils/fs'; +import { findFreePort } from '../../utils/network'; +import { installWorkspacePackages, uninstallPackage } 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 uninstallPackage('@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; + }); +}