Alan Agius b2e2be052f refactor(@angular/ssr): remove RenderMode.AppShell in favor of new configuration option
This commit removes the `RenderMode.AppShell` option. Instead, a new configuration parameter, `{ appShellRoute: 'shell' }`, is introduced to the `provideServerRoutesConfig` method.

```ts
provideServerRoutesConfig(serverRoutes, { appShellRoute: 'shell' })
```
2024-11-08 19:51:35 +01:00

554 lines
19 KiB
TypeScript

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import { APP_BASE_HREF, PlatformLocation } from '@angular/common';
import {
ApplicationRef,
Compiler,
Injector,
runInInjectionContext,
ɵwhenStable as whenStable,
ɵConsole,
} from '@angular/core';
import { INITIAL_CONFIG, platformServer } from '@angular/platform-server';
import { Route, Router, ɵloadChildren as loadChildrenHelper } from '@angular/router';
import { ServerAssets } from '../assets';
import { Console } from '../console';
import { AngularAppManifest, getAngularAppManifest } from '../manifest';
import { AngularBootstrap, isNgModule } from '../utils/ng';
import { joinUrlParts, stripLeadingSlash } from '../utils/url';
import {
PrerenderFallback,
RenderMode,
SERVER_ROUTES_CONFIG,
ServerRoute,
ServerRoutesConfig,
} from './route-config';
import { RouteTree, RouteTreeNodeMetadata } from './route-tree';
/**
* 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]);
/**
* Additional metadata for a server configuration route tree.
*/
type ServerConfigRouteTreeAdditionalMetadata = Partial<ServerRoute> & {
/** Indicates if the route has been matched with the Angular router routes. */
presentInClientRouter?: boolean;
};
/**
* Metadata for a server configuration route tree node.
*/
type ServerConfigRouteTreeNodeMetadata = RouteTreeNodeMetadata &
ServerConfigRouteTreeAdditionalMetadata;
/**
* Result of extracting routes from an Angular application.
*/
interface AngularRouterConfigResult {
/**
* The base URL for the application.
* This is the base href that is used for resolving relative paths within the application.
*/
baseHref: string;
/**
* An array of `RouteTreeNodeMetadata` objects representing the application's routes.
*
* Each `RouteTreeNodeMetadata` contains details about a specific route, such as its path and any
* associated redirection targets. This array is asynchronously generated and
* provides information on how routes are structured and resolved.
*/
routes: RouteTreeNodeMetadata[];
/**
* Optional configuration for server routes.
*
* This property allows you to specify an array of server routes for configuration.
* If not provided, the default configuration or behavior will be used.
*/
serverRoutesConfig?: ServerRoute[] | null;
/**
* A list of errors encountered during the route extraction process.
*/
errors: string[];
/**
* The specified route for the app-shell, if configured.
*/
appShellRoute?: string;
}
/**
* 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: {
routes: Route[];
compiler: Compiler;
parentInjector: Injector;
parentRoute: string;
serverConfigRouteTree: RouteTree<ServerConfigRouteTreeAdditionalMetadata> | undefined;
invokeGetPrerenderParams: boolean;
includePrerenderFallbackRoutes: boolean;
}): AsyncIterableIterator<RouteTreeNodeMetadata | { error: string }> {
const {
routes,
compiler,
parentInjector,
parentRoute,
serverConfigRouteTree,
invokeGetPrerenderParams,
includePrerenderFallbackRoutes,
} = options;
for (const route of routes) {
try {
const { path = '', redirectTo, loadChildren, children } = route;
const currentRoutePath = joinUrlParts(parentRoute, path);
// Get route metadata from the server config route tree, if available
let matchedMetaData: ServerConfigRouteTreeNodeMetadata | undefined;
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;
}
const metadata: ServerConfigRouteTreeNodeMetadata = {
renderMode: RenderMode.Prerender,
...matchedMetaData,
route: currentRoutePath,
};
delete metadata.presentInClientRouter;
// Handle redirects
if (typeof redirectTo === 'string') {
const redirectToResolved = resolveRedirectTo(currentRoutePath, redirectTo);
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(', ')}.`,
};
continue;
}
yield { ...metadata, redirectTo: redirectToResolved };
} else if (metadata.renderMode === RenderMode.Prerender) {
// Handle SSG routes
yield* handleSSGRoute(
metadata,
parentInjector,
invokeGetPrerenderParams,
includePrerenderFallbackRoutes,
);
} else {
yield metadata;
}
// Recursively process child routes
if (children?.length) {
yield* traverseRoutesConfig({
...options,
routes: children,
parentRoute: currentRoutePath,
});
}
// Load and process lazy-loaded child routes
if (loadChildren) {
const loadedChildRoutes = await loadChildrenHelper(
route,
compiler,
parentInjector,
).toPromise();
if (loadedChildRoutes) {
const { routes: childRoutes, injector = parentInjector } = loadedChildRoutes;
yield* traverseRoutesConfig({
...options,
routes: childRoutes,
parentInjector: injector,
parentRoute: currentRoutePath,
});
}
}
} catch (error) {
yield {
error: `Error processing route '${stripLeadingSlash(route.path ?? '')}': ${(error as Error).message}`,
};
}
}
}
/**
* Handles SSG (Static Site Generation) routes by invoking `getPrerenderParams` and yielding
* all parameterized paths, returning any errors encountered.
*
* @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(
metadata: ServerConfigRouteTreeNodeMetadata,
parentInjector: Injector,
invokeGetPrerenderParams: boolean,
includePrerenderFallbackRoutes: boolean,
): AsyncIterableIterator<RouteTreeNodeMetadata | { error: string }> {
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 (!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;
}
const parameters = await runInInjectionContext(parentInjector, () => getPrerenderParams());
try {
for (const params of parameters) {
const routeWithResolvedParams = currentRoutePath.replace(URL_PARAMETER_REGEXP, (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 value;
});
yield { ...meta, route: routeWithResolvedParams };
}
} catch (error) {
yield { error: `${(error as 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,
};
}
}
/**
* 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: string, redirectTo: string): string {
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.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 }: ServerRoutesConfig): {
errors: string[];
serverConfigRouteTree: RouteTree<ServerConfigRouteTreeAdditionalMetadata>;
} {
const serverRoutes: ServerRoute[] = [...routes];
if (appShellRoute !== undefined) {
serverRoutes.unshift({
path: appShellRoute,
renderMode: RenderMode.Prerender,
});
}
const serverConfigRouteTree = new RouteTree<ServerConfigRouteTreeAdditionalMetadata>();
const errors: string[] = [];
for (const { path, ...metadata } of serverRoutes) {
if (path[0] === '/') {
errors.push(`Invalid '${path}' route configuration: the path cannot start with a slash.`);
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 relative paths succeed, which is essential for accurate route extraction.
* @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`.
*
* @returns A promise that resolves to an object of type `AngularRouterConfigResult` or errors.
*/
export async function getRoutesFromAngularRouterConfig(
bootstrap: AngularBootstrap,
document: string,
url: URL,
invokeGetPrerenderParams = false,
includePrerenderFallbackRoutes = true,
): Promise<AngularRouterConfigResult> {
const { protocol, host } = url;
// Create and initialize the Angular platform for server-side rendering.
const platformRef = platformServer([
{
provide: INITIAL_CONFIG,
useValue: { document, url: `${protocol}//${host}/` },
},
{
provide: ɵConsole,
useFactory: () => new Console(),
},
]);
try {
let applicationRef: ApplicationRef;
if (isNgModule(bootstrap)) {
const moduleRef = await platformRef.bootstrapModule(bootstrap);
applicationRef = moduleRef.injector.get(ApplicationRef);
} else {
applicationRef = await bootstrap();
}
// Wait until the application is stable.
await whenStable(applicationRef);
const injector = applicationRef.injector;
const router = injector.get(Router);
const routesResults: RouteTreeNodeMetadata[] = [];
const errors: string[] = [];
const baseHref =
injector.get(APP_BASE_HREF, null, { optional: true }) ??
injector.get(PlatformLocation).getBaseHrefFromDOM();
const compiler = injector.get(Compiler);
const serverRoutesConfig = injector.get(SERVER_ROUTES_CONFIG, null, { optional: true });
let serverConfigRouteTree: RouteTree<ServerConfigRouteTreeAdditionalMetadata> | undefined;
if (serverRoutesConfig) {
const result = buildServerConfigRouteTree(serverRoutesConfig);
serverConfigRouteTree = result.serverConfigRouteTree;
errors.push(...result.errors);
}
if (errors.length) {
return {
baseHref,
routes: routesResults,
errors,
};
}
if (router.config.length) {
// Retrieve all routes from the Angular router configuration.
const traverseRoutes = traverseRoutesConfig({
routes: router.config,
compiler,
parentInjector: injector,
parentRoute: '',
serverConfigRouteTree,
invokeGetPrerenderParams,
includePrerenderFallbackRoutes,
});
let seenAppShellRoute: string | undefined;
for await (const result of traverseRoutes) {
if ('error' in result) {
errors.push(result.error);
} else {
routesResults.push(result);
}
}
if (serverConfigRouteTree) {
for (const { route, presentInClientRouter } of serverConfigRouteTree.traverse()) {
if (presentInClientRouter || route === '**') {
// Skip if matched or it's the catch-all route.
continue;
}
errors.push(
`The '${route}' server route does not match any routes defined in the Angular ` +
`routing configuration (typically provided as a part of the 'provideRouter' call). ` +
'Please make sure that the mentioned server route is present in the Angular routing configuration.',
);
}
}
} else {
const renderMode = serverConfigRouteTree?.match('')?.renderMode ?? RenderMode.Prerender;
routesResults.push({
route: '',
renderMode,
});
}
return {
baseHref,
routes: routesResults,
errors,
appShellRoute: serverRoutesConfig?.appShellRoute,
};
} finally {
platformRef.destroy();
}
}
/**
* Asynchronously extracts routes from the Angular application configuration
* and creates a `RouteTree` to manage server-side routing.
*
* @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 relative paths succeed, which is essential for accurate route extraction.
* See:
* - https://github.com/angular/angular/blob/d608b857c689d17a7ffa33bbb510301014d24a17/packages/platform-server/src/location.ts#L51
* - https://github.com/angular/angular/blob/6882cc7d9eed26d3caeedca027452367ba25f2b9/packages/platform-server/src/http.ts#L44
* @param manifest - An optional `AngularAppManifest` that contains the application's routing and configuration details.
* If not provided, the default manifest is retrieved using `getAngularAppManifest()`.
* @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`.
*
* @returns A promise that resolves to an object containing:
* - `routeTree`: A populated `RouteTree` containing all extracted routes from the Angular application.
* - `appShellRoute`: The specified route for the app-shell, if configured.
* - `errors`: An array of strings representing any errors encountered during the route extraction process.
*/
export async function extractRoutesAndCreateRouteTree(
url: URL,
manifest: AngularAppManifest = getAngularAppManifest(),
invokeGetPrerenderParams = false,
includePrerenderFallbackRoutes = true,
): Promise<{ routeTree: RouteTree; appShellRoute?: string; errors: string[] }> {
const routeTree = new RouteTree();
const document = await new ServerAssets(manifest).getIndexServerHtml().text();
const bootstrap = await manifest.bootstrap();
const { baseHref, appShellRoute, routes, errors } = await getRoutesFromAngularRouterConfig(
bootstrap,
document,
url,
invokeGetPrerenderParams,
includePrerenderFallbackRoutes,
);
for (const { route, ...metadata } of routes) {
if (metadata.redirectTo !== undefined) {
metadata.redirectTo = joinUrlParts(baseHref, metadata.redirectTo);
}
const fullRoute = joinUrlParts(baseHref, route);
routeTree.insert(fullRoute, metadata);
}
return {
appShellRoute,
routeTree,
errors,
};
}