diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts index b5ca83a76405..3da1b3721f11 100644 --- a/packages/angular/build/src/builders/application/execute-build.ts +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -247,12 +247,13 @@ export async function executeBuild( // Perform i18n translation inlining if enabled if (i18nOptions.shouldInline) { - const result = await inlineI18n(options, executionResult, initialFiles); + const result = await inlineI18n(metafile, options, executionResult, initialFiles); executionResult.addErrors(result.errors); executionResult.addWarnings(result.warnings); executionResult.addPrerenderedRoutes(result.prerenderedRoutes); } else { const result = await executePostBundleSteps( + metafile, options, executionResult.outputFiles, executionResult.assetFiles, 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 2f4f73c69b08..ebe0cb78da93 100644 --- a/packages/angular/build/src/builders/application/execute-post-bundle.ts +++ b/packages/angular/build/src/builders/application/execute-post-bundle.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +import type { Metafile } from 'esbuild'; import assert from 'node:assert'; import { BuildOutputFile, @@ -34,6 +35,7 @@ import { OutputMode } from './schema'; /** * Run additional builds steps including SSG, AppShell, Index HTML file and Service worker generation. + * @param metafile An esbuild metafile object. * @param options The normalized application builder options used to create the build. * @param outputFiles The output files of an executed build. * @param assetFiles The assets of an executed build. @@ -42,6 +44,7 @@ import { OutputMode } from './schema'; */ // eslint-disable-next-line max-lines-per-function export async function executePostBundleSteps( + metafile: Metafile, options: NormalizedApplicationBuildOptions, outputFiles: BuildOutputFile[], assetFiles: BuildOutputAsset[], @@ -70,6 +73,7 @@ export async function executePostBundleSteps( serverEntryPoint, prerenderOptions, appShellOptions, + publicPath, workspaceRoot, partialSSRBuild, } = options; @@ -107,6 +111,7 @@ export async function executePostBundleSteps( } // Create server manifest + const initialFilesPaths = new Set(initialFiles.keys()); if (serverEntryPoint) { const { manifestContent, serverAssetsChunks } = generateAngularServerAppManifest( additionalHtmlOutputFiles, @@ -114,6 +119,9 @@ export async function executePostBundleSteps( optimizationOptions.styles.inlineCritical ?? false, undefined, locale, + initialFilesPaths, + metafile, + publicPath, ); additionalOutputFiles.push( @@ -194,6 +202,9 @@ export async function executePostBundleSteps( optimizationOptions.styles.inlineCritical ?? false, serializableRouteTreeNodeForManifest, locale, + initialFilesPaths, + metafile, + publicPath, ); for (const chunk of serverAssetsChunks) { diff --git a/packages/angular/build/src/builders/application/i18n.ts b/packages/angular/build/src/builders/application/i18n.ts index cfb044f0e34f..2b4a83f89c21 100644 --- a/packages/angular/build/src/builders/application/i18n.ts +++ b/packages/angular/build/src/builders/application/i18n.ts @@ -7,6 +7,7 @@ */ import { BuilderContext } from '@angular-devkit/architect'; +import type { Metafile } from 'esbuild'; import { join } from 'node:path'; import { BuildOutputFileType, InitialFileRecord } from '../../tools/esbuild/bundler-context'; import { @@ -23,11 +24,13 @@ import { NormalizedApplicationBuildOptions, getLocaleBaseHref } from './options' /** * Inlines all active locales as specified by the application build options into all * application JavaScript files created during the build. + * @param metafile An esbuild metafile object. * @param options The normalized application builder options used to create the build. * @param executionResult The result of an executed build. * @param initialFiles A map containing initial file information for the executed build. */ export async function inlineI18n( + metafile: Metafile, options: NormalizedApplicationBuildOptions, executionResult: ExecutionResult, initialFiles: Map, @@ -80,6 +83,7 @@ export async function inlineI18n( additionalOutputFiles, prerenderedRoutes: generatedRoutes, } = await executePostBundleSteps( + metafile, { ...options, baseHref, diff --git a/packages/angular/build/src/utils/server-rendering/manifest.ts b/packages/angular/build/src/utils/server-rendering/manifest.ts index eb13be07e5d1..ee72a7d86f76 100644 --- a/packages/angular/build/src/utils/server-rendering/manifest.ts +++ b/packages/angular/build/src/utils/server-rendering/manifest.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +import type { Metafile } from 'esbuild'; import { extname } from 'node:path'; import { NormalizedApplicationBuildOptions, @@ -13,6 +14,7 @@ import { } from '../../builders/application/options'; import { type BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-context'; import { createOutputFile } from '../../tools/esbuild/utils'; +import { shouldOptimizeChunks } from '../environment-options'; export const SERVER_APP_MANIFEST_FILENAME = 'angular-app-manifest.mjs'; export const SERVER_APP_ENGINE_MANIFEST_FILENAME = 'angular-app-engine-manifest.mjs'; @@ -103,6 +105,9 @@ export default { * 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. + * @param initialFiles - A list of initial files that preload tags have already been added for. + * @param metafile - An esbuild metafile object. + * @param publicPath - The configured public path. * * @returns An object containing: * - `manifestContent`: A string of the SSR manifest content. @@ -114,6 +119,9 @@ export function generateAngularServerAppManifest( inlineCriticalCss: boolean, routes: readonly unknown[] | undefined, locale: string | undefined, + initialFiles: Set, + metafile: Metafile, + publicPath: string | undefined, ): { manifestContent: string; serverAssetsChunks: BuildOutputFile[]; @@ -138,15 +146,89 @@ export function generateAngularServerAppManifest( } } + // When routes have been extracted, mappings are no longer needed, as preloads will be included in the metadata. + // When shouldOptimizeChunks is enabled the metadata is no longer correct and thus we cannot generate the mappings. + const serverToBrowserMapppings = + routes?.length || shouldOptimizeChunks + ? undefined + : generateLazyLoadedFilesMappings(metafile, initialFiles, publicPath); + const manifestContent = ` export default { bootstrap: () => import('./main.server.mjs').then(m => m.default), inlineCriticalCss: ${inlineCriticalCss}, + locale: ${JSON.stringify(locale, undefined, 2)}, + serverToBrowserMapppings: ${JSON.stringify(serverToBrowserMapppings, undefined, 2)}, routes: ${JSON.stringify(routes, undefined, 2)}, assets: new Map([\n${serverAssetsContent.join(', \n')}\n]), - locale: ${locale !== undefined ? `'${locale}'` : undefined}, }; `; return { manifestContent, serverAssetsChunks }; } + +/** + * Generates a mapping of lazy-loaded files from a given metafile. + * + * This function processes the outputs of a metafile to create a mapping + * between MJS files (server bundles) and their corresponding JS files + * that should be lazily loaded. It filters out files that do not have + * an entry point, do not export any modules, or are not of the + * appropriate file extensions (.js or .mjs). + * + * @param metafile - An object containing metadata about the output files, + * including entry points, exports, and imports. + * @param initialFiles - A set of initial file names that are considered + * already loaded and should be excluded from the mapping. + * @param publicPath - The configured public path. + * + * @returns A record where the keys are MJS file names (server bundles) and + * the values are arrays of corresponding JS file names (browser bundles). + */ +function generateLazyLoadedFilesMappings( + metafile: Metafile, + initialFiles: Set, + publicPath = '', +): Record { + const entryPointToBundles = new Map< + string, + { js: string[] | undefined; mjs: string | undefined } + >(); + + for (const [fileName, { entryPoint, exports, imports }] of Object.entries(metafile.outputs)) { + const extension = extname(fileName); + + // Skip files that don't have an entryPoint, no exports, or are not .js or .mjs + if (!entryPoint || exports?.length < 1 || (extension !== '.js' && extension !== '.mjs')) { + continue; + } + + const data = entryPointToBundles.get(entryPoint) ?? { js: undefined, mjs: undefined }; + if (extension === '.js') { + const importedPaths: string[] = [`${publicPath}${fileName}`]; + for (const { kind, external, path } of imports) { + if (external || kind !== 'import-statement' || initialFiles.has(path)) { + continue; + } + + importedPaths.push(`${publicPath}${path}`); + } + + data.js = importedPaths; + } else { + data.mjs = fileName; + } + + entryPointToBundles.set(entryPoint, data); + } + + const bundlesReverseLookup: Record = {}; + // Populate resultedLookup with mjs as key and js as value + for (const { js, mjs } of entryPointToBundles.values()) { + if (mjs && js?.length) { + bundlesReverseLookup[mjs] = js; + } + } + + return bundlesReverseLookup; +} diff --git a/packages/angular/ssr/src/app.ts b/packages/angular/ssr/src/app.ts index e49a0b0e6c0e..06cf06895c42 100644 --- a/packages/angular/ssr/src/app.ts +++ b/packages/angular/ssr/src/app.ts @@ -251,7 +251,7 @@ export class AngularServerApp { return Response.redirect(new URL(redirectTo, new URL(request.url)), (status as any) ?? 302); } - const { renderMode, headers } = matchedRoute; + const { renderMode, headers, preload } = matchedRoute; if (!this.allowStaticRouteRender && renderMode === RenderMode.Prerender) { return null; } @@ -285,7 +285,12 @@ export class AngularServerApp { ); } else if (renderMode === RenderMode.Client) { // Serve the client-side rendered version if the route is configured for CSR. - return new Response(await this.assets.getServerAsset('index.csr.html').text(), responseInit); + const html = appendPreloadHintsToHtml( + await this.assets.getServerAsset('index.csr.html').text(), + preload, + ); + + return new Response(html, responseInit); } const { @@ -352,6 +357,8 @@ export class AngularServerApp { } } + html = appendPreloadHintsToHtml(html, preload); + return new Response(html, responseInit); } @@ -413,3 +420,34 @@ export function destroyAngularServerApp(): void { angularServerApp = undefined; } + +/** + * Appends module preload hints to an HTML string for specified JavaScript resources. + * + * This function enhances the HTML by injecting `` elements + * for each provided resource, allowing browsers to preload the specified JavaScript + * modules for better performance. + * + * @param html - The original HTML string to which preload hints will be added. + * @param preload - An array of URLs representing the JavaScript resources to preload. + * If `undefined` or empty, the original HTML string is returned unchanged. + * @returns The modified HTML string with the preload hints injected before the closing `` tag. + * If `` is not found, the links are not added. + */ +function appendPreloadHintsToHtml(html: string, preload: readonly string[] | undefined): string { + if (!preload?.length) { + return html; + } + + const bodyCloseIdx = html.lastIndexOf(''); + if (bodyCloseIdx === -1) { + return html; + } + + return [ + html.slice(0, bodyCloseIdx), + '\n', + ...preload.map((val) => `\n`), + html.slice(bodyCloseIdx), + ].join(''); +} diff --git a/packages/angular/ssr/src/manifest.ts b/packages/angular/ssr/src/manifest.ts index 0331ac10b0bd..93883c45fe2f 100644 --- a/packages/angular/ssr/src/manifest.ts +++ b/packages/angular/ssr/src/manifest.ts @@ -104,6 +104,17 @@ export interface AngularAppManifest { * the application, aiding with localization and rendering content specific to the locale. */ readonly locale?: string; + + /** + * Maps server bundle filenames to the related JavaScript browser bundles for preloading. + * + * This mapping ensures that when a server bundle is loaded, the corresponding browser bundles + * are preloaded to improve performance and reduce latency. + * + * - **Key**: The filename of the server bundle, typically in the `.mjs` format. + * - **Value**: An array of JavaScript browser bundle filenames to be preloaded. + */ + readonly serverToBrowserMapppings?: Record; } /** diff --git a/packages/angular/ssr/src/routes/ng-routes.ts b/packages/angular/ssr/src/routes/ng-routes.ts index 9e48f49b2b11..2f6f81a51be0 100644 --- a/packages/angular/ssr/src/routes/ng-routes.ts +++ b/packages/angular/ssr/src/routes/ng-routes.ts @@ -31,6 +31,18 @@ import { } from './route-config'; import { RouteTree, RouteTreeNodeMetadata } from './route-tree'; +/** + * The maximum number of module preload link elements that should be added for + * initial scripts. + */ +const MODULE_PRELOAD_MAX = 10; + +/** + * Regular expression used to match dynamic import statements in the form of: + * `import('...')` or `__vite_ssr_dynamic_import__('...')`. + */ +const IMPORT_REGEXP = /[import|__vite_ssr_dynamic_import__]\(['"]\.?\/(.+\.mjs)['"]\)/; + /** * Regular expression to match segments preceded by a colon in a string. */ @@ -110,6 +122,8 @@ async function* traverseRoutesConfig(options: { serverConfigRouteTree: RouteTree | undefined; invokeGetPrerenderParams: boolean; includePrerenderFallbackRoutes: boolean; + serverToBrowserMapppings: Readonly> | undefined; + parentPreloads?: readonly string[]; }): AsyncIterableIterator { const { routes, @@ -117,13 +131,15 @@ async function* traverseRoutesConfig(options: { parentInjector, parentRoute, serverConfigRouteTree, + serverToBrowserMapppings, + parentPreloads, invokeGetPrerenderParams, includePrerenderFallbackRoutes, } = options; for (const route of routes) { try { - const { path = '', redirectTo, loadChildren, children } = route; + const { path = '', redirectTo, loadChildren, loadComponent, children } = route; const currentRoutePath = joinUrlParts(parentRoute, path); // Get route metadata from the server config route tree, if available @@ -146,11 +162,14 @@ async function* traverseRoutesConfig(options: { const metadata: ServerConfigRouteTreeNodeMetadata = { renderMode: RenderMode.Prerender, ...matchedMetaData, + preload: parentPreloads, route: currentRoutePath, }; delete metadata.presentInClientRouter; + appendPreloadToMetadata(loadComponent, serverToBrowserMapppings, metadata); + // Handle redirects if (typeof redirectTo === 'string') { const redirectToResolved = resolveRedirectTo(currentRoutePath, redirectTo); @@ -181,11 +200,14 @@ async function* traverseRoutesConfig(options: { ...options, routes: children, parentRoute: currentRoutePath, + parentPreloads: metadata.preload, }); } // Load and process lazy-loaded child routes if (loadChildren) { + appendPreloadToMetadata(loadChildren, serverToBrowserMapppings, metadata); + const loadedChildRoutes = await loadChildrenHelper( route, compiler, @@ -199,6 +221,7 @@ async function* traverseRoutesConfig(options: { routes: childRoutes, parentInjector: injector, parentRoute: currentRoutePath, + parentPreloads: metadata.preload, }); } } @@ -210,6 +233,42 @@ async function* traverseRoutesConfig(options: { } } +/** + * Appends preload information to the metadata based on the provided load statement and chunk mappings. + * + * This function checks if a valid load statement and chunk mappings are provided. If so, it extracts + * the server chunk name from the load statement and retrieves corresponding preload entries from + * the chunk mappings. The retrieved entries are then added to the metadata's preload list, ensuring + * that there are no duplicate entries. + * + * @param loadStatement - The load statement from which to extract the server chunk name. + * @param serverToBrowserMapppings - Maps server bundle filenames to the associated JavaScript browser bundles. + * @param metadata - The metadata object that will be modified to include the new preload entries. + * It must conform to the ServerConfigRouteTreeNodeMetadata type. + */ +function appendPreloadToMetadata( + loadStatement: unknown, + serverToBrowserMapppings: Readonly> | undefined, + metadata: ServerConfigRouteTreeNodeMetadata, +): void { + if (!loadStatement || !serverToBrowserMapppings) { + return; + } + + const serverChunkName = IMPORT_REGEXP.exec(loadStatement.toString())?.[1]; + if (!serverChunkName) { + return; + } + + const preload = serverToBrowserMapppings[serverChunkName]; + if (preload?.length) { + // Merge existing preloads with new ones, ensuring uniqueness and limiting the + // total number to the maximum allowed. + const combinedPreloads = [...(metadata.preload ?? []), ...preload]; + metadata.preload = Array.from(new Set(combinedPreloads)).slice(0, MODULE_PRELOAD_MAX); + } +} + /** * Handles SSG (Static Site Generation) routes by invoking `getPrerenderParams` and yielding * all parameterized paths, returning any errors encountered. @@ -379,6 +438,7 @@ function buildServerConfigRouteTree({ routes, appShellRoute }: ServerRoutesConfi * @param invokeGetPrerenderParams - A boolean flag indicating whether to invoke `getPrerenderParams` for parameterized SSG routes * to handle prerendering paths. Defaults to `false`. * @param includePrerenderFallbackRoutes - A flag indicating whether to include fallback routes in the result. Defaults to `true`. + * @param serverToBrowserMapppings - Maps server bundle filenames to the associated JavaScript browser bundles. * * @returns A promise that resolves to an object of type `AngularRouterConfigResult` or errors. */ @@ -388,6 +448,7 @@ export async function getRoutesFromAngularRouterConfig( url: URL, invokeGetPrerenderParams = false, includePrerenderFallbackRoutes = true, + serverToBrowserMapppings: Readonly> | undefined = undefined, ): Promise { const { protocol, host } = url; @@ -457,6 +518,7 @@ export async function getRoutesFromAngularRouterConfig( serverConfigRouteTree, invokeGetPrerenderParams, includePrerenderFallbackRoutes, + serverToBrowserMapppings, }); let seenAppShellRoute: string | undefined; @@ -542,6 +604,7 @@ export async function extractRoutesAndCreateRouteTree( url, invokeGetPrerenderParams, includePrerenderFallbackRoutes, + manifest.serverToBrowserMapppings, ); for (const { route, ...metadata } of routes) { diff --git a/packages/angular/ssr/src/routes/route-tree.ts b/packages/angular/ssr/src/routes/route-tree.ts index 36bdabb027df..0fc4dc30353e 100644 --- a/packages/angular/ssr/src/routes/route-tree.ts +++ b/packages/angular/ssr/src/routes/route-tree.ts @@ -65,6 +65,11 @@ export interface RouteTreeNodeMetadata { * Specifies the rendering mode used for this route. */ renderMode: RenderMode; + + /** + * A list of resource that should be preloaded by the browser. + */ + preload?: readonly string[]; } /** diff --git a/packages/angular/ssr/test/testing-utils.ts b/packages/angular/ssr/test/testing-utils.ts index 99971ab894ef..a0523e11738f 100644 --- a/packages/angular/ssr/test/testing-utils.ts +++ b/packages/angular/ssr/test/testing-utils.ts @@ -21,7 +21,8 @@ import { ServerRoute, provideServerRoutesConfig } from '../src/routes/route-conf * * @param routes - An array of route definitions to be used by the Angular Router. * @param serverRoutes - An array of ServerRoute definitions to be used for server-side rendering. - * @param [baseHref='/'] - An optional base href to be used in the HTML template. + * @param baseHref - An optional base href to be used in the HTML template. + * @param additionalServerAssets - An optional dictionary of additional server assets. */ export function setAngularAppTestingManifest( routes: Routes, diff --git a/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-preload-links.ts b/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-preload-links.ts new file mode 100644 index 000000000000..1314f836c1f0 --- /dev/null +++ b/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-preload-links.ts @@ -0,0 +1,118 @@ +import assert from 'node:assert'; +import { writeFile } from '../../../utils/fs'; +import { execAndWaitForOutputToMatch, ng, noSilentNg, silentNg } from '../../../utils/process'; +import { installWorkspacePackages, uninstallPackage } from '../../../utils/packages'; +import { useSha } from '../../../utils/project'; +import { getGlobalVariable } from '../../../utils/env'; +import { findFreePort } from '../../../utils/network'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--server-routing', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + // Add routes + await writeFile( + 'src/app/app.routes.ts', + ` + import { Routes } from '@angular/router'; + + export const routes: Routes = [ + { + path: '', + loadComponent: () => import('./home/home.component').then(c => c.HomeComponent), + }, + { + path: 'ssg', + loadComponent: () => import('./ssg/ssg.component').then(c => c.SsgComponent), + }, + { + path: 'ssr', + loadComponent: () => import('./ssr/ssr.component').then(c => c.SsrComponent), + }, + { + path: 'csr', + loadComponent: () => import('./csr/csr.component').then(c => c.CsrComponent), + }, + ]; + `, + ); + + // Add server routing + await writeFile( + 'src/app/app.routes.server.ts', + ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const serverRoutes: ServerRoute[] = [ + { + path: 'ssr', + renderMode: RenderMode.Server, + }, + { + path: 'csr', + renderMode: RenderMode.Client, + }, + { + path: '**', + renderMode: RenderMode.Prerender, + }, + ]; + `, + ); + + // 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', '--named-chunks'); + + // Tests responses + const responseExpects: Record = { + '/': //, + '/ssg': //, + '/ssr': //, + '/csr': //, + }; + + const port = await spawnServer(); + for (const [pathname, contentRegeExp] of Object.entries(responseExpects)) { + const res = await fetch(`http://localhost:${port}${pathname}`); + const text = await res.text(); + + assert.match( + text, + contentRegeExp, + `Response for '${pathname}': ${contentRegeExp} was not matched in content.`, + ); + + // Ensure that the url is correct and not a 404. + const link = text.match(contentRegeExp)?.[1]; + const preloadRes = await fetch(`http://localhost:${port}/${link}`); + assert.equal(preloadRes.status, 200); + } +} + +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; +}