@angular/ssr
Version:
Angular server side rendering utilities
1,214 lines (1,204 loc) • 110 kB
JavaScript
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