diff --git a/goldens/circular-deps/packages.json b/goldens/circular-deps/packages.json index ba9e8df1ecc4..cca29e550ffe 100644 --- a/goldens/circular-deps/packages.json +++ b/goldens/circular-deps/packages.json @@ -12,6 +12,11 @@ "packages/angular/build/src/tools/esbuild/utils.ts", "packages/angular/build/src/tools/esbuild/bundler-execution-result.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/tools/esbuild/bundler-execution-result.ts", "packages/angular/build/src/tools/esbuild/utils.ts" 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 898756678459..d8325c7e27ff 100644 --- a/packages/angular/build/src/builders/application/execute-post-bundle.ts +++ b/packages/angular/build/src/builders/application/execute-post-bundle.ts @@ -16,9 +16,13 @@ import { BuildOutputAsset } 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 { + SERVER_APP_MANIFEST_FILENAME, + generateAngularServerAppManifest, +} from '../../utils/server-rendering/manifest'; import { prerenderPages } from '../../utils/server-rendering/prerender'; import { augmentAppWithServiceWorkerEsbuild } from '../../utils/service-worker'; -import { NormalizedApplicationBuildOptions } from './options'; +import { INDEX_HTML_SERVER, NormalizedApplicationBuildOptions } from './options'; /** * Run additional builds steps including SSG, AppShell, Index HTML file and Service worker generation. @@ -48,25 +52,22 @@ export async function executePostBundleSteps( const prerenderedRoutes: string[] = []; const { + baseHref = '/', serviceWorker, indexHtmlOptions, optimizationOptions, sourcemapOptions, + ssrOptions, prerenderOptions, appShellOptions, workspaceRoot, verbose, } = options; - /** - * Index HTML content without CSS inlining to be used for server rendering (AppShell, SSG and SSR). - * - * NOTE: we don't perform critical CSS inlining as this will be done during server rendering. - */ - let ssrIndexContent: string | undefined; - - // When using prerender/app-shell the index HTML file can be regenerated. - // Thus, we use a Map so that we do not generate 2 files with the same filename. + // Index HTML content without CSS inlining to be used for server rendering (AppShell, SSG and SSR). + // NOTE: Critical CSS inlining is deliberately omitted here, as it will be handled during server rendering. + // Additionally, when using prerendering or AppShell, the index HTML file may be regenerated. + // To prevent generating duplicate files with the same filename, a `Map` is used to store and manage the files. const additionalHtmlOutputFiles = new Map(); // Generate index HTML file @@ -88,21 +89,34 @@ export async function executePostBundleSteps( ); if (ssrContent) { - const serverIndexHtmlFilename = 'index.server.html'; additionalHtmlOutputFiles.set( - serverIndexHtmlFilename, - createOutputFile(serverIndexHtmlFilename, ssrContent, BuildOutputFileType.Server), + INDEX_HTML_SERVER, + createOutputFile(INDEX_HTML_SERVER, ssrContent, BuildOutputFileType.Server), ); - - ssrIndexContent = ssrContent; } } + // Create server manifest + if (prerenderOptions || appShellOptions || ssrOptions) { + additionalOutputFiles.push( + createOutputFile( + SERVER_APP_MANIFEST_FILENAME, + generateAngularServerAppManifest( + additionalHtmlOutputFiles, + outputFiles, + optimizationOptions.styles.inlineCritical ?? false, + undefined, + ), + BuildOutputFileType.Server, + ), + ); + } + // Pre-render (SSG) and App-shell // If localization is enabled, prerendering is handled in the inlining process. - if (prerenderOptions || appShellOptions) { + if ((prerenderOptions || appShellOptions) && !allErrors.length) { assert( - ssrIndexContent, + indexHtmlOptions, 'The "index" option is required when using the "ssg" or "appShell" options.', ); @@ -111,15 +125,15 @@ export async function executePostBundleSteps( warnings, errors, prerenderedRoutes: generatedRoutes, + serializableRouteTreeNode, } = await prerenderPages( workspaceRoot, + baseHref, appShellOptions, prerenderOptions, - outputFiles, + [...outputFiles, ...additionalOutputFiles], assetFiles, - ssrIndexContent, sourcemapOptions.scripts, - optimizationOptions.styles.inlineCritical, maxWorkers, verbose, ); @@ -128,10 +142,31 @@ export async function executePostBundleSteps( allWarnings.push(...warnings); prerenderedRoutes.push(...Array.from(generatedRoutes)); - for (const [path, content] of Object.entries(output)) { + const indexHasBeenPrerendered = generatedRoutes.has(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. + // - Always replace 'index.csr.html' with the app shell. + const filePath = appShellRoute && !indexHasBeenPrerendered ? indexHtmlOptions.output : path; additionalHtmlOutputFiles.set( - path, - createOutputFile(path, content, BuildOutputFileType.Browser), + filePath, + createOutputFile(filePath, content, BuildOutputFileType.Browser), + ); + } + + if (ssrOptions) { + // 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.`); + + manifest.contents = new TextEncoder().encode( + generateAngularServerAppManifest( + additionalHtmlOutputFiles, + outputFiles, + optimizationOptions.styles.inlineCritical ?? false, + serializableRouteTreeNode, + ), ); } } @@ -145,7 +180,7 @@ export async function executePostBundleSteps( const serviceWorkerResult = await augmentAppWithServiceWorkerEsbuild( workspaceRoot, serviceWorker, - options.baseHref || '/', + baseHref, options.indexHtmlOptions?.output, // Ensure additional files recently added are used [...outputFiles, ...additionalOutputFiles], diff --git a/packages/angular/build/src/builders/application/i18n.ts b/packages/angular/build/src/builders/application/i18n.ts index e3b203dbc1dc..ce5e0c7f27fc 100644 --- a/packages/angular/build/src/builders/application/i18n.ts +++ b/packages/angular/build/src/builders/application/i18n.ts @@ -14,9 +14,8 @@ import { I18nInliner } from '../../tools/esbuild/i18n-inliner'; import { maxWorkers } from '../../utils/environment-options'; import { loadTranslations } from '../../utils/i18n-options'; import { createTranslationLoader } from '../../utils/load-translations'; -import { urlJoin } from '../../utils/url'; import { executePostBundleSteps } from './execute-post-bundle'; -import { NormalizedApplicationBuildOptions } from './options'; +import { NormalizedApplicationBuildOptions, getLocaleBaseHref } from './options'; /** * Inlines all active locales as specified by the application build options into all @@ -127,22 +126,6 @@ export async function inlineI18n( return inlineResult; } -function getLocaleBaseHref( - baseHref: string | undefined, - i18n: NormalizedApplicationBuildOptions['i18nOptions'], - locale: string, -): string | undefined { - if (i18n.flatOutput) { - return undefined; - } - - if (i18n.locales[locale] && i18n.locales[locale].baseHref !== '') { - return urlJoin(baseHref || '', i18n.locales[locale].baseHref ?? `/${locale}/`); - } - - return undefined; -} - /** * Loads all active translations using the translation loaders from the `@angular/localize` package. * @param context The architect builder context for the current build. diff --git a/packages/angular/build/src/builders/application/options.ts b/packages/angular/build/src/builders/application/options.ts index 6ddc46097142..40d566bedb0d 100644 --- a/packages/angular/build/src/builders/application/options.ts +++ b/packages/angular/build/src/builders/application/options.ts @@ -24,6 +24,7 @@ import { generateSearchDirectories, loadPostcssConfiguration, } from '../../utils/postcss-configuration'; +import { urlJoin } from '../../utils/url'; import { Schema as ApplicationBuilderOptions, I18NTranslation, @@ -31,6 +32,18 @@ import { OutputPathClass, } from './schema'; +/** + * The filename for the client-side rendered HTML template. + * This template is used for client-side rendering (CSR) in a web application. + */ +export const INDEX_HTML_CSR = 'index.csr.html'; + +/** + * The filename for the server-side rendered HTML template. + * This template is used for server-side rendering (SSR) in a web application. + */ +export const INDEX_HTML_SERVER = 'index.server.html'; + export type NormalizedOutputOptions = Required & { clean: boolean; ignoreServer: boolean; @@ -252,7 +265,7 @@ export async function normalizeOptions( * For instance, accessing `foo.com/` would lead to `foo.com/index.html` being served instead of hitting the server. */ const indexBaseName = path.basename(options.index); - indexOutput = ssrOptions && indexBaseName === 'index.html' ? 'index.csr.html' : indexBaseName; + indexOutput = ssrOptions && indexBaseName === 'index.html' ? INDEX_HTML_CSR : indexBaseName; } else { indexOutput = options.index.output || 'index.html'; } @@ -532,3 +545,19 @@ function normalizeGlobalEntries( return [...bundles.values()]; } + +export function getLocaleBaseHref( + baseHref: string | undefined, + i18n: NormalizedApplicationBuildOptions['i18nOptions'], + locale: string, +): string | undefined { + if (i18n.flatOutput) { + return undefined; + } + + if (i18n.locales[locale] && i18n.locales[locale].baseHref !== '') { + return urlJoin(baseHref || '', i18n.locales[locale].baseHref ?? `/${locale}/`); + } + + return undefined; +} diff --git a/packages/angular/build/src/builders/application/setup-bundling.ts b/packages/angular/build/src/builders/application/setup-bundling.ts index 2b5489453b8b..814b68a1a1ab 100644 --- a/packages/angular/build/src/builders/application/setup-bundling.ts +++ b/packages/angular/build/src/builders/application/setup-bundling.ts @@ -10,7 +10,7 @@ import { SourceFileCache } from '../../tools/esbuild/angular/source-file-cache'; import { createBrowserCodeBundleOptions, createBrowserPolyfillBundleOptions, - createServerCodeBundleOptions, + createServerMainCodeBundleOptions, createServerPolyfillBundleOptions, } from '../../tools/esbuild/application-code-bundle'; import { BundlerContext } from '../../tools/esbuild/bundler-context'; @@ -35,8 +35,14 @@ export function setupBundlerContexts( browsers: string[], codeBundleCache: SourceFileCache, ): BundlerContext[] { - const { appShellOptions, prerenderOptions, serverEntryPoint, ssrOptions, workspaceRoot } = - options; + const { + appShellOptions, + prerenderOptions, + serverEntryPoint, + ssrOptions, + workspaceRoot, + watch = false, + } = options; const target = transformSupportedBrowsersToTargets(browsers); const bundlerContexts = []; @@ -44,7 +50,7 @@ export function setupBundlerContexts( bundlerContexts.push( new BundlerContext( workspaceRoot, - !!options.watch, + watch, createBrowserCodeBundleOptions(options, target, codeBundleCache), ), ); @@ -56,9 +62,7 @@ export function setupBundlerContexts( codeBundleCache, ); if (browserPolyfillBundleOptions) { - bundlerContexts.push( - new BundlerContext(workspaceRoot, !!options.watch, browserPolyfillBundleOptions), - ); + bundlerContexts.push(new BundlerContext(workspaceRoot, watch, browserPolyfillBundleOptions)); } // Global Stylesheets @@ -67,7 +71,7 @@ export function setupBundlerContexts( const bundleOptions = createGlobalStylesBundleOptions(options, target, initial); if (bundleOptions) { bundlerContexts.push( - new BundlerContext(workspaceRoot, !!options.watch, bundleOptions, () => initial), + new BundlerContext(workspaceRoot, watch, bundleOptions, () => initial), ); } } @@ -79,7 +83,7 @@ export function setupBundlerContexts( const bundleOptions = createGlobalScriptsBundleOptions(options, target, initial); if (bundleOptions) { bundlerContexts.push( - new BundlerContext(workspaceRoot, !!options.watch, bundleOptions, () => initial), + new BundlerContext(workspaceRoot, watch, bundleOptions, () => initial), ); } } @@ -92,8 +96,8 @@ export function setupBundlerContexts( bundlerContexts.push( new BundlerContext( workspaceRoot, - !!options.watch, - createServerCodeBundleOptions(options, nodeTargets, codeBundleCache), + watch, + createServerMainCodeBundleOptions(options, nodeTargets, codeBundleCache), ), ); @@ -105,9 +109,7 @@ export function setupBundlerContexts( ); if (serverPolyfillBundleOptions) { - bundlerContexts.push( - new BundlerContext(workspaceRoot, !!options.watch, serverPolyfillBundleOptions), - ); + bundlerContexts.push(new BundlerContext(workspaceRoot, watch, serverPolyfillBundleOptions)); } } diff --git a/packages/angular/build/src/builders/dev-server/internal.ts b/packages/angular/build/src/builders/dev-server/internal.ts index d3681f90cc00..4f869d1c845e 100644 --- a/packages/angular/build/src/builders/dev-server/internal.ts +++ b/packages/angular/build/src/builders/dev-server/internal.ts @@ -10,7 +10,6 @@ export { type BuildOutputFile, BuildOutputFileType } from '@angular/build'; export { createRxjsEsmResolutionPlugin } from '../../tools/esbuild/rxjs-esm-resolution-plugin'; export { JavaScriptTransformer } from '../../tools/esbuild/javascript-transformer'; export { getFeatureSupport, isZonelessApp } from '../../tools/esbuild/utils'; -export { renderPage } from '../../utils/server-rendering/render-page'; export { type IndexHtmlTransform } from '../../utils/index-file/index-html-generator'; export { purgeStaleBuildCache } from '../../utils/purge-cache'; export { getSupportedBrowsers } from '../../utils/supported-browsers'; 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 ec3ddbb84cd8..edf056e8e5e5 100644 --- a/packages/angular/build/src/builders/dev-server/vite-server.ts +++ b/packages/angular/build/src/builders/dev-server/vite-server.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +import type { ɵdestroyAngularServerApp as destroyAngularServerApp } from '@angular/ssr'; import type { BuilderContext } from '@angular-devkit/architect'; import type { Plugin } from 'esbuild'; import assert from 'node:assert'; @@ -38,6 +39,7 @@ interface OutputFileRecord { hash?: string; updated: boolean; servable: boolean; + type: BuildOutputFileType; } export type BuilderAction = ( @@ -255,12 +257,12 @@ export async function* serveWithVite( ...new Set([...server.config.server.fs.allow, ...assetFiles.values()]), ]; - handleUpdate(normalizePath, generatedFiles, server, serverOptions, context.logger); - if (requiresServerRestart) { // Restart the server to force SSR dep re-optimization when a dependency has been added. // This is a workaround for: https://github.com/vitejs/vite/issues/14896 await server.restart(); + } else { + await handleUpdate(normalizePath, generatedFiles, server, serverOptions, context.logger); } } else { const projectName = context.target?.project; @@ -351,19 +353,22 @@ export async function* serveWithVite( await new Promise((resolve) => (deferred = resolve)); } -function handleUpdate( +async function handleUpdate( normalizePath: (id: string) => string, generatedFiles: Map, server: ViteDevServer, serverOptions: NormalizedDevServerOptions, logger: BuilderContext['logger'], -): void { +): Promise { const updatedFiles: string[] = []; + let isServerFileUpdated = false; // Invalidate any updated files for (const [file, record] of generatedFiles) { if (record.updated) { updatedFiles.push(file); + isServerFileUpdated ||= record.type === BuildOutputFileType.Server; + const updatedModules = server.moduleGraph.getModulesByFile( normalizePath(join(server.config.root, file)), ); @@ -375,6 +380,15 @@ function handleUpdate( return; } + // clean server apps cache + if (isServerFileUpdated) { + const { ɵdestroyAngularServerApp } = (await server.ssrLoadModule('/main.server.mjs')) as { + ɵdestroyAngularServerApp: typeof destroyAngularServerApp; + }; + + ɵdestroyAngularServerApp(); + } + if (serverOptions.liveReload || serverOptions.hmr) { if (updatedFiles.every((f) => f.endsWith('.css'))) { const timestamp = Date.now(); @@ -438,6 +452,7 @@ function analyzeResultFiles( contents: file.contents, servable, size: file.contents.byteLength, + type: file.type, updated: false, }); @@ -461,6 +476,7 @@ function analyzeResultFiles( size: file.contents.byteLength, hash: file.hash, updated: true, + type: file.type, servable, }); } 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 af06c813e3dd..546f97f2c1fc 100644 --- a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts +++ b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts @@ -9,9 +9,10 @@ import type { BuildOptions, PartialMessage } from 'esbuild'; import assert from 'node:assert'; import { createHash } from 'node:crypto'; -import { extname } from 'node:path'; +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 { createCompilerPlugin } from './angular/compiler-plugin'; import { SourceFileCache } from './angular/source-file-cache'; import { BundlerOptionsFactory } from './bundler-context'; @@ -21,7 +22,7 @@ import { createAngularLocaleDataPlugin } from './i18n-locale-plugin'; import { createLoaderImportAttributePlugin } from './loader-import-attribute-plugin'; import { createRxjsEsmResolutionPlugin } from './rxjs-esm-resolution-plugin'; import { createSourcemapIgnorelistPlugin } from './sourcemap-ignorelist-plugin'; -import { getFeatureSupport, isZonelessApp } from './utils'; +import { SERVER_GENERATED_EXTERNALS, getFeatureSupport, isZonelessApp } from './utils'; import { createVirtualModulePlugin } from './virtual-module-plugin'; import { createWasmPlugin } from './wasm-plugin'; @@ -149,28 +150,83 @@ export function createBrowserPolyfillBundleOptions( return hasTypeScriptEntries ? buildOptions : () => buildOptions; } -/** - * Create an esbuild 'build' options object for the server bundle. - * @param options The builder's user-provider normalized options. - * @returns An esbuild BuildOptions object. - */ -export function createServerCodeBundleOptions( +export function createServerPolyfillBundleOptions( + options: NormalizedApplicationBuildOptions, + target: string[], + sourceFileCache?: SourceFileCache, +): BundlerOptionsFactory | undefined { + const serverPolyfills: string[] = []; + const polyfillsFromConfig = new Set(options.polyfills); + if (!isZonelessApp(options.polyfills)) { + serverPolyfills.push('zone.js/node'); + } + + if ( + polyfillsFromConfig.has('@angular/localize') || + polyfillsFromConfig.has('@angular/localize/init') + ) { + serverPolyfills.push('@angular/localize/init'); + } + + serverPolyfills.push('@angular/platform-server/init'); + + const namespace = 'angular:polyfills-server'; + const polyfillBundleOptions = getEsBuildCommonPolyfillsOptions( + { + ...options, + polyfills: serverPolyfills, + }, + namespace, + false, + sourceFileCache, + ); + + if (!polyfillBundleOptions) { + return; + } + + const buildOptions: BuildOptions = { + ...polyfillBundleOptions, + platform: 'node', + outExtension: { '.js': '.mjs' }, + // Note: `es2015` is needed for RxJS v6. If not specified, `module` would + // match and the ES5 distribution would be bundled and ends up breaking at + // runtime with the RxJS testing library. + // More details: https://github.com/angular/angular-cli/issues/25405. + mainFields: ['es2020', 'es2015', 'module', 'main'], + entryNames: '[name]', + banner: { + js: [ + // Note: Needed as esbuild does not provide require shims / proxy from ESModules. + // See: https://github.com/evanw/esbuild/issues/1921. + `import { createRequire } from 'node:module';`, + `globalThis['require'] ??= createRequire(import.meta.url);`, + ].join('\n'), + }, + target, + entryPoints: { + 'polyfills.server': namespace, + }, + }; + + return () => buildOptions; +} + +export function createServerMainCodeBundleOptions( options: NormalizedApplicationBuildOptions, target: string[], sourceFileCache: SourceFileCache, ): BuildOptions { const { - serverEntryPoint, + serverEntryPoint: mainServerEntryPoint, workspaceRoot, - ssrOptions, - watch, externalPackages, - prerenderOptions, + ssrOptions, polyfills, } = options; assert( - serverEntryPoint, + mainServerEntryPoint, 'createServerCodeBundleOptions should not be called without a defined serverEntryPoint.', ); @@ -180,38 +236,29 @@ export function createServerCodeBundleOptions( sourceFileCache, ); - const mainServerNamespace = 'angular:server-render-utils'; + const mainServerNamespace = 'angular:main-server'; + const mainServerInjectPolyfillsNamespace = 'angular:main-server-inject-polyfills'; + const mainServerInjectManifestNamespace = 'angular:main-server-inject-manifest'; + const zoneless = isZonelessApp(polyfills); const entryPoints: Record = { - 'render-utils.server': mainServerNamespace, - 'main.server': serverEntryPoint, + 'main.server': mainServerNamespace, }; const ssrEntryPoint = ssrOptions?.entry; + if (ssrEntryPoint) { + // 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; } - const zoneless = isZonelessApp(polyfills); - const buildOptions: BuildOptions = { - ...getEsBuildCommonOptions(options), - platform: 'node', - splitting: true, - outExtension: { '.js': '.mjs' }, - // Note: `es2015` is needed for RxJS v6. If not specified, `module` would - // match and the ES5 distribution would be bundled and ends up breaking at - // runtime with the RxJS testing library. - // More details: https://github.com/angular/angular-cli/issues/25405. - mainFields: ['es2020', 'es2015', 'module', 'main'], - entryNames: '[name]', + ...getEsBuildServerCommonOptions(options), target, - banner: { - js: `import './polyfills.server.mjs';`, - }, + inject: [mainServerInjectPolyfillsNamespace, mainServerInjectManifestNamespace], entryPoints, supported: getFeatureSupport(target, zoneless), plugins: [ - createLoaderImportAttributePlugin(), createWasmPlugin({ allowAsync: zoneless, cache: sourceFileCache?.loadResultCache }), createSourcemapIgnorelistPlugin(), createCompilerPlugin( @@ -224,29 +271,66 @@ export function createServerCodeBundleOptions( }; buildOptions.plugins ??= []; + if (externalPackages) { buildOptions.packages = 'external'; } else { buildOptions.plugins.push(createRxjsEsmResolutionPlugin()); } + // Mark manifest and polyfills file as external as these are generated by a different bundle step. + (buildOptions.external ??= []).push(...SERVER_GENERATED_EXTERNALS); + buildOptions.plugins.push( createVirtualModulePlugin({ - namespace: mainServerNamespace, + namespace: mainServerInjectPolyfillsNamespace, + cache: sourceFileCache?.loadResultCache, + loadContent: () => ({ + contents: `import './polyfills.server.mjs';`, + loader: 'js', + resolveDir: workspaceRoot, + }), + }), + createVirtualModulePlugin({ + namespace: mainServerInjectManifestNamespace, cache: sourceFileCache?.loadResultCache, loadContent: async () => { const contents: string[] = [ - `export { ɵConsole } from '@angular/core';`, - `export { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/platform-server';`, + // Configure `@angular/ssr` manifest. + `import manifest from './${SERVER_APP_MANIFEST_FILENAME}';`, + `import { ɵsetAngularAppManifest } from '@angular/ssr';`, + `ɵsetAngularAppManifest(manifest);`, ]; - if (watch) { - contents.push(`export { ɵresetCompiledComponents } from '@angular/core';`); - } + return { + contents: contents.join('\n'), + loader: 'js', + resolveDir: workspaceRoot, + }; + }, + }), + createVirtualModulePlugin({ + namespace: mainServerNamespace, + cache: sourceFileCache?.loadResultCache, + loadContent: async () => { + const mainServerEntryPointJsImport = entryFileToWorkspaceRelative( + workspaceRoot, + mainServerEntryPoint, + ); - if (prerenderOptions?.discoverRoutes) { - contents.push(`export { ɵgetRoutesFromAngularRouterConfig } from '@angular/ssr';`); - } + const contents: string[] = [ + // Re-export all symbols including default export from 'main.server.ts' + `export { default } from '${mainServerEntryPointJsImport}';`, + `export * from '${mainServerEntryPointJsImport}';`, + + // Add @angular/ssr exports + `export { + ɵServerRenderContext, + ɵdestroyAngularServerApp, + ɵextractRoutesAndCreateRouteTree, + ɵgetOrCreateAngularServerApp, + } from '@angular/ssr';`, + ]; return { contents: contents.join('\n'), @@ -264,43 +348,9 @@ export function createServerCodeBundleOptions( return buildOptions; } -export function createServerPolyfillBundleOptions( - options: NormalizedApplicationBuildOptions, - target: string[], - sourceFileCache?: SourceFileCache, -): BundlerOptionsFactory | undefined { - const serverPolyfills: string[] = []; - const polyfillsFromConfig = new Set(options.polyfills); - if (!isZonelessApp(options.polyfills)) { - serverPolyfills.push('zone.js/node'); - } - - if ( - polyfillsFromConfig.has('@angular/localize') || - polyfillsFromConfig.has('@angular/localize/init') - ) { - serverPolyfills.push('@angular/localize/init'); - } - - serverPolyfills.push('@angular/platform-server/init'); - - const namespace = 'angular:polyfills-server'; - const polyfillBundleOptions = getEsBuildCommonPolyfillsOptions( - { - ...options, - polyfills: serverPolyfills, - }, - namespace, - false, - sourceFileCache, - ); - - if (!polyfillBundleOptions) { - return; - } - - const buildOptions: BuildOptions = { - ...polyfillBundleOptions, +function getEsBuildServerCommonOptions(options: NormalizedApplicationBuildOptions): BuildOptions { + return { + ...getEsBuildCommonOptions(options), platform: 'node', outExtension: { '.js': '.mjs' }, // Note: `es2015` is needed for RxJS v6. If not specified, `module` would @@ -309,21 +359,7 @@ export function createServerPolyfillBundleOptions( // More details: https://github.com/angular/angular-cli/issues/25405. mainFields: ['es2020', 'es2015', 'module', 'main'], entryNames: '[name]', - banner: { - js: [ - // Note: Needed as esbuild does not provide require shims / proxy from ESModules. - // See: https://github.com/evanw/esbuild/issues/1921. - `import { createRequire } from 'node:module';`, - `globalThis['require'] ??= createRequire(import.meta.url);`, - ].join('\n'), - }, - target, - entryPoints: { - 'polyfills.server': namespace, - }, }; - - return () => buildOptions; } function getEsBuildCommonOptions(options: NormalizedApplicationBuildOptions): BuildOptions { @@ -339,6 +375,7 @@ function getEsBuildCommonOptions(options: NormalizedApplicationBuildOptions): Bu jit, loaderExtensions, jsonLogs, + i18nOptions, } = options; // Ensure unique hashes for i18n translation changes when using post-process inlining. @@ -346,9 +383,9 @@ function getEsBuildCommonOptions(options: NormalizedApplicationBuildOptions): Bu // change when translation files have changed. If this is not done the post processed files may have // different content but would retain identical production file names which would lead to browser caching problems. let footer; - if (options.i18nOptions.shouldInline) { + if (i18nOptions.shouldInline) { // Update file hashes to include translation file content - const i18nHash = Object.values(options.i18nOptions.locales).reduce( + const i18nHash = Object.values(i18nOptions.locales).reduce( (data, locale) => data + locale.files.map((file) => file.integrity || '').join('|'), '', ); @@ -377,7 +414,7 @@ function getEsBuildCommonOptions(options: NormalizedApplicationBuildOptions): Bu splitting: true, chunkNames: options.namedChunks ? '[name]-[hash]' : 'chunk-[hash]', tsconfig, - external: externalDependencies, + external: externalDependencies ? [...externalDependencies] : undefined, write: false, preserveSymlinks, define: { @@ -491,3 +528,12 @@ function getEsBuildCommonPolyfillsOptions( return buildOptions; } + +function entryFileToWorkspaceRelative(workspaceRoot: string, entryFile: string): string { + return ( + './' + + relative(workspaceRoot, entryFile) + .replace(/.[mc]?ts$/, '') + .replace(/\\/g, '/') + ); +} diff --git a/packages/angular/build/src/tools/esbuild/bundler-context.ts b/packages/angular/build/src/tools/esbuild/bundler-context.ts index 5bb00b904654..f65c5724e9ac 100644 --- a/packages/angular/build/src/tools/esbuild/bundler-context.ts +++ b/packages/angular/build/src/tools/esbuild/bundler-context.ts @@ -20,7 +20,7 @@ import { import assert from 'node:assert'; import { basename, extname, join, relative } from 'node:path'; import { LoadResultCache, MemoryLoadResultCache } from './load-result-cache'; -import { convertOutputFile } from './utils'; +import { SERVER_GENERATED_EXTERNALS, convertOutputFile } from './utils'; export type BundleContextResult = | { errors: Message[]; warnings: Message[] } @@ -200,6 +200,7 @@ export class BundlerContext { return result; } + // eslint-disable-next-line max-lines-per-function async #performBundle(): Promise { // Create esbuild options if not present if (this.#esbuildOptions === undefined) { @@ -227,13 +228,6 @@ export class BundlerContext { // For non-incremental builds, perform a single build result = await build(this.#esbuildOptions); } - - if (this.#platformIsServer) { - for (const entry of Object.values(result.metafile.outputs)) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (entry as any)['ng-platform-server'] = true; - } - } } catch (failure) { // Build failures will throw an exception which contains errors/warnings if (isEsBuildFailure(failure)) { @@ -357,6 +351,7 @@ export class BundlerContext { for (const importData of imports) { if ( !importData.external || + SERVER_GENERATED_EXTERNALS.has(importData.path) || (importData.kind !== 'import-statement' && importData.kind !== 'dynamic-import' && importData.kind !== 'require-call') @@ -374,15 +369,26 @@ export class BundlerContext { // All files that are not JS, CSS, WASM, or sourcemaps for them are considered media if (!/\.([cm]?js|css|wasm)(\.map)?$/i.test(file.path)) { fileType = BuildOutputFileType.Media; + } else if (this.#platformIsServer) { + fileType = BuildOutputFileType.Server; } else { - fileType = this.#platformIsServer - ? BuildOutputFileType.Server - : BuildOutputFileType.Browser; + fileType = BuildOutputFileType.Browser; } return convertOutputFile(file, fileType); }); + let externalConfiguration = this.#esbuildOptions.external; + if (this.#platformIsServer && externalConfiguration) { + externalConfiguration = externalConfiguration.filter( + (dep) => !SERVER_GENERATED_EXTERNALS.has(dep), + ); + + if (!externalConfiguration.length) { + externalConfiguration = undefined; + } + } + // Return the successful build results return { ...result, @@ -391,7 +397,7 @@ export class BundlerContext { externalImports: { [this.#platformIsServer ? 'server' : 'browser']: externalImports, }, - externalConfiguration: this.#esbuildOptions.external, + externalConfiguration, errors: undefined, }; } diff --git a/packages/angular/build/src/tools/esbuild/utils.ts b/packages/angular/build/src/tools/esbuild/utils.ts index d85c60062eef..8b8e744d5e5a 100644 --- a/packages/angular/build/src/tools/esbuild/utils.ts +++ b/packages/angular/build/src/tools/esbuild/utils.ts @@ -10,17 +10,13 @@ import { BuilderContext } from '@angular-devkit/architect'; import { BuildOptions, Metafile, OutputFile, formatMessages } from 'esbuild'; import { Listr } from 'listr2'; import { createHash } from 'node:crypto'; -import { constants as fsConstants } from 'node:fs'; -import fs from 'node:fs/promises'; -import { basename, dirname, join } from 'node:path'; +import { basename, join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { brotliCompress } from 'node:zlib'; import { coerce } from 'semver'; -import { - NormalizedApplicationBuildOptions, - NormalizedOutputOptions, -} from '../../builders/application/options'; +import { NormalizedApplicationBuildOptions } from '../../builders/application/options'; import { BudgetCalculatorResult } from '../../utils/bundle-calculator'; +import { 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'; @@ -487,3 +483,16 @@ export function getEntryPointName(entryPoint: string): string { .replace(/\.[cm]?[jt]s$/, '') .replace(/[\\/.]/g, '-'); } + +/** + * A set of server-generated dependencies that are treated as external. + * + * These dependencies are marked as external because they are produced by a + * separate bundling process and are not included in the primary bundle. This + * ensures that these generated files are resolved from an external source rather + * than being part of the main bundle. + */ +export const SERVER_GENERATED_EXTERNALS = new Set([ + './polyfills.server.mjs', + './' + SERVER_APP_MANIFEST_FILENAME, +]); diff --git a/packages/angular/build/src/tools/vite/angular-memory-plugin.ts b/packages/angular/build/src/tools/vite/angular-memory-plugin.ts index c1421b1f3c8f..d53410918b9c 100644 --- a/packages/angular/build/src/tools/vite/angular-memory-plugin.ts +++ b/packages/angular/build/src/tools/vite/angular-memory-plugin.ts @@ -122,14 +122,12 @@ export function createAngularMemoryPlugin(options: AngularMemoryPluginOptions): // Returning a function, installs middleware after the main transform middleware but // before the built-in HTML middleware return () => { - server.middlewares.use(angularHtmlFallbackMiddleware); - if (ssr) { - server.middlewares.use( - createAngularSSRMiddleware(server, outputFiles, indexHtmlTransformer), - ); + server.middlewares.use(createAngularSSRMiddleware(server, indexHtmlTransformer)); } + server.middlewares.use(angularHtmlFallbackMiddleware); + server.middlewares.use( createAngularIndexHtmlMiddleware(server, outputFiles, indexHtmlTransformer), ); diff --git a/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts index 87066001f163..496e021b5038 100644 --- a/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts +++ b/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts @@ -6,65 +6,57 @@ * found in the LICENSE file at https://angular.dev/license */ +import type { ɵgetOrCreateAngularServerApp as getOrCreateAngularServerApp } from '@angular/ssr'; import type { ServerResponse } from 'node:http'; import type { Connect, ViteDevServer } from 'vite'; -import { renderPage } from '../../../utils/server-rendering/render-page'; -import { appendServerConfiguredHeaders, lookupMimeTypeFromRequest } from '../utils'; +import { appendServerConfiguredHeaders } from '../utils'; export function createAngularSSRMiddleware( server: ViteDevServer, - outputFiles: Map, indexHtmlTransformer?: (content: string) => Promise, ): Connect.NextHandleFunction { - return function (req: Connect.IncomingMessage, res: ServerResponse, next: Connect.NextFunction) { - const url = req.originalUrl; - if ( - !req.url || - // Skip if path is not defined. - !url || - // Skip if path is like a file. - // NOTE: We use a mime type lookup to mitigate against matching requests like: /browse/pl.0ef59752c0cd457dbf1391f08cbd936f - lookupMimeTypeFromRequest(url) - ) { - next(); + let cachedAngularServerApp: ReturnType | undefined; - return; + return function (req: Connect.IncomingMessage, res: ServerResponse, next: Connect.NextFunction) { + if (req.url === undefined) { + return next(); } - const rawHtml = outputFiles.get('/index.server.html')?.contents; - if (!rawHtml) { - next(); + const resolvedUrls = server.resolvedUrls; + const baseUrl = resolvedUrls?.local[0] ?? resolvedUrls?.network[0]; + const url = new URL(req.url, baseUrl); - return; - } + (async () => { + const { ɵgetOrCreateAngularServerApp } = (await server.ssrLoadModule('/main.server.mjs')) as { + ɵgetOrCreateAngularServerApp: typeof getOrCreateAngularServerApp; + }; - server - .transformIndexHtml(req.url, Buffer.from(rawHtml).toString('utf-8')) - .then(async (processedHtml) => { - const resolvedUrls = server.resolvedUrls; - const baseUrl = resolvedUrls?.local[0] ?? resolvedUrls?.network[0]; - - if (indexHtmlTransformer) { - processedHtml = await indexHtmlTransformer(processedHtml); - } + const angularServerApp = ɵgetOrCreateAngularServerApp(); + // Only Add the transform hook only if it's a different instance. + if (cachedAngularServerApp !== angularServerApp) { + angularServerApp.hooks.on('html:transform:pre', async ({ html }) => { + const processedHtml = await server.transformIndexHtml(url.pathname, html); - const { content: ssrContent } = await renderPage({ - document: processedHtml, - route: new URL(req.originalUrl ?? '/', baseUrl).toString(), - serverContext: 'ssr', - loadBundle: (uri: string) => - // eslint-disable-next-line @typescript-eslint/no-explicit-any - server.ssrLoadModule(uri.slice(1)) as any, - // Files here are only needed for critical CSS inlining. - outputFiles: {}, - // TODO: add support for critical css inlining. - inlineCriticalCss: false, + return indexHtmlTransformer?.(processedHtml) ?? processedHtml; }); - res.setHeader('Content-Type', 'text/html'); - res.setHeader('Cache-Control', 'no-cache'); + cachedAngularServerApp = angularServerApp; + } + + const response = await angularServerApp.render( + new Request(url, { signal: AbortSignal.timeout(30_000) }), + undefined, + ); + + return response?.text(); + })() + .then((content) => { + if (typeof content !== 'string') { + return next(); + } + appendServerConfiguredHeaders(server, res); - res.end(ssrContent); + res.end(content); }) .catch((error) => next(error)); }; diff --git a/packages/angular/build/src/utils/server-rendering/fetch-patch.ts b/packages/angular/build/src/utils/server-rendering/fetch-patch.ts index 5ed2d88270c5..ec67c249ecb3 100644 --- a/packages/angular/build/src/utils/server-rendering/fetch-patch.ts +++ b/packages/angular/build/src/utils/server-rendering/fetch-patch.ts @@ -21,8 +21,6 @@ const { assetFiles } = workerData as { const assetsCache: Map; content: Buffer }> = new Map(); -const RESOLVE_PROTOCOL = 'resolve:'; - export function patchFetchToLoadInMemoryAssets(): void { const originalFetch = globalThis.fetch; const patchedFetch: typeof fetch = async (input, init) => { @@ -30,17 +28,17 @@ export function patchFetchToLoadInMemoryAssets(): void { if (input instanceof URL) { url = input; } else if (typeof input === 'string') { - url = new URL(input, RESOLVE_PROTOCOL + '//'); + url = new URL(input); } else if (typeof input === 'object' && 'url' in input) { - url = new URL(input.url, RESOLVE_PROTOCOL + '//'); + url = new URL(input.url); } else { return originalFetch(input, init); } - const { protocol } = url; + const { hostname } = url; const pathname = decodeURIComponent(url.pathname); - if (protocol !== RESOLVE_PROTOCOL || !assetFiles[pathname]) { + if (hostname !== 'local-angular-prerender' || !assetFiles[pathname]) { // Only handle relative requests or files that are in assets. return originalFetch(input, init); } diff --git a/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts b/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts index a3a3384545a4..bf4603109463 100644 --- a/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts +++ b/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts @@ -6,17 +6,28 @@ * found in the LICENSE file at https://angular.dev/license */ +import type { ApplicationRef, Type } from '@angular/core'; +import type { + ɵServerRenderContext, + ɵextractRoutesAndCreateRouteTree, + ɵgetOrCreateAngularServerApp, +} from '@angular/ssr'; import { assertIsError } from '../error'; import { loadEsmModule } from '../load-esm'; -import { MainServerBundleExports, RenderUtilsServerBundleExports } from './main-bundle-exports'; + +/** + * Represents the exports available from the main server bundle. + */ +interface MainServerBundleExports { + default: (() => Promise) | Type; + ɵServerRenderContext: typeof ɵServerRenderContext; + ɵextractRoutesAndCreateRouteTree: typeof ɵextractRoutesAndCreateRouteTree; + ɵgetOrCreateAngularServerApp: typeof ɵgetOrCreateAngularServerApp; +} export function loadEsmModuleFromMemory( path: './main.server.mjs', -): Promise; -export function loadEsmModuleFromMemory( - path: './render-utils.server.mjs', -): Promise; -export function loadEsmModuleFromMemory(path: string): Promise { +): Promise { return loadEsmModule(new URL(path, 'memory://')).catch((e) => { assertIsError(e); @@ -29,5 +40,5 @@ export function loadEsmModuleFromMemory(path: string): Promise { error.code = e.code; throw error; - }); + }) as Promise; } diff --git a/packages/angular/build/src/utils/server-rendering/main-bundle-exports.ts b/packages/angular/build/src/utils/server-rendering/main-bundle-exports.ts deleted file mode 100644 index 4983805c454b..000000000000 --- a/packages/angular/build/src/utils/server-rendering/main-bundle-exports.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * @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 { ApplicationRef, Type, ɵConsole } from '@angular/core'; -import type { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/platform-server'; -import type { ɵgetRoutesFromAngularRouterConfig } from '@angular/ssr'; - -export interface MainServerBundleExports { - /** Standalone application bootstrapping function. */ - default: (() => Promise) | Type; -} - -export interface RenderUtilsServerBundleExports { - /** An internal token that allows providing extra information about the server context. */ - ɵSERVER_CONTEXT: typeof ɵSERVER_CONTEXT; - - /** Render an NgModule application. */ - renderModule: typeof renderModule; - - /** Method to render a standalone application. */ - renderApplication: typeof renderApplication; - - /** Method to extract routes from the router config. */ - ɵgetRoutesFromAngularRouterConfig: typeof ɵgetRoutesFromAngularRouterConfig; - - ɵresetCompiledComponents?: () => void; - - /** Angular Console token/class. */ - ɵConsole: typeof ɵConsole; -} diff --git a/packages/angular/build/src/utils/server-rendering/manifest.ts b/packages/angular/build/src/utils/server-rendering/manifest.ts new file mode 100644 index 000000000000..d3a5519adf77 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/manifest.ts @@ -0,0 +1,110 @@ +/** + * @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 { + INDEX_HTML_CSR, + INDEX_HTML_SERVER, + NormalizedApplicationBuildOptions, + getLocaleBaseHref, +} from '../../builders/application/options'; +import type { BuildOutputFile } from '../../tools/esbuild/bundler-context'; + +export const SERVER_APP_MANIFEST_FILENAME = 'angular-app-manifest.mjs'; + +const MAIN_SERVER_OUTPUT_FILENAME = 'main.server.mjs'; + +/** + * Generates the server manifest for the App Engine environment. + * + * This manifest is used to configure the server-side rendering (SSR) setup for the + * Angular application when deployed to Google App Engine. It includes the entry points + * for different locales and the base HREF for the application. + * + * @param i18nOptions - The internationalization options for the application build. This + * 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. + * @returns A string representing the content of the SSR server manifest for App Engine. + */ +export function generateAngularServerAppEngineManifest( + i18nOptions: NormalizedApplicationBuildOptions['i18nOptions'], + baseHref: string | undefined, +): string { + const entryPointsContent: string[] = []; + + if (i18nOptions.shouldInline) { + for (const locale of i18nOptions.inlineLocales) { + const importPath = + './' + (i18nOptions.flatOutput ? '' : locale + '/') + MAIN_SERVER_OUTPUT_FILENAME; + + const localWithBaseHref = getLocaleBaseHref('', i18nOptions, locale) || '/'; + entryPointsContent.push(`['${localWithBaseHref}', () => import('${importPath}')]`); + } + } else { + entryPointsContent.push(`['/', () => import('./${MAIN_SERVER_OUTPUT_FILENAME}')]`); + } + + const manifestContent = ` + { + basePath: '${baseHref ?? '/'}', + entryPoints: new Map([${entryPointsContent.join(', \n')}]), + } +`; + + return manifestContent; +} + +/** + * Generates the server manifest for the standard Node.js environment. + * + * This manifest is used to configure the server-side rendering (SSR) setup for the + * Angular application when running in a standard Node.js environment. It includes + * information about the bootstrap module, whether to inline critical CSS, and any + * additional HTML and CSS output files. + * + * @param additionalHtmlOutputFiles - A map of additional HTML output files generated + * during the build process, keyed by their file paths. + * @param outputFiles - An array of all output files from the build process, including + * JavaScript and CSS files. + * @param inlineCriticalCss - A boolean indicating whether critical CSS should be inlined + * 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. + */ +export function generateAngularServerAppManifest( + additionalHtmlOutputFiles: Map, + outputFiles: BuildOutputFile[], + inlineCriticalCss: boolean, + routes: readonly unknown[] | undefined, +): string { + const serverAssetsContent: string[] = []; + for (const file of [...additionalHtmlOutputFiles.values(), ...outputFiles]) { + if ( + file.path === INDEX_HTML_SERVER || + file.path === INDEX_HTML_CSR || + file.path.endsWith('.css') + ) { + serverAssetsContent.push(`['${file.path}', async () => ${JSON.stringify(file.text)}]`); + } + } + + const manifestContent = ` +import bootstrap from './main.server.mjs'; + +export default { + bootstrap: () => bootstrap, + inlineCriticalCss: ${inlineCriticalCss}, + routes: ${JSON.stringify(routes, undefined, 2)}, + assets: new Map([${serverAssetsContent.join(', \n')}]), +}; +`; + + return manifestContent; +} diff --git a/packages/angular/build/src/utils/server-rendering/prerender.ts b/packages/angular/build/src/utils/server-rendering/prerender.ts index f8796a7ac861..19c6095f783a 100644 --- a/packages/angular/build/src/utils/server-rendering/prerender.ts +++ b/packages/angular/build/src/utils/server-rendering/prerender.ts @@ -12,11 +12,11 @@ import { pathToFileURL } from 'node:url'; import Piscina from 'piscina'; import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-context'; import { BuildOutputAsset } from '../../tools/esbuild/bundler-execution-result'; -import type { RenderResult, ServerContext } from './render-page'; +import { urlJoin } from '../url'; import type { RenderWorkerData } from './render-worker'; import type { - RoutersExtractorWorkerResult, RoutesExtractorWorkerData, + RoutersExtractorWorkerResult as SerializableRouteTreeNode, } from './routes-extractor-worker'; interface PrerenderOptions { @@ -28,22 +28,38 @@ interface AppShellOptions { route?: string; } +/** + * Represents the output of a prerendering process. + * + * The key is the file path, and the value is an object containing the following properties: + * + * - `content`: The HTML content or output generated for the corresponding file path. + * - `appShellRoute`: A boolean flag indicating whether the content is an app shell. + * + * @example + * { + * '/index.html': { content: '...', appShell: false }, + * '/shell/index.html': { content: '...', appShellRoute: true } + * } + */ +type PrerenderOutput = Record; + export async function prerenderPages( workspaceRoot: string, + baseHref: string, appShellOptions: AppShellOptions = {}, prerenderOptions: PrerenderOptions = {}, outputFiles: Readonly, assets: Readonly, - document: string, sourcemap = false, - inlineCriticalCss = false, maxThreads = 1, verbose = false, ): Promise<{ - output: Record; + output: PrerenderOutput; warnings: string[]; errors: string[]; prerenderedRoutes: Set; + serializableRouteTreeNode: SerializableRouteTreeNode; }> { const outputFilesForWorker: Record = {}; const serverBundlesSourceMaps = new Map(); @@ -51,13 +67,14 @@ export async function prerenderPages( const errors: string[] = []; for (const { text, path, type } of outputFiles) { - const fileExt = extname(path); - if (type === BuildOutputFileType.Server && fileExt === '.map') { + if (type !== BuildOutputFileType.Server) { + continue; + } + + // Contains the server runnable application code + if (extname(path) === '.map') { serverBundlesSourceMaps.set(path.slice(0, -4), text); - } else if ( - type === BuildOutputFileType.Server || // Contains the server runnable application code - (type === BuildOutputFileType.Browser && fileExt === '.css') // Global styles for critical CSS inlining. - ) { + } else { outputFilesForWorker[path] = text; } } @@ -85,11 +102,12 @@ export async function prerenderPages( routes: allRoutes, warnings: routesWarnings, errors: routesErrors, + serializableRouteTreeNode, } = await getAllRoutes( workspaceRoot, + baseHref, outputFilesForWorker, assetsReversed, - document, appShellOptions, prerenderOptions, sourcemap, @@ -109,34 +127,30 @@ export async function prerenderPages( errors, warnings, output: {}, + serializableRouteTreeNode, prerenderedRoutes: allRoutes, }; } // Render routes - const { - warnings: renderingWarnings, - errors: renderingErrors, - output, - } = await renderPages( + const { errors: renderingErrors, output } = await renderPages( + baseHref, sourcemap, allRoutes, maxThreads, workspaceRoot, outputFilesForWorker, assetsReversed, - inlineCriticalCss, - document, appShellOptions, ); errors.push(...renderingErrors); - warnings.push(...renderingWarnings); return { errors, warnings, output, + serializableRouteTreeNode, prerenderedRoutes: allRoutes, }; } @@ -148,22 +162,19 @@ class RoutesSet extends Set { } async function renderPages( + baseHref: string, sourcemap: boolean, allRoutes: Set, maxThreads: number, workspaceRoot: string, outputFilesForWorker: Record, assetFilesForWorker: Record, - inlineCriticalCss: boolean, - document: string, appShellOptions: AppShellOptions, ): Promise<{ - output: Record; - warnings: string[]; + output: PrerenderOutput; errors: string[]; }> { - const output: Record = {}; - const warnings: string[] = []; + const output: PrerenderOutput = {}; const errors: string[] = []; const workerExecArgv = [ @@ -183,8 +194,6 @@ async function renderPages( workspaceRoot, outputFiles: outputFilesForWorker, assetFiles: assetFilesForWorker, - inlineCriticalCss, - document, } as RenderWorkerData, execArgv: workerExecArgv, recordTiming: false, @@ -193,30 +202,27 @@ async function renderPages( try { const renderingPromises: Promise[] = []; const appShellRoute = appShellOptions.route && addLeadingSlash(appShellOptions.route); + const baseHrefWithLeadingSlash = addLeadingSlash(baseHref); for (const route of allRoutes) { - const isAppShellRoute = appShellRoute === route; - const serverContext: ServerContext = isAppShellRoute ? 'app-shell' : 'ssg'; - const render: Promise = renderWorker.run({ route, serverContext }); - const renderResult: Promise = render - .then(({ content, warnings, errors }) => { - if (content !== undefined) { - const outPath = isAppShellRoute - ? 'index.html' - : posix.join(removeLeadingSlash(route), 'index.html'); - output[outPath] = content; - } - - if (warnings) { - warnings.push(...warnings); - } + // Remove base href from file output path. + const routeWithoutBaseHref = addLeadingSlash( + route.slice(baseHrefWithLeadingSlash.length - 1), + ); - if (errors) { - errors.push(...errors); + const isAppShellRoute = 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'); + output[outPath] = { content, appShellRoute: isAppShellRoute }; } }) .catch((err) => { - errors.push(`An error occurred while prerendering route '${route}'.\n\n${err.stack}`); + errors.push( + `An error occurred while prerendering route '${route}'.\n\n${err.stack ?? err.message ?? err.code ?? err}`, + ); void renderWorker.destroy(); }); @@ -230,38 +236,42 @@ async function renderPages( return { errors, - warnings, output, }; } async function getAllRoutes( workspaceRoot: string, + baseHref: string, outputFilesForWorker: Record, assetFilesForWorker: Record, - document: string, appShellOptions: AppShellOptions, prerenderOptions: PrerenderOptions, sourcemap: boolean, verbose: boolean, -): Promise<{ routes: Set; warnings?: string[]; errors?: string[] }> { +): 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(appShellRoute); + routes.add(urlJoin(baseHref, appShellRoute)); } if (routesFile) { const routesFromFile = (await readFile(routesFile, 'utf8')).split(/\r?\n/); for (const route of routesFromFile) { - routes.add(route.trim()); + routes.add(urlJoin(baseHref, route.trim())); } } if (!discoverRoutes) { - return { routes }; + return { routes, serializableRouteTreeNode: [] }; } const workerExecArgv = [ @@ -281,15 +291,13 @@ async function getAllRoutes( workspaceRoot, outputFiles: outputFilesForWorker, assetFiles: assetFilesForWorker, - document, - verbose, } as RoutesExtractorWorkerData, execArgv: workerExecArgv, recordTiming: false, }); const errors: string[] = []; - const { routes: extractedRoutes, warnings }: RoutersExtractorWorkerResult = await renderWorker + const serializableRouteTreeNode: SerializableRouteTreeNode = await renderWorker .run({}) .catch((err) => { errors.push(`An error occurred while extracting routes.\n\n${err.stack}`); @@ -298,11 +306,36 @@ async function getAllRoutes( void renderWorker.destroy(); }); - for (const route of extractedRoutes) { - routes.add(route); + 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, warnings, errors }; + return { routes, serializableRouteTreeNode, warnings }; } function addLeadingSlash(value: string): string { diff --git a/packages/angular/build/src/utils/server-rendering/render-page.ts b/packages/angular/build/src/utils/server-rendering/render-page.ts deleted file mode 100644 index aaf4509c35a2..000000000000 --- a/packages/angular/build/src/utils/server-rendering/render-page.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * @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 { ApplicationRef, StaticProvider } from '@angular/core'; -import assert from 'node:assert'; -import { basename } from 'node:path'; -import { loadEsmModuleFromMemory } from './load-esm-from-memory'; -import { MainServerBundleExports, RenderUtilsServerBundleExports } from './main-bundle-exports'; - -export interface RenderOptions { - route: string; - serverContext: ServerContext; - outputFiles: Record; - document: string; - inlineCriticalCss?: boolean; - loadBundle?: ((path: './main.server.mjs') => Promise) & - ((path: './render-utils.server.mjs') => Promise); -} - -export interface RenderResult { - errors?: string[]; - warnings?: string[]; - content?: string; -} - -export type ServerContext = 'app-shell' | 'ssg' | 'ssr'; - -/** - * Renders each route in routes and writes them to //index.html. - */ -export async function renderPage({ - route, - serverContext, - document, - inlineCriticalCss, - outputFiles, - loadBundle = loadEsmModuleFromMemory, -}: RenderOptions): Promise { - const { default: bootstrapAppFnOrModule } = await loadBundle('./main.server.mjs'); - const { ɵSERVER_CONTEXT, renderModule, renderApplication, ɵresetCompiledComponents, ɵConsole } = - await loadBundle('./render-utils.server.mjs'); - - // Need to clean up GENERATED_COMP_IDS map in `@angular/core`. - // Otherwise an incorrect component ID generation collision detected warning will be displayed in development. - // See: https://github.com/angular/angular-cli/issues/25924 - ɵresetCompiledComponents?.(); - - const platformProviders: StaticProvider[] = [ - { - provide: ɵSERVER_CONTEXT, - useValue: serverContext, - }, - { - provide: ɵConsole, - /** An Angular Console Provider that does not print a set of predefined logs. */ - useFactory: () => { - class Console extends ɵConsole { - private readonly ignoredLogs = new Set(['Angular is running in development mode.']); - override log(message: string): void { - if (!this.ignoredLogs.has(message)) { - super.log(message); - } - } - } - - return new Console(); - }, - }, - ]; - - assert( - bootstrapAppFnOrModule, - 'The file "./main.server.mjs" does not have a default export for an AppServerModule or a bootstrapping function.', - ); - - let renderAppPromise: Promise; - if (isBootstrapFn(bootstrapAppFnOrModule)) { - renderAppPromise = renderApplication(bootstrapAppFnOrModule, { - document, - url: route, - platformProviders, - }); - } else { - renderAppPromise = renderModule(bootstrapAppFnOrModule, { - document, - url: route, - extraProviders: platformProviders, - }); - } - - // The below should really handled by the framework!!!. - // See: https://github.com/angular/angular/issues/51549 - let timer: NodeJS.Timeout; - const renderingTimeout = new Promise( - (_, reject) => - (timer = setTimeout( - () => - reject( - new Error( - `Page ${new URL(route, 'resolve://').pathname} did not render in 30 seconds.`, - ), - ), - 30_000, - )), - ); - - const html = await Promise.race([renderAppPromise, renderingTimeout]).finally(() => - clearTimeout(timer), - ); - - if (inlineCriticalCss) { - const { InlineCriticalCssProcessor } = await import( - '../../utils/index-file/inline-critical-css' - ); - - const inlineCriticalCssProcessor = new InlineCriticalCssProcessor({ - minify: false, // CSS has already been minified during the build. - readAsset: async (filePath) => { - filePath = basename(filePath); - const content = outputFiles[filePath]; - if (content === undefined) { - throw new Error(`Output file does not exist: ${filePath}`); - } - - return content; - }, - }); - - return inlineCriticalCssProcessor.process(html, { outputPath: '' }); - } - - return { - content: html, - }; -} - -function isBootstrapFn(value: unknown): value is () => Promise { - // We can differentiate between a module and a bootstrap function by reading compiler-generated `ɵmod` static property: - return typeof value === 'function' && !('ɵmod' in value); -} 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 e7e439838a21..c419b65de195 100644 --- a/packages/angular/build/src/utils/server-rendering/render-worker.ts +++ b/packages/angular/build/src/utils/server-rendering/render-worker.ts @@ -6,41 +6,43 @@ * found in the LICENSE file at https://angular.dev/license */ -import { workerData } from 'node:worker_threads'; import type { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks'; import { patchFetchToLoadInMemoryAssets } from './fetch-patch'; -import { RenderResult, ServerContext, renderPage } from './render-page'; +import { loadEsmModuleFromMemory } from './load-esm-from-memory'; export interface RenderWorkerData extends ESMInMemoryFileLoaderWorkerData { - document: string; - inlineCriticalCss?: boolean; assetFiles: Record; } export interface RenderOptions { - route: string; - serverContext: ServerContext; + url: string; + isAppShellRoute: boolean; } /** - * This is passed as workerData when setting up the worker via the `piscina` package. + * Renders each route in routes and writes them to //index.html. */ -const { outputFiles, document, inlineCriticalCss } = workerData as RenderWorkerData; - -/** Renders an application based on a provided options. */ -function render(options: RenderOptions): Promise { - return renderPage({ - ...options, - outputFiles, - document, - inlineCriticalCss, - }); +async function renderPage({ url, isAppShellRoute }: RenderOptions): Promise { + const { + ɵgetOrCreateAngularServerApp: getOrCreateAngularServerApp, + ɵServerRenderContext: ServerRenderContext, + } = await loadEsmModuleFromMemory('./main.server.mjs'); + const angularServerApp = getOrCreateAngularServerApp(); + const response = await angularServerApp.render( + new Request(new URL(url, 'http://local-angular-prerender'), { + signal: AbortSignal.timeout(30_000), + }), + undefined, + isAppShellRoute ? ServerRenderContext.AppShell : ServerRenderContext.SSG, + ); + + return response ? response.text() : null; } function initialize() { patchFetchToLoadInMemoryAssets(); - return render; + return renderPage; } export default initialize(); 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 d438ef1139e7..52d2e78d7671 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,73 +6,29 @@ * found in the LICENSE file at https://angular.dev/license */ -import { workerData } from 'node:worker_threads'; +import type { ɵextractRoutesAndCreateRouteTree } from '@angular/ssr'; import type { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks'; import { patchFetchToLoadInMemoryAssets } from './fetch-patch'; import { loadEsmModuleFromMemory } from './load-esm-from-memory'; export interface RoutesExtractorWorkerData extends ESMInMemoryFileLoaderWorkerData { - document: string; - verbose: boolean; assetFiles: Record; } -export interface RoutersExtractorWorkerResult { - routes: string[]; - warnings?: string[]; -} - -/** - * This is passed as workerData when setting up the worker via the `piscina` package. - */ -const { document, verbose } = workerData as RoutesExtractorWorkerData; +export type RoutersExtractorWorkerResult = ReturnType< + Awaited>['toObject'] +>; /** Renders an application based on a provided options. */ async function extractRoutes(): Promise { - const { ɵgetRoutesFromAngularRouterConfig: getRoutesFromAngularRouterConfig } = - await loadEsmModuleFromMemory('./render-utils.server.mjs'); - const { default: bootstrapAppFnOrModule } = await loadEsmModuleFromMemory('./main.server.mjs'); - - const skippedRedirects: string[] = []; - const skippedOthers: string[] = []; - const routes: string[] = []; + const { ɵextractRoutesAndCreateRouteTree: extractRoutesAndCreateRouteTree } = + await loadEsmModuleFromMemory('./main.server.mjs'); - const { routes: extractRoutes } = await getRoutesFromAngularRouterConfig( - bootstrapAppFnOrModule, - document, - new URL('http://localhost'), + const routeTree = await extractRoutesAndCreateRouteTree( + new URL('http://local-angular-prerender/'), ); - for (const { route, redirectTo } of extractRoutes) { - if (redirectTo !== undefined) { - skippedRedirects.push(route); - } else if (/[:*]/.test(route)) { - skippedOthers.push(route); - } else { - routes.push(route); - } - } - - if (!verbose) { - return { routes }; - } - - let warnings: string[] | undefined; - 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, warnings }; + return routeTree.toObject(); } function initialize() { diff --git a/packages/angular/ssr/src/app.ts b/packages/angular/ssr/src/app.ts index 06925a239a64..ec64dcdbbf75 100644 --- a/packages/angular/ssr/src/app.ts +++ b/packages/angular/ssr/src/app.ts @@ -168,13 +168,6 @@ export class AngularServerApp { ); } - if (typeof ngDevMode === 'undefined' || ngDevMode) { - // Need to clean up GENERATED_COMP_IDS map in `@angular/core`. - // Otherwise an incorrect component ID generation collision detected warning will be displayed in development. - // See: https://github.com/angular/angular-cli/issues/25924 - ɵresetCompiledComponents(); - } - const { manifest, hooks, assets } = this; let html = await assets.getIndexServerHtml(); @@ -220,5 +213,12 @@ export function getOrCreateAngularServerApp(): AngularServerApp { * typically when server configuration or application state needs to be refreshed. */ export function destroyAngularServerApp(): void { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + // Need to clean up GENERATED_COMP_IDS map in `@angular/core`. + // Otherwise an incorrect component ID generation collision detected warning will be displayed in development. + // See: https://github.com/angular/angular-cli/issues/25924 + ɵresetCompiledComponents(); + } + angularServerApp = undefined; } diff --git a/packages/angular/ssr/src/routes/ng-routes.ts b/packages/angular/ssr/src/routes/ng-routes.ts index 3ead85923c82..d2ef31608752 100644 --- a/packages/angular/ssr/src/routes/ng-routes.ts +++ b/packages/angular/ssr/src/routes/ng-routes.ts @@ -15,7 +15,6 @@ import { platformCore, ɵwhenStable as whenStable, ɵConsole, - ɵresetCompiledComponents, } from '@angular/core'; import { INITIAL_CONFIG, @@ -191,13 +190,6 @@ export async function getRoutesFromAngularRouterConfig( document: string, url: URL, ): Promise { - if (typeof ngDevMode === 'undefined' || ngDevMode) { - // Need to clean up GENERATED_COMP_IDS map in `@angular/core`. - // Otherwise an incorrect component ID generation collision detected warning will be displayed in development. - // See: https://github.com/angular/angular-cli/issues/25924 - ɵresetCompiledComponents(); - } - const { protocol, host } = url; // Create and initialize the Angular platform for server-side rendering. diff --git a/tests/legacy-cli/e2e/tests/build/prerender/discover-routes-ngmodule.ts b/tests/legacy-cli/e2e/tests/build/prerender/discover-routes-ngmodule.ts index f19589ac1ef4..6da698b2b403 100644 --- a/tests/legacy-cli/e2e/tests/build/prerender/discover-routes-ngmodule.ts +++ b/tests/legacy-cli/e2e/tests/build/prerender/discover-routes-ngmodule.ts @@ -11,9 +11,6 @@ export default async function () { const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; if (useWebpackBuilder) { - // Forcibly remove in case another test doesn't clean itself up. - await rimraf('node_modules/@angular/ssr'); - // Setup webpack builder if esbuild is not requested on the commandline await updateJsonFile('angular.json', (json) => { const build = json['projects'][projectName]['architect']['build']; @@ -32,6 +29,7 @@ export default async function () { }; }); } + // Forcibly remove in case another test doesn't clean itself up. await rimraf('node_modules/@angular/ssr'); await ng(