UNPKG

@angular/ssr

Version:

Angular server side rendering utilities

1,214 lines (1,204 loc) 110 kB
import { ɵConsole as _Console, ApplicationRef, InjectionToken, provideEnvironmentInitializer, inject, makeEnvironmentProviders, ɵENABLE_ROOT_COMPONENT_BOOTSTRAP as _ENABLE_ROOT_COMPONENT_BOOTSTRAP, Compiler, createEnvironmentInjector, EnvironmentInjector, runInInjectionContext, ɵresetCompiledComponents as _resetCompiledComponents, REQUEST, REQUEST_CONTEXT, RESPONSE_INIT, LOCALE_ID } from '@angular/core'; import { platformServer, INITIAL_CONFIG, ɵSERVER_CONTEXT as _SERVER_CONTEXT, ɵrenderInternal as _renderInternal, provideServerRendering as provideServerRendering$1 } from '@angular/platform-server'; import { ActivatedRoute, Router, ROUTES, ɵloadChildren as _loadChildren } from '@angular/router'; import { APP_BASE_HREF, PlatformLocation } from '@angular/common'; import Beasties from '../third_party/beasties/index.js'; /** * Manages server-side assets. */ class ServerAssets { manifest; /** * Creates an instance of ServerAsset. * * @param manifest - The manifest containing the server assets. */ constructor(manifest) { this.manifest = manifest; } /** * Retrieves the content of a server-side asset using its path. * * @param path - The path to the server asset within the manifest. * @returns The server asset associated with the provided path, as a `ServerAsset` object. * @throws Error - Throws an error if the asset does not exist. */ getServerAsset(path) { const asset = this.manifest.assets[path]; if (!asset) { throw new Error(`Server asset '${path}' does not exist.`); } return asset; } /** * Checks if a specific server-side asset exists. * * @param path - The path to the server asset. * @returns A boolean indicating whether the asset exists. */ hasServerAsset(path) { return !!this.manifest.assets[path]; } /** * Retrieves the asset for 'index.server.html'. * * @returns The `ServerAsset` object for 'index.server.html'. * @throws Error - Throws an error if 'index.server.html' does not exist. */ getIndexServerHtml() { return this.getServerAsset('index.server.html'); } } /** * A set of log messages that should be ignored and not printed to the console. */ const IGNORED_LOGS = new Set(['Angular is running in development mode.']); /** * Custom implementation of the Angular Console service that filters out specific log messages. * * This class extends the internal Angular `ɵConsole` class to provide customized logging behavior. * It overrides the `log` method to suppress logs that match certain predefined messages. */ class Console extends _Console { /** * Logs a message to the console if it is not in the set of ignored messages. * * @param message - The message to log to the console. * * This method overrides the `log` method of the `ɵConsole` class. It checks if the * message is in the `IGNORED_LOGS` set. If it is not, it delegates the logging to * the parent class's `log` method. Otherwise, the message is suppressed. */ log(message) { if (!IGNORED_LOGS.has(message)) { super.log(message); } } } /** * The Angular app manifest object. * This is used internally to store the current Angular app manifest. */ let angularAppManifest; /** * Sets the Angular app manifest. * * @param manifest - The manifest object to set for the Angular application. */ function setAngularAppManifest(manifest) { angularAppManifest = manifest; } /** * Gets the Angular app manifest. * * @returns The Angular app manifest. * @throws Will throw an error if the Angular app manifest is not set. */ function getAngularAppManifest() { if (!angularAppManifest) { throw new Error('Angular app manifest is not set. ' + `Please ensure you are using the '@angular/build:application' builder to build your server application.`); } return angularAppManifest; } /** * The Angular app engine manifest object. * This is used internally to store the current Angular app engine manifest. */ let angularAppEngineManifest; /** * Sets the Angular app engine manifest. * * @param manifest - The engine manifest object to set. */ function setAngularAppEngineManifest(manifest) { angularAppEngineManifest = manifest; } /** * Gets the Angular app engine manifest. * * @returns The Angular app engine manifest. * @throws Will throw an error if the Angular app engine manifest is not set. */ function getAngularAppEngineManifest() { if (!angularAppEngineManifest) { throw new Error('Angular app engine manifest is not set. ' + `Please ensure you are using the '@angular/build:application' builder to build your server application.`); } return angularAppEngineManifest; } /** * Removes the trailing slash from a URL if it exists. * * @param url - The URL string from which to remove the trailing slash. * @returns The URL string without a trailing slash. * * @example * ```js * stripTrailingSlash('path/'); // 'path' * stripTrailingSlash('/path'); // '/path' * stripTrailingSlash('/'); // '/' * stripTrailingSlash(''); // '' * ``` */ /** * Removes the leading slash from a URL if it exists. * * @param url - The URL string from which to remove the leading slash. * @returns The URL string without a leading slash. * * @example * ```js * stripLeadingSlash('/path'); // 'path' * stripLeadingSlash('/path/'); // 'path/' * stripLeadingSlash('/'); // '/' * stripLeadingSlash(''); // '' * ``` */ function stripLeadingSlash(url) { // Check if the first character of the URL is a slash return url.length > 1 && url[0] === '/' ? url.slice(1) : url; } /** * Adds a leading slash to a URL if it does not already have one. * * @param url - The URL string to which the leading slash will be added. * @returns The URL string with a leading slash. * * @example * ```js * addLeadingSlash('path'); // '/path' * addLeadingSlash('/path'); // '/path' * ``` */ function addLeadingSlash(url) { // Check if the URL already starts with a slash return url[0] === '/' ? url : `/${url}`; } /** * Adds a trailing slash to a URL if it does not already have one. * * @param url - The URL string to which the trailing slash will be added. * @returns The URL string with a trailing slash. * * @example * ```js * addTrailingSlash('path'); // 'path/' * addTrailingSlash('path/'); // 'path/' * ``` */ function addTrailingSlash(url) { // Check if the URL already end with a slash return url[url.length - 1] === '/' ? url : `${url}/`; } /** * Joins URL parts into a single URL string. * * This function takes multiple URL segments, normalizes them by removing leading * and trailing slashes where appropriate, and then joins them into a single URL. * * @param parts - The parts of the URL to join. Each part can be a string with or without slashes. * @returns The joined URL string, with normalized slashes. * * @example * ```js * joinUrlParts('path/', '/to/resource'); // '/path/to/resource' * joinUrlParts('/path/', 'to/resource'); // '/path/to/resource' * joinUrlParts('', ''); // '/' * ``` */ function joinUrlParts(...parts) { const normalizeParts = []; for (const part of parts) { if (part === '') { // Skip any empty parts continue; } let normalizedPart = part; if (part[0] === '/') { normalizedPart = normalizedPart.slice(1); } if (part[part.length - 1] === '/') { normalizedPart = normalizedPart.slice(0, -1); } if (normalizedPart !== '') { normalizeParts.push(normalizedPart); } } return addLeadingSlash(normalizeParts.join('/')); } /** * Strips `/index.html` from the end of a URL's path, if present. * * This function is used to convert URLs pointing to an `index.html` file into their directory * equivalents. For example, it transforms a URL like `http://www.example.com/page/index.html` * into `http://www.example.com/page`. * * @param url - The URL object to process. * @returns A new URL object with `/index.html` removed from the path, if it was present. * * @example * ```typescript * const originalUrl = new URL('http://www.example.com/page/index.html'); * const cleanedUrl = stripIndexHtmlFromURL(originalUrl); * console.log(cleanedUrl.href); // Output: 'http://www.example.com/page' * ``` */ function stripIndexHtmlFromURL(url) { if (url.pathname.endsWith('/index.html')) { const modifiedURL = new URL(url); // Remove '/index.html' from the pathname modifiedURL.pathname = modifiedURL.pathname.slice(0, /** '/index.html'.length */ -11); return modifiedURL; } return url; } /** * Resolves `*` placeholders in a path template by mapping them to corresponding segments * from a base path. This is useful for constructing paths dynamically based on a given base path. * * The function processes the `toPath` string, replacing each `*` placeholder with * the corresponding segment from the `fromPath`. If the `toPath` contains no placeholders, * it is returned as-is. Invalid `toPath` formats (not starting with `/`) will throw an error. * * @param toPath - A path template string that may contain `*` placeholders. Each `*` is replaced * by the corresponding segment from the `fromPath`. Static paths (e.g., `/static/path`) are returned * directly without placeholder replacement. * @param fromPath - A base path string, split into segments, that provides values for * replacing `*` placeholders in the `toPath`. * @returns A resolved path string with `*` placeholders replaced by segments from the `fromPath`, * or the `toPath` returned unchanged if it contains no placeholders. * * @throws If the `toPath` does not start with a `/`, indicating an invalid path format. * * @example * ```typescript * // Example with placeholders resolved * const resolvedPath = buildPathWithParams('/*\/details', '/123/abc'); * console.log(resolvedPath); // Outputs: '/123/details' * * // Example with a static path * const staticPath = buildPathWithParams('/static/path', '/base/unused'); * console.log(staticPath); // Outputs: '/static/path' * ``` */ function buildPathWithParams(toPath, fromPath) { if (toPath[0] !== '/') { throw new Error(`Invalid toPath: The string must start with a '/'. Received: '${toPath}'`); } if (fromPath[0] !== '/') { throw new Error(`Invalid fromPath: The string must start with a '/'. Received: '${fromPath}'`); } if (!toPath.includes('/*')) { return toPath; } const fromPathParts = fromPath.split('/'); const toPathParts = toPath.split('/'); const resolvedParts = toPathParts.map((part, index) => toPathParts[index] === '*' ? fromPathParts[index] : part); return joinUrlParts(...resolvedParts); } /** * Renders an Angular application or module to an HTML string. * * This function determines whether the provided `bootstrap` value is an Angular module * or a bootstrap function and invokes the appropriate rendering method (`renderModule` or `renderApplication`). * * @param html - The initial HTML document content. * @param bootstrap - An Angular module type or a function returning a promise that resolves to an `ApplicationRef`. * @param url - The application URL, used for route-based rendering in SSR. * @param platformProviders - An array of platform providers for the rendering process. * @param serverContext - A string representing the server context, providing additional metadata for SSR. * @returns A promise resolving to an object containing: * - `hasNavigationError`: Indicates if a navigation error occurred. * - `redirectTo`: (Optional) The redirect URL if a navigation redirect occurred. * - `content`: A function returning a promise that resolves to the rendered HTML string. */ async function renderAngular(html, bootstrap, url, platformProviders, serverContext) { // A request to `http://www.example.com/page/index.html` will render the Angular route corresponding to `http://www.example.com/page`. const urlToRender = stripIndexHtmlFromURL(url).toString(); const platformRef = platformServer([ { provide: INITIAL_CONFIG, useValue: { url: urlToRender, document: html, }, }, { provide: _SERVER_CONTEXT, useValue: serverContext, }, { // An Angular Console Provider that does not print a set of predefined logs. provide: _Console, // Using `useClass` would necessitate decorating `Console` with `@Injectable`, // which would require switching from `ts_library` to `ng_module`. This change // would also necessitate various patches of `@angular/bazel` to support ESM. useFactory: () => new Console(), }, ...platformProviders, ]); let redirectTo; let hasNavigationError = true; try { let applicationRef; if (isNgModule(bootstrap)) { const moduleRef = await platformRef.bootstrapModule(bootstrap); applicationRef = moduleRef.injector.get(ApplicationRef); } else { applicationRef = await bootstrap({ platformRef }); } // Block until application is stable. await applicationRef.whenStable(); // TODO(alanagius): Find a way to avoid rendering here especially for redirects as any output will be discarded. const envInjector = applicationRef.injector; const routerIsProvided = !!envInjector.get(ActivatedRoute, null); const router = envInjector.get(Router); const lastSuccessfulNavigation = router.lastSuccessfulNavigation; if (!routerIsProvided) { hasNavigationError = false; } else if (lastSuccessfulNavigation?.finalUrl) { hasNavigationError = false; const { finalUrl, initialUrl } = lastSuccessfulNavigation; const finalUrlStringified = finalUrl.toString(); if (initialUrl.toString() !== finalUrlStringified) { const baseHref = envInjector.get(APP_BASE_HREF, null, { optional: true }) ?? envInjector.get(PlatformLocation).getBaseHrefFromDOM(); redirectTo = joinUrlParts(baseHref, finalUrlStringified); } } return { hasNavigationError, redirectTo, content: () => new Promise((resolve, reject) => { // Defer rendering to the next event loop iteration to avoid blocking, as most operations in `renderInternal` are synchronous. setTimeout(() => { _renderInternal(platformRef, applicationRef) .then(resolve) .catch(reject) .finally(() => void asyncDestroyPlatform(platformRef)); }, 0); }), }; } catch (error) { await asyncDestroyPlatform(platformRef); throw error; } finally { if (hasNavigationError || redirectTo) { void asyncDestroyPlatform(platformRef); } } } /** * Type guard to determine if a given value is an Angular module. * Angular modules are identified by the presence of the `ɵmod` static property. * This function helps distinguish between Angular modules and bootstrap functions. * * @param value - The value to be checked. * @returns True if the value is an Angular module (i.e., it has the `ɵmod` property), false otherwise. */ function isNgModule(value) { return 'ɵmod' in value; } /** * Gracefully destroys the application in a macrotask, allowing pending promises to resolve * and surfacing any potential errors to the user. * * @param platformRef - The platform reference to be destroyed. */ function asyncDestroyPlatform(platformRef) { return new Promise((resolve) => { setTimeout(() => { if (!platformRef.destroyed) { platformRef.destroy(); } resolve(); }, 0); }); } /** * Creates a promise that resolves with the result of the provided `promise` or rejects with an * `AbortError` if the `AbortSignal` is triggered before the promise resolves. * * @param promise - The promise to monitor for completion. * @param signal - An `AbortSignal` used to monitor for an abort event. If the signal is aborted, * the returned promise will reject. * @param errorMessagePrefix - A custom message prefix to include in the error message when the operation is aborted. * @returns A promise that either resolves with the value of the provided `promise` or rejects with * an `AbortError` if the `AbortSignal` is triggered. * * @throws {AbortError} If the `AbortSignal` is triggered before the `promise` resolves. */ function promiseWithAbort(promise, signal, errorMessagePrefix) { return new Promise((resolve, reject) => { const abortHandler = () => { reject(new DOMException(`${errorMessagePrefix} was aborted.\n${signal.reason}`, 'AbortError')); }; // Check for abort signal if (signal.aborted) { abortHandler(); return; } signal.addEventListener('abort', abortHandler, { once: true }); promise .then(resolve) .catch(reject) .finally(() => { signal.removeEventListener('abort', abortHandler); }); }); } /** * The internal path used for the app shell route. * @internal */ const APP_SHELL_ROUTE = 'ng-app-shell'; /** * Identifies a particular kind of `ServerRenderingFeatureKind`. * @see {@link ServerRenderingFeature} */ var ServerRenderingFeatureKind; (function (ServerRenderingFeatureKind) { ServerRenderingFeatureKind[ServerRenderingFeatureKind["AppShell"] = 0] = "AppShell"; ServerRenderingFeatureKind[ServerRenderingFeatureKind["ServerRoutes"] = 1] = "ServerRoutes"; })(ServerRenderingFeatureKind || (ServerRenderingFeatureKind = {})); /** * Different rendering modes for server routes. * @see {@link withRoutes} * @see {@link ServerRoute} */ var RenderMode; (function (RenderMode) { /** Server-Side Rendering (SSR) mode, where content is rendered on the server for each request. */ RenderMode[RenderMode["Server"] = 0] = "Server"; /** Client-Side Rendering (CSR) mode, where content is rendered on the client side in the browser. */ RenderMode[RenderMode["Client"] = 1] = "Client"; /** Static Site Generation (SSG) mode, where content is pre-rendered at build time and served as static files. */ RenderMode[RenderMode["Prerender"] = 2] = "Prerender"; })(RenderMode || (RenderMode = {})); /** * Defines the fallback strategies for Static Site Generation (SSG) routes when a pre-rendered path is not available. * This is particularly relevant for routes with parameterized URLs where some paths might not be pre-rendered at build time. * @see {@link ServerRoutePrerenderWithParams} */ var PrerenderFallback; (function (PrerenderFallback) { /** * Fallback to Server-Side Rendering (SSR) if the pre-rendered path is not available. * This strategy dynamically generates the page on the server at request time. */ PrerenderFallback[PrerenderFallback["Server"] = 0] = "Server"; /** * Fallback to Client-Side Rendering (CSR) if the pre-rendered path is not available. * This strategy allows the page to be rendered on the client side. */ PrerenderFallback[PrerenderFallback["Client"] = 1] = "Client"; /** * No fallback; if the path is not pre-rendered, the server will not handle the request. * This means the application will not provide any response for paths that are not pre-rendered. */ PrerenderFallback[PrerenderFallback["None"] = 2] = "None"; })(PrerenderFallback || (PrerenderFallback = {})); /** * Token for providing the server routes configuration. * @internal */ const SERVER_ROUTES_CONFIG = new InjectionToken('SERVER_ROUTES_CONFIG'); /** * Configures server-side routing for the application. * * This function registers an array of `ServerRoute` definitions, enabling server-side rendering * for specific URL paths. These routes are used to pre-render content on the server, improving * initial load performance and SEO. * * @param routes - An array of `ServerRoute` objects, each defining a server-rendered route. * @returns A `ServerRenderingFeature` object configuring server-side routes. * * @example * ```ts * import { provideServerRendering, withRoutes, ServerRoute, RenderMode } from '@angular/ssr'; * * const serverRoutes: ServerRoute[] = [ * { * route: '', // This renders the "/" route on the client (CSR) * renderMode: RenderMode.Client, * }, * { * route: 'about', // This page is static, so we prerender it (SSG) * renderMode: RenderMode.Prerender, * }, * { * route: 'profile', // This page requires user-specific data, so we use SSR * renderMode: RenderMode.Server, * }, * { * route: '**', // All other routes will be rendered on the server (SSR) * renderMode: RenderMode.Server, * }, * ]; * * provideServerRendering(withRoutes(serverRoutes)); * ``` * * @see {@link provideServerRendering} * @see {@link ServerRoute} */ function withRoutes(routes) { const config = { routes }; return { ɵkind: ServerRenderingFeatureKind.ServerRoutes, ɵproviders: [ { provide: SERVER_ROUTES_CONFIG, useValue: config, }, ], }; } /** * Configures the shell of the application. * * The app shell is a minimal, static HTML page that is served immediately, while the * full Angular application loads in the background. This improves perceived performance * by providing instant feedback to the user. * * This function configures the app shell route, which serves the provided component for * requests that do not match any defined server routes. * * @param component - The Angular component to render for the app shell. Can be a direct * component type or a dynamic import function. * @returns A `ServerRenderingFeature` object configuring the app shell. * * @example * ```ts * import { provideServerRendering, withAppShell, withRoutes } from '@angular/ssr'; * import { AppShellComponent } from './app-shell.component'; * * provideServerRendering( * withRoutes(serverRoutes), * withAppShell(AppShellComponent) * ); * ``` * * @example * ```ts * import { provideServerRendering, withAppShell, withRoutes } from '@angular/ssr'; * * provideServerRendering( * withRoutes(serverRoutes), * withAppShell(() => * import('./app-shell.component').then((m) => m.AppShellComponent) * ) * ); * ``` * * @see {@link provideServerRendering} * @see {@link https://angular.dev/ecosystem/service-workers/app-shell App shell pattern on Angular.dev} */ function withAppShell(component) { const routeConfig = { path: APP_SHELL_ROUTE, }; if ('ɵcmp' in component) { routeConfig.component = component; } else { routeConfig.loadComponent = component; } return { ɵkind: ServerRenderingFeatureKind.AppShell, ɵproviders: [ { provide: ROUTES, useValue: routeConfig, multi: true, }, provideEnvironmentInitializer(() => { const config = inject(SERVER_ROUTES_CONFIG); config.appShellRoute = APP_SHELL_ROUTE; }), ], }; } /** * Configures server-side rendering for an Angular application. * * This function sets up the necessary providers for server-side rendering, including * support for server routes and app shell. It combines features configured using * `withRoutes` and `withAppShell` to provide a comprehensive server-side rendering setup. * * @param features - Optional features to configure additional server rendering behaviors. * @returns An `EnvironmentProviders` instance with the server-side rendering configuration. * * @example * Basic example of how you can enable server-side rendering in your application * when using the `bootstrapApplication` function: * * ```ts * import { bootstrapApplication, BootstrapContext } from '@angular/platform-browser'; * import { provideServerRendering, withRoutes, withAppShell } from '@angular/ssr'; * import { AppComponent } from './app/app.component'; * import { SERVER_ROUTES } from './app/app.server.routes'; * import { AppShellComponent } from './app/app-shell.component'; * * const bootstrap = (context: BootstrapContext) => * bootstrapApplication(AppComponent, { * providers: [ * provideServerRendering( * withRoutes(SERVER_ROUTES), * withAppShell(AppShellComponent), * ), * ], * }, context); * * export default bootstrap; * ``` * @see {@link withRoutes} configures server-side routing * @see {@link withAppShell} configures the application shell */ function provideServerRendering(...features) { let hasAppShell = false; let hasServerRoutes = false; const providers = [provideServerRendering$1()]; for (const { ɵkind, ɵproviders } of features) { hasAppShell ||= ɵkind === ServerRenderingFeatureKind.AppShell; hasServerRoutes ||= ɵkind === ServerRenderingFeatureKind.ServerRoutes; providers.push(...ɵproviders); } if (!hasServerRoutes && hasAppShell) { throw new Error(`Configuration error: found 'withAppShell()' without 'withRoutes()' in the same call to 'provideServerRendering()'.` + `The 'withAppShell()' function requires 'withRoutes()' to be used.`); } return makeEnvironmentProviders(providers); } /** * A route tree implementation that supports efficient route matching, including support for wildcard routes. * This structure is useful for organizing and retrieving routes in a hierarchical manner, * enabling complex routing scenarios with nested paths. * * @typeParam AdditionalMetadata - Type of additional metadata that can be associated with route nodes. */ class RouteTree { /** * The root node of the route tree. * All routes are stored and accessed relative to this root node. */ root = this.createEmptyRouteTreeNode(); /** * Inserts a new route into the route tree. * The route is broken down into segments, and each segment is added to the tree. * Parameterized segments (e.g., :id) are normalized to wildcards (*) for matching purposes. * * @param route - The route path to insert into the tree. * @param metadata - Metadata associated with the route, excluding the route path itself. */ insert(route, metadata) { let node = this.root; const segments = this.getPathSegments(route); const normalizedSegments = []; for (const segment of segments) { // Replace parameterized segments (e.g., :id) with a wildcard (*) for matching const normalizedSegment = segment[0] === ':' ? '*' : segment; let childNode = node.children.get(normalizedSegment); if (!childNode) { childNode = this.createEmptyRouteTreeNode(); node.children.set(normalizedSegment, childNode); } node = childNode; normalizedSegments.push(normalizedSegment); } // At the leaf node, store the full route and its associated metadata node.metadata = { ...metadata, route: addLeadingSlash(normalizedSegments.join('/')), }; } /** * Matches a given route against the route tree and returns the best matching route's metadata. * The best match is determined by the lowest insertion index, meaning the earliest defined route * takes precedence. * * @param route - The route path to match against the route tree. * @returns The metadata of the best matching route or `undefined` if no match is found. */ match(route) { const segments = this.getPathSegments(route); return this.traverseBySegments(segments)?.metadata; } /** * Converts the route tree into a serialized format representation. * This method converts the route tree into an array of metadata objects that describe the structure of the tree. * The array represents the routes in a nested manner where each entry includes the route and its associated metadata. * * @returns An array of `RouteTreeNodeMetadata` objects representing the route tree structure. * Each object includes the `route` and associated metadata of a route. */ toObject() { return Array.from(this.traverse()); } /** * Constructs a `RouteTree` from an object representation. * This method is used to recreate a `RouteTree` instance from an array of metadata objects. * The array should be in the format produced by `toObject`, allowing for the reconstruction of the route tree * with the same routes and metadata. * * @param value - An array of `RouteTreeNodeMetadata` objects that represent the serialized format of the route tree. * Each object should include a `route` and its associated metadata. * @returns A new `RouteTree` instance constructed from the provided metadata objects. */ static fromObject(value) { const tree = new RouteTree(); for (const { route, ...metadata } of value) { tree.insert(route, metadata); } return tree; } /** * A generator function that recursively traverses the route tree and yields the metadata of each node. * This allows for easy and efficient iteration over all nodes in the tree. * * @param node - The current node to start the traversal from. Defaults to the root node of the tree. */ *traverse(node = this.root) { if (node.metadata) { yield node.metadata; } for (const childNode of node.children.values()) { yield* this.traverse(childNode); } } /** * Extracts the path segments from a given route string. * * @param route - The route string from which to extract segments. * @returns An array of path segments. */ getPathSegments(route) { return route.split('/').filter(Boolean); } /** * Recursively traverses the route tree from a given node, attempting to match the remaining route segments. * If the node is a leaf node (no more segments to match) and contains metadata, the node is yielded. * * This function prioritizes exact segment matches first, followed by wildcard matches (`*`), * and finally deep wildcard matches (`**`) that consume all segments. * * @param segments - The array of route path segments to match against the route tree. * @param node - The current node in the route tree to start traversal from. Defaults to the root node. * @param currentIndex - The index of the segment in `remainingSegments` currently being matched. * Defaults to `0` (the first segment). * * @returns The node that best matches the remaining segments or `undefined` if no match is found. */ traverseBySegments(segments, node = this.root, currentIndex = 0) { if (currentIndex >= segments.length) { return node.metadata ? node : node.children.get('**'); } if (!node.children.size) { return undefined; } const segment = segments[currentIndex]; // 1. Attempt exact match with the current segment. const exactMatch = node.children.get(segment); if (exactMatch) { const match = this.traverseBySegments(segments, exactMatch, currentIndex + 1); if (match) { return match; } } // 2. Attempt wildcard match ('*'). const wildcardMatch = node.children.get('*'); if (wildcardMatch) { const match = this.traverseBySegments(segments, wildcardMatch, currentIndex + 1); if (match) { return match; } } // 3. Attempt double wildcard match ('**'). return node.children.get('**'); } /** * Creates an empty route tree node. * This helper function is used during the tree construction. * * @returns A new, empty route tree node. */ createEmptyRouteTreeNode() { return { children: new Map(), }; } } /** * The maximum number of module preload link elements that should be added for * initial scripts. */ const MODULE_PRELOAD_MAX = 10; /** * Regular expression to match a catch-all route pattern in a URL path, * specifically one that ends with '/**'. */ const CATCH_ALL_REGEXP = /\/(\*\*)$/; /** * Regular expression to match segments preceded by a colon in a string. */ const URL_PARAMETER_REGEXP = /(?<!\\):([^/]+)/g; /** * An set of HTTP status codes that are considered valid for redirect responses. */ const VALID_REDIRECT_RESPONSE_CODES = new Set([301, 302, 303, 307, 308]); /** * Handles a single route within the route tree and yields metadata or errors. * * @param options - Configuration options for handling the route. * @returns An async iterable iterator yielding `RouteTreeNodeMetadata` or an error object. */ async function* handleRoute(options) { try { const { metadata, currentRoutePath, route, compiler, parentInjector, serverConfigRouteTree, entryPointToBrowserMapping, invokeGetPrerenderParams, includePrerenderFallbackRoutes, } = options; const { redirectTo, loadChildren, loadComponent, children, ɵentryName } = route; if (ɵentryName && loadComponent) { appendPreloadToMetadata(ɵentryName, entryPointToBrowserMapping, metadata); } if (metadata.renderMode === RenderMode.Prerender) { yield* handleSSGRoute(serverConfigRouteTree, typeof redirectTo === 'string' ? redirectTo : undefined, metadata, parentInjector, invokeGetPrerenderParams, includePrerenderFallbackRoutes); } else if (redirectTo !== undefined) { if (metadata.status && !VALID_REDIRECT_RESPONSE_CODES.has(metadata.status)) { yield { error: `The '${metadata.status}' status code is not a valid redirect response code. ` + `Please use one of the following redirect response codes: ${[...VALID_REDIRECT_RESPONSE_CODES.values()].join(', ')}.`, }; } else if (typeof redirectTo === 'string') { yield { ...metadata, redirectTo: resolveRedirectTo(metadata.route, redirectTo), }; } else { yield metadata; } } else { yield metadata; } // Recursively process child routes if (children?.length) { yield* traverseRoutesConfig({ ...options, routes: children, parentRoute: currentRoutePath, parentPreloads: metadata.preload, }); } // Load and process lazy-loaded child routes if (loadChildren) { if (ɵentryName) { appendPreloadToMetadata(ɵentryName, entryPointToBrowserMapping, metadata); } const routeInjector = route.providers ? createEnvironmentInjector(route.providers, parentInjector.get(EnvironmentInjector), `Route: ${route.path}`) : parentInjector; const loadedChildRoutes = await _loadChildren(route, compiler, routeInjector).toPromise(); if (loadedChildRoutes) { const { routes: childRoutes, injector = routeInjector } = loadedChildRoutes; yield* traverseRoutesConfig({ ...options, routes: childRoutes, parentInjector: injector, parentRoute: currentRoutePath, parentPreloads: metadata.preload, }); } } } catch (error) { yield { error: `Error in handleRoute for '${options.currentRoutePath}': ${error.message}`, }; } } /** * Traverses an array of route configurations to generate route tree node metadata. * * This function processes each route and its children, handling redirects, SSG (Static Site Generation) settings, * and lazy-loaded routes. It yields route metadata for each route and its potential variants. * * @param options - The configuration options for traversing routes. * @returns An async iterable iterator yielding either route tree node metadata or an error object with an error message. */ async function* traverseRoutesConfig(options) { const { routes: routeConfigs, parentPreloads, parentRoute, serverConfigRouteTree } = options; for (const route of routeConfigs) { const { matcher, path = matcher ? '**' : '' } = route; const currentRoutePath = joinUrlParts(parentRoute, path); if (matcher && serverConfigRouteTree) { let foundMatch = false; for (const matchedMetaData of serverConfigRouteTree.traverse()) { if (!matchedMetaData.route.startsWith(currentRoutePath)) { continue; } foundMatch = true; matchedMetaData.presentInClientRouter = true; if (matchedMetaData.renderMode === RenderMode.Prerender) { yield { error: `The route '${stripLeadingSlash(currentRoutePath)}' is set for prerendering but has a defined matcher. ` + `Routes with matchers cannot use prerendering. Please specify a different 'renderMode'.`, }; continue; } yield* handleRoute({ ...options, currentRoutePath, route, metadata: { ...matchedMetaData, preload: parentPreloads, route: matchedMetaData.route, presentInClientRouter: undefined, }, }); } if (!foundMatch) { yield { error: `The route '${stripLeadingSlash(currentRoutePath)}' has a defined matcher but does not ` + 'match any route in the server routing configuration. Please ensure this route is added to the server routing configuration.', }; } continue; } let matchedMetaData; if (serverConfigRouteTree) { matchedMetaData = serverConfigRouteTree.match(currentRoutePath); if (!matchedMetaData) { yield { error: `The '${stripLeadingSlash(currentRoutePath)}' route does not match any route defined in the server routing configuration. ` + 'Please ensure this route is added to the server routing configuration.', }; continue; } matchedMetaData.presentInClientRouter = true; } yield* handleRoute({ ...options, metadata: { renderMode: RenderMode.Prerender, ...matchedMetaData, preload: parentPreloads, // Match Angular router behavior // ['one', 'two', ''] -> 'one/two/' // ['one', 'two', 'three'] -> 'one/two/three' route: path === '' ? addTrailingSlash(currentRoutePath) : currentRoutePath, presentInClientRouter: undefined, }, currentRoutePath, route, }); } } /** * Appends preload information to the metadata object based on the specified entry-point and chunk mappings. * * This function extracts preload data for a given entry-point from the provided chunk mappings. It adds the * corresponding browser bundles to the metadata's preload list, ensuring no duplicates and limiting the total * preloads to a predefined maximum. */ function appendPreloadToMetadata(entryName, entryPointToBrowserMapping, metadata) { const existingPreloads = metadata.preload ?? []; if (!entryPointToBrowserMapping || existingPreloads.length >= MODULE_PRELOAD_MAX) { return; } const preload = entryPointToBrowserMapping[entryName]; if (!preload?.length) { return; } // Merge existing preloads with new ones, ensuring uniqueness and limiting the total to the maximum allowed. const combinedPreloads = new Set(existingPreloads); for (const href of preload) { combinedPreloads.add(href); if (combinedPreloads.size === MODULE_PRELOAD_MAX) { break; } } metadata.preload = Array.from(combinedPreloads); } /** * Handles SSG (Static Site Generation) routes by invoking `getPrerenderParams` and yielding * all parameterized paths, returning any errors encountered. * * @param serverConfigRouteTree - The tree representing the server's routing setup. * @param redirectTo - Optional path to redirect to, if specified. * @param metadata - The metadata associated with the route tree node. * @param parentInjector - The dependency injection container for the parent route. * @param invokeGetPrerenderParams - A flag indicating whether to invoke the `getPrerenderParams` function. * @param includePrerenderFallbackRoutes - A flag indicating whether to include fallback routes in the result. * @returns An async iterable iterator that yields route tree node metadata for each SSG path or errors. */ async function* handleSSGRoute(serverConfigRouteTree, redirectTo, metadata, parentInjector, invokeGetPrerenderParams, includePrerenderFallbackRoutes) { if (metadata.renderMode !== RenderMode.Prerender) { throw new Error(`'handleSSGRoute' was called for a route which rendering mode is not prerender.`); } const { route: currentRoutePath, fallback, ...meta } = metadata; const getPrerenderParams = 'getPrerenderParams' in meta ? meta.getPrerenderParams : undefined; if ('getPrerenderParams' in meta) { delete meta['getPrerenderParams']; } if (redirectTo !== undefined) { meta.redirectTo = resolveRedirectTo(currentRoutePath, redirectTo); } const isCatchAllRoute = CATCH_ALL_REGEXP.test(currentRoutePath); if ((isCatchAllRoute && !getPrerenderParams) || (!isCatchAllRoute && !URL_PARAMETER_REGEXP.test(currentRoutePath))) { // Route has no parameters yield { ...meta, route: currentRoutePath, }; return; } if (invokeGetPrerenderParams) { if (!getPrerenderParams) { yield { error: `The '${stripLeadingSlash(currentRoutePath)}' route uses prerendering and includes parameters, but 'getPrerenderParams' ` + `is missing. Please define 'getPrerenderParams' function for this route in your server routing configuration ` + `or specify a different 'renderMode'.`, }; return; } if (serverConfigRouteTree) { // Automatically resolve dynamic parameters for nested routes. const catchAllRoutePath = isCatchAllRoute ? currentRoutePath : joinUrlParts(currentRoutePath, '**'); const match = serverConfigRouteTree.match(catchAllRoutePath); if (match && match.renderMode === RenderMode.Prerender && !('getPrerenderParams' in match)) { serverConfigRouteTree.insert(catchAllRoutePath, { ...match, presentInClientRouter: true, getPrerenderParams, }); } } const parameters = await runInInjectionContext(parentInjector, () => getPrerenderParams()); try { for (const params of parameters) { const replacer = handlePrerenderParamsReplacement(params, currentRoutePath); const routeWithResolvedParams = currentRoutePath .replace(URL_PARAMETER_REGEXP, replacer) .replace(CATCH_ALL_REGEXP, replacer); yield { ...meta, route: routeWithResolvedParams, redirectTo: redirectTo === undefined ? undefined : resolveRedirectTo(routeWithResolvedParams, redirectTo), }; } } catch (error) { yield { error: `${error.message}` }; return; } } // Handle fallback render modes if (includePrerenderFallbackRoutes && (fallback !== PrerenderFallback.None || !invokeGetPrerenderParams)) { yield { ...meta, route: currentRoutePath, renderMode: fallback === PrerenderFallback.Client ? RenderMode.Client : RenderMode.Server, }; } } /** * Creates a replacer function used for substituting parameter placeholders in a route path * with their corresponding values provided in the `params` object. * * @param params - An object mapping parameter names to their string values. * @param currentRoutePath - The current route path, used for constructing error messages. * @returns A function that replaces a matched parameter placeholder (e.g., ':id') with its corresponding value. */ function handlePrerenderParamsReplacement(params, currentRoutePath) { return (match) => { const parameterName = match.slice(1); const value = params[parameterName]; if (typeof value !== 'string') { throw new Error(`The 'getPrerenderParams' function defined for the '${stripLeadingSlash(currentRoutePath)}' route ` + `returned a non-string value for parameter '${parameterName}'. ` + `Please make sure the 'getPrerenderParams' function returns values for all parameters ` + 'specified in this route.'); } return parameterName === '**' ? `/${value}` : value; }; } /** * Resolves the `redirectTo` property for a given route. * * This function processes the `redirectTo` property to ensure that it correctly * resolves relative to the current route path. If `redirectTo` is an absolute path, * it is returned as is. If it is a relative path, it is resolved based on the current route path. * * @param routePath - The current route path. * @param redirectTo - The target path for redirection. * @returns The resolved redirect path as a string. */ function resolveRedirectTo(routePath, redirectTo) { if (redirectTo[0] === '/') { // If the redirectTo path is absolute, return it as is. return redirectTo; } // Resolve relative redirectTo based on the current route path. const segments = routePath.replace(URL_PARAMETER_REGEXP, '*').split('/'); segments.pop(); // Remove the last segment to make it relative. return joinUrlParts(...segments, redirectTo); } /** * Builds a server configuration route tree from the given server routes configuration. * * @param serverRoutesConfig - The server routes to be used for configuration. * @returns An object containing: * - `serverConfigRouteTree`: A populated `RouteTree` instance, which organizes the server routes * along with their additional metadata. * - `errors`: An array of strings that list any errors encountered during the route tree construction * process, such as invalid paths. */ function buildServerConfigRouteTree({ routes, appShellRoute }) { const serverRoutes = [...routes]; if (appShellRoute !== undefined) { serverRoutes.unshift({ path: appShellRoute, renderMode: RenderMode.Prerender, }); } const serverConfigRouteTree = new RouteTree(); const errors = []; for (const { path, ...metadata } of serverRoutes) { if (path[0] === '/') { errors.push(`Invalid '${path}' route configuration: the path cannot start with a slash.`); continue; } if ('getPrerenderParams' in metadata && (path.includes('/*/') || path.endsWith('/*'))) { errors.push(`Invalid '${path}' route configuration: 'getPrerenderParams' cannot be used with a '*' route.`); continue; } serverConfigRouteTree.insert(path, metadata); } return { serverConfigRouteTree, errors }; } /** * Retrieves routes from the given Angular application. * * This function initializes an Angular platform, bootstraps the application or module, * and retrieves routes from the Angular router configuration. It handles both module-based * and function-based bootstrapping. It yields the resulting routes as `RouteTreeNodeMetadata` objects or errors. * * @param bootstrap - A function that returns a promise resolving to an `ApplicationRef` or an Angular module to bootstrap. * @param document - The initial HTML document used for server-side rendering. * This document is necessary to render the application on the server. * @param url - The URL for server-side rendering. The URL is used to configure `ServerPlatformLocation`. This configuration is crucial * for ensuring that API requests for relat