feat(@angular/ssr): dynamic route resolution using Angular router

This enhancement eliminates the dependency on file extensions for server-side rendering (SSR) route handling, leveraging Angular's router configuration for more dynamic and flexible route determination. Additionally, configured redirectTo routes now correctly respond with a 302 redirect status.

The new router uses a radix tree for storing routes. This data structure allows for efficient prefix-based lookups and insertions, which is particularly crucial when dealing with nested and parameterized routes.

This change also lays the groundwork for potential future server-side routing configurations, further enhancing the capabilities of Angular's SSR functionality.
This commit is contained in:
Alan Agius 2024-08-09 12:31:55 +00:00 committed by Alan Agius
parent 37693c40e3
commit bca5683893
22 changed files with 1355 additions and 178 deletions

View File

@ -24,6 +24,10 @@
"packages/angular/cli/src/analytics/analytics.ts",
"packages/angular/cli/src/command-builder/command-module.ts"
],
["packages/angular/ssr/src/app.ts", "packages/angular/ssr/src/manifest.ts"],
[
"packages/angular/ssr/src/app.ts",
"packages/angular/ssr/src/assets.ts",
"packages/angular/ssr/src/manifest.ts"
],
["packages/angular/ssr/src/app.ts", "packages/angular/ssr/src/render.ts"]
]

View File

@ -18,11 +18,12 @@ ts_library(
),
module_name = "@angular/ssr",
deps = [
"@npm//@angular/common",
"@npm//@angular/core",
"@npm//@angular/platform-server",
"@npm//@angular/router",
"@npm//@types/node",
"@npm//critters",
"@npm//mrmime",
],
)

View File

@ -14,18 +14,20 @@
},
"dependencies": {
"critters": "0.0.24",
"mrmime": "2.0.0",
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": "^18.0.0 || ^18.2.0-next.0",
"@angular/core": "^18.0.0 || ^18.2.0-next.0"
"@angular/core": "^18.0.0 || ^18.2.0-next.0",
"@angular/router": "^18.0.0 || ^18.2.0-next.0"
},
"devDependencies": {
"@angular/compiler": "18.2.0-next.2",
"@angular/platform-browser": "18.2.0-next.2",
"@angular/platform-server": "18.2.0-next.2",
"@angular/router": "18.2.0-next.2",
"@angular/common": "18.2.0-rc.0",
"@angular/compiler": "18.2.0-rc.0",
"@angular/core": "18.2.0-rc.0",
"@angular/platform-browser": "18.2.0-rc.0",
"@angular/platform-server": "18.2.0-rc.0",
"@angular/router": "18.2.0-rc.0",
"zone.js": "^0.14.0"
},
"schematics": "./schematics/collection.json",

View File

@ -6,7 +6,6 @@
* found in the LICENSE file at https://angular.dev/license
*/
import { lookup as lookupMimeType } from 'mrmime';
import { AngularServerApp } from './app';
import { Hooks } from './hooks';
import { getPotentialLocaleIdFromUrl } from './i18n';
@ -70,10 +69,6 @@ export class AngularAppEngine {
async render(request: Request, requestContext?: unknown): Promise<Response | null> {
// Skip if the request looks like a file but not `/index.html`.
const url = new URL(request.url);
const { pathname } = url;
if (isFileLike(pathname) && !pathname.endsWith('/index.html')) {
return null;
}
const entryPoint = this.getEntryPointFromUrl(url);
if (!entryPoint) {
@ -131,20 +126,3 @@ export class AngularAppEngine {
return entryPoint ? [potentialLocale, entryPoint] : null;
}
}
/**
* Determines if the given pathname corresponds to a file-like resource.
*
* @param pathname - The pathname to check.
* @returns True if the pathname appears to be a file, false otherwise.
*/
function isFileLike(pathname: string): boolean {
const dotIndex = pathname.lastIndexOf('.');
if (dotIndex === -1) {
return false;
}
const extension = pathname.slice(dotIndex);
return extension === '.ico' || !!lookupMimeType(extension);
}

View File

@ -6,9 +6,11 @@
* found in the LICENSE file at https://angular.dev/license
*/
import { ServerAssets } from './assets';
import { Hooks } from './hooks';
import { getAngularAppManifest } from './manifest';
import { ServerRenderContext, render } from './render';
import { ServerRouter } from './routes/router';
/**
* Configuration options for initializing a `AngularServerApp` instance.
@ -53,6 +55,17 @@ export class AngularServerApp {
*/
readonly isDevMode: boolean;
/**
* An instance of ServerAsset that handles server-side asset.
* @internal
*/
readonly assets = new ServerAssets(this.manifest);
/**
* The router instance used for route matching and handling.
*/
private router: ServerRouter | undefined;
/**
* Creates a new `AngularServerApp` instance with the provided configuration options.
*
@ -60,7 +73,7 @@ export class AngularServerApp {
* - `isDevMode`: Flag indicating if the application is in development mode.
* - `hooks`: Optional hooks for customizing application behavior.
*/
constructor(options: AngularServerAppOptions) {
constructor(readonly options: AngularServerAppOptions) {
this.isDevMode = options.isDevMode ?? false;
this.hooks = options.hooks ?? new Hooks();
}
@ -74,31 +87,29 @@ export class AngularServerApp {
* @param requestContext - Optional additional context for rendering, such as request metadata.
* @param serverContext - The rendering context.
*
* @returns A promise that resolves to the HTTP response object resulting from the rendering.
* @returns A promise that resolves to the HTTP response object resulting from the rendering, or null if no match is found.
*/
render(
async render(
request: Request,
requestContext?: unknown,
serverContext: ServerRenderContext = ServerRenderContext.SSR,
): Promise<Response> {
return render(this, request, serverContext, requestContext);
}
): Promise<Response | null> {
const url = new URL(request.url);
this.router ??= await ServerRouter.from(this.manifest, url);
/**
* Retrieves the content of a server-side asset using its path.
*
* This method fetches the content of a specific asset defined in the server application's manifest.
*
* @param path - The path to the server asset.
* @returns A promise that resolves to the asset content as a string.
* @throws Error If the asset path is not found in the manifest, an error is thrown.
*/
async getServerAsset(path: string): Promise<string> {
const asset = this.manifest.assets[path];
if (!asset) {
throw new Error(`Server asset '${path}' does not exist.`);
const matchedRoute = this.router.match(url);
if (!matchedRoute) {
// Not a known Angular route.
return null;
}
return asset();
const { redirectTo } = matchedRoute;
if (redirectTo !== undefined) {
// 302 Found is used by default for redirections
// See: https://developer.mozilla.org/en-US/docs/Web/API/Response/redirect_static#status
return Response.redirect(new URL(redirectTo, url), 302);
}
return render(this, request, serverContext, requestContext);
}
}

View File

@ -0,0 +1,47 @@
/**
* @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 { AngularAppManifest } from './manifest';
/**
* Manages server-side assets.
*/
export class ServerAssets {
/**
* Creates an instance of ServerAsset.
*
* @param manifest - The manifest containing the server assets.
*/
constructor(private readonly manifest: AngularAppManifest) {}
/**
* Retrieves the content of a server-side asset using its path.
*
* @param path - The path to the server asset.
* @returns A promise that resolves to the asset content as a string.
* @throws Error If the asset path is not found in the manifest, an error is thrown.
*/
async getServerAsset(path: string): Promise<string> {
const asset = this.manifest.assets[path];
if (!asset) {
throw new Error(`Server asset '${path}' does not exist.`);
}
return asset();
}
/**
* Retrieves and caches the content of 'index.server.html'.
*
* @returns A promise that resolves to the content of 'index.server.html'.
* @throws Error If there is an issue retrieving the asset.
*/
getIndexServerHtml(): Promise<string> {
return this.getServerAsset('index.server.html');
}
}

View File

@ -6,8 +6,9 @@
* found in the LICENSE file at https://angular.dev/license
*/
import { ApplicationRef, Type } from '@angular/core';
import type { AngularServerApp } from './app';
import type { SerializableRouteTreeNode } from './routes/route-tree';
import { AngularBootstrap } from './utils/ng';
/**
* Manifest for the Angular server application engine, defining entry points.
@ -15,16 +16,19 @@ import type { AngularServerApp } from './app';
export interface AngularAppEngineManifest {
/**
* A map of entry points for the server application.
* Each entry consists of:
* - `key`: The base href.
* Each entry in the map consists of:
* - `key`: The base href for the entry point.
* - `value`: A function that returns a promise resolving to an object containing the `AngularServerApp` type.
*/
entryPoints: Map<string, () => Promise<{ AngularServerApp: typeof AngularServerApp }>>;
readonly entryPoints: Readonly<
Map<string, () => Promise<{ AngularServerApp: typeof AngularServerApp }>>
>;
/**
* The base path for the server application.
* This is used to determine the root path of the application.
*/
basePath: string;
readonly basePath: string;
}
/**
@ -33,33 +37,42 @@ export interface AngularAppEngineManifest {
export interface AngularAppManifest {
/**
* A record of assets required by the server application.
* Each entry consists of:
* Each entry in the record consists of:
* - `key`: The path of the asset.
* - `value`: A function returning a promise that resolves to the file contents.
* - `value`: A function returning a promise that resolves to the file contents of the asset.
*/
assets: Record<string, () => Promise<string>>;
readonly assets: Readonly<Record<string, () => Promise<string>>>;
/**
* The bootstrap mechanism for the server application.
* A function that returns a reference to an NgModule or a function returning a promise that resolves to an ApplicationRef.
*/
bootstrap: () => Type<unknown> | (() => Promise<ApplicationRef>);
readonly bootstrap: () => AngularBootstrap;
/**
* Indicates whether critical CSS should be inlined.
* Indicates whether critical CSS should be inlined into the HTML.
* If set to `true`, critical CSS will be inlined for faster page rendering.
*/
inlineCriticalCss?: boolean;
readonly inlineCriticalCss?: boolean;
/**
* The route tree representation for the routing configuration of the application.
* This represents the routing information of the application, mapping route paths to their corresponding metadata.
* It is used for route matching and navigation within the server application.
*/
readonly routes?: SerializableRouteTreeNode;
}
/**
* Angular app manifest object.
* The Angular app manifest object.
* This is used internally to store the current Angular app manifest.
*/
let angularAppManifest: AngularAppManifest | undefined;
/**
* Sets the Angular app manifest.
*
* @param manifest - The manifest object to set.
* @param manifest - The manifest object to set for the Angular application.
*/
export function setAngularAppManifest(manifest: AngularAppManifest): void {
angularAppManifest = manifest;
@ -74,7 +87,7 @@ export function setAngularAppManifest(manifest: AngularAppManifest): void {
export function getAngularAppManifest(): AngularAppManifest {
if (!angularAppManifest) {
throw new Error(
'Angular app manifest is not set.' +
'Angular app manifest is not set. ' +
`Please ensure you are using the '@angular/build:application' builder to build your server application.`,
);
}
@ -83,7 +96,8 @@ export function getAngularAppManifest(): AngularAppManifest {
}
/**
* Angular app engine manifest object.
* The Angular app engine manifest object.
* This is used internally to store the current Angular app engine manifest.
*/
let angularAppEngineManifest: AngularAppEngineManifest | undefined;
@ -105,7 +119,7 @@ export function setAngularAppEngineManifest(manifest: AngularAppEngineManifest):
export function getAngularAppEngineManifest(): AngularAppEngineManifest {
if (!angularAppEngineManifest) {
throw new Error(
'Angular app engine manifest is not set.' +
'Angular app engine manifest is not set. ' +
`Please ensure you are using the '@angular/build:application' builder to build your server application.`,
);
}

View File

@ -11,7 +11,7 @@ import { ɵSERVER_CONTEXT as SERVER_CONTEXT } from '@angular/platform-server';
import type { AngularServerApp } from './app';
import { Console } from './console';
import { REQUEST, REQUEST_CONTEXT, RESPONSE_INIT } from './tokens';
import { renderAngular } from './utils';
import { renderAngular } from './utils/ng';
/**
* Enum representing the different contexts in which server rendering can occur.
@ -82,23 +82,14 @@ export async function render(
});
}
let html = await app.getServerAsset('index.server.html');
let html = await app.assets.getIndexServerHtml();
// Skip extra microtask if there are no pre hooks.
if (hooks.has('html:transform:pre')) {
html = await hooks.run('html:transform:pre', { html });
}
let url = request.url;
// A request to `http://www.example.com/page/index.html` will render the Angular route corresponding to `http://www.example.com/page`.
if (url.includes('/index.html')) {
const urlToModify = new URL(url);
urlToModify.pathname = urlToModify.pathname.replace(/index\.html$/, '');
url = urlToModify.toString();
}
return new Response(
await renderAngular(html, manifest.bootstrap(), url, platformProviders),
await renderAngular(html, manifest.bootstrap(), new URL(request.url), platformProviders),
responseInit,
);
}

View File

@ -0,0 +1,239 @@
/**
* @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,
createPlatformFactory,
platformCore,
ɵwhenStable as whenStable,
ɵConsole,
ɵresetCompiledComponents,
} from '@angular/core';
import {
INITIAL_CONFIG,
ɵINTERNAL_SERVER_PLATFORM_PROVIDERS as INTERNAL_SERVER_PLATFORM_PROVIDERS,
} from '@angular/platform-server';
import { Route, Router, ɵloadChildren as loadChildrenHelper } from '@angular/router';
import { Console } from '../console';
import { AngularBootstrap, isNgModule } from '../utils/ng';
import { joinUrlParts } from '../utils/url';
/**
* 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 async iterator that yields `RouteResult` objects.
*
* Each `RouteResult` represents a route and its associated information, such as the path
* and any potential redirection target.
*/
routes: AsyncIterableIterator<RouteResult>;
}
/**
* Represents the result of processing a route.
*/
interface RouteResult {
/**
* The resolved path of the route.
*
* This string represents the complete URL path for the route after it has been
* resolved, including any parent routes or path segments that have been joined.
*/
route: string;
/**
* The target path for route redirection, if applicable.
*
* If this route has a `redirectTo` property in the configuration, this field will
* contain the full resolved URL path that the route should redirect to.
*/
redirectTo?: string;
}
/**
* Recursively traverses the Angular router configuration to retrieve routes.
*
* Iterates through the router configuration, yielding each route along with its potential
* redirection or error status. Handles nested routes and lazy-loaded child routes.
*
* @param options - An object containing the parameters for traversing routes.
* @returns An async iterator yielding `RouteResult` objects.
*/
async function* traverseRoutesConfig(options: {
/** The array of route configurations to process. */
routes: Route[];
/** The Angular compiler used to compile route modules. */
compiler: Compiler;
/** The parent injector for lazy-loaded modules. */
parentInjector: Injector;
/** The parent route path to prefix child routes. */
parentRoute: string;
}): AsyncIterableIterator<RouteResult> {
const { routes, compiler, parentInjector, parentRoute } = options;
for (const route of routes) {
const { path = '', redirectTo, loadChildren, children } = route;
const currentRoutePath = joinUrlParts(parentRoute, path);
yield {
route: currentRoutePath,
redirectTo:
typeof redirectTo === 'string'
? resolveRedirectTo(currentRoutePath, redirectTo)
: undefined,
};
if (children?.length) {
// Recursively process child routes.
yield* traverseRoutesConfig({
routes: children,
compiler,
parentInjector,
parentRoute: currentRoutePath,
});
}
if (loadChildren) {
// Load and process lazy-loaded child routes.
const loadedChildRoutes = await loadChildrenHelper(
route,
compiler,
parentInjector,
).toPromise();
if (loadedChildRoutes) {
const { routes: childRoutes, injector = parentInjector } = loadedChildRoutes;
yield* traverseRoutesConfig({
routes: childRoutes,
compiler,
parentInjector: injector,
parentRoute: currentRoutePath,
});
}
}
}
}
/**
* 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);
}
/**
* 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 `RouteResult` objects.
*
* @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.
* 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
* @returns A promise that resolves to an object of type `AngularRouterConfigResult`.
*/
export async function getRoutesFromAngularRouterConfig(
bootstrap: AngularBootstrap,
document: string,
url: URL,
): Promise<AngularRouterConfigResult> {
// Need to clean up GENERATED_COMP_IDS map in `@angular/core`.
// Otherwise an incorrect component ID generation collision detected warning will be displayed in development.
// See: https://github.com/angular/angular-cli/issues/25924
ɵresetCompiledComponents();
const { protocol, host } = url;
// Create and initialize the Angular platform for server-side rendering.
const platformRef = createPlatformFactory(platformCore, 'server', [
{
provide: INITIAL_CONFIG,
useValue: { document, url: `${protocol}//${host}/` },
},
{
provide: ɵConsole,
useFactory: () => new Console(),
},
...INTERNAL_SERVER_PLATFORM_PROVIDERS,
])();
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 baseHref =
injector.get(APP_BASE_HREF, null, { optional: true }) ??
injector.get(PlatformLocation).getBaseHrefFromDOM();
if (router.config.length === 0) {
// No routes found in the configuration.
return { baseHref, routes: (async function* () {})() };
} else {
const compiler = injector.get(Compiler);
// Retrieve all routes from the Angular router configuration.
return {
baseHref,
routes: traverseRoutesConfig({
routes: router.config,
compiler,
parentInjector: injector,
parentRoute: '',
}),
};
}
} finally {
platformRef.destroy();
}
}

View File

@ -0,0 +1,296 @@
/**
* @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 { stripTrailingSlash } from '../utils/url';
/**
* Represents the serialized format of a route tree as an array of node metadata objects.
* Each entry in the array corresponds to a specific node's metadata within the route tree.
*/
export type SerializableRouteTreeNode = ReadonlyArray<RouteTreeNodeMetadata>;
/**
* Represents metadata for a route tree node, excluding the 'route' path segment.
*/
export type RouteTreeNodeMetadataWithoutRoute = Omit<RouteTreeNodeMetadata, 'route'>;
/**
* Describes metadata associated with a node in the route tree.
* This metadata includes information such as the route path and optional redirect instructions.
*/
export interface RouteTreeNodeMetadata {
/**
* Optional redirect path associated with this node.
* This defines where to redirect if this route is matched.
*/
redirectTo?: string;
/**
* The route path for this node.
*
* A "route" is a URL path or pattern that is used to navigate to different parts of a web application.
* It is made up of one or more segments separated by slashes `/`. For instance, in the URL `/products/details/42`,
* the full route is `/products/details/42`, with segments `products`, `details`, and `42`.
*
* Routes define how URLs map to views or components in an application. Each route segment contributes to
* the overall path that determines which view or component is displayed.
*
* - **Static Routes**: These routes have fixed segments. For example, `/about` or `/contact`.
* - **Parameterized Routes**: These include dynamic segments that act as placeholders, such as `/users/:id`,
* where `:id` could be any user ID.
*
* In the context of `RouteTreeNodeMetadata`, the `route` property represents the complete path that this node
* in the route tree corresponds to. This path is used to determine how a specific URL in the browser maps to the
* structure and content of the application.
*/
route: string;
}
/**
* Represents a node within the route tree structure.
* Each node corresponds to a route segment and may have associated metadata and child nodes.
*/
interface RouteTreeNode {
/**
* The segment value associated with this node.
* A segment is a single part of a route path, typically delimited by slashes (`/`).
* For example, in the route `/users/:id/profile`, the segments are `users`, `:id`, and `profile`.
* Segments can also be wildcards (`*`), which match any segment in that position of the route.
*/
segment: string;
/**
* The index indicating the order in which the route was inserted into the tree.
* This index helps determine the priority of routes during matching, with lower indexes
* indicating earlier inserted routes.
*/
insertionIndex: number;
/**
* A map of child nodes, keyed by their corresponding route segment or wildcard.
*/
children: Map<string, RouteTreeNode>;
/**
* Optional metadata associated with this node, providing additional information such as redirects.
*/
metadata?: RouteTreeNodeMetadata;
}
/**
* 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.
*/
export class RouteTree {
/**
* The root node of the route tree.
* All routes are stored and accessed relative to this root node.
*/
private readonly root = this.createEmptyRouteTreeNode('');
/**
* A counter that tracks the order of route insertion.
* This ensures that routes are matched in the order they were defined,
* with earlier routes taking precedence.
*/
private insertionIndexCounter = 0;
/**
* 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: string, metadata: RouteTreeNodeMetadataWithoutRoute): void {
let node = this.root;
const normalizedRoute = stripTrailingSlash(route);
const segments = normalizedRoute.split('/');
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(normalizedSegment);
node.children.set(normalizedSegment, childNode);
}
node = childNode;
}
// At the leaf node, store the full route and its associated metadata
node.metadata = {
...metadata,
route: normalizedRoute,
};
node.insertionIndex = this.insertionIndexCounter++;
}
/**
* 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: string): RouteTreeNodeMetadata | undefined {
const segments = stripTrailingSlash(route).split('/');
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(): SerializableRouteTreeNode {
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: SerializableRouteTreeNode): RouteTree {
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.
*/
private *traverse(node = this.root): Generator<RouteTreeNodeMetadata> {
if (node.metadata) {
yield node.metadata;
}
for (const childNode of node.children.values()) {
yield* this.traverse(childNode);
}
}
/**
* 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 remainingSegments - The remaining segments of the route path to match.
* @param node - The current node in the route tree to start traversal from.
*
* @returns The node that best matches the remaining segments or `undefined` if no match is found.
*/
private traverseBySegments(
remainingSegments: string[] | undefined,
node = this.root,
): RouteTreeNode | undefined {
const { metadata, children } = node;
// If there are no remaining segments and the node has metadata, return this node
if (!remainingSegments?.length) {
if (metadata) {
return node;
}
return;
}
// If the node has no children, end the traversal
if (!children.size) {
return;
}
const [segment, ...restSegments] = remainingSegments;
let currentBestMatchNode: RouteTreeNode | undefined;
// 1. Exact segment match
const exactMatchNode = node.children.get(segment);
currentBestMatchNode = this.getHigherPriorityNode(
currentBestMatchNode,
this.traverseBySegments(restSegments, exactMatchNode),
);
// 2. Wildcard segment match (`*`)
const wildcardNode = node.children.get('*');
currentBestMatchNode = this.getHigherPriorityNode(
currentBestMatchNode,
this.traverseBySegments(restSegments, wildcardNode),
);
// 3. Deep wildcard segment match (`**`)
const deepWildcardNode = node.children.get('**');
currentBestMatchNode = this.getHigherPriorityNode(currentBestMatchNode, deepWildcardNode);
return currentBestMatchNode;
}
/**
* Compares two nodes and returns the node with higher priority based on insertion index.
* A node with a lower insertion index is prioritized as it was defined earlier.
*
* @param currentBestMatchNode - The current best match node.
* @param candidateNode - The node being evaluated for higher priority based on insertion index.
* @returns The node with higher priority (i.e., lower insertion index). If one of the nodes is `undefined`, the other node is returned.
*/
private getHigherPriorityNode(
currentBestMatchNode: RouteTreeNode | undefined,
candidateNode: RouteTreeNode | undefined,
): RouteTreeNode | undefined {
if (!candidateNode) {
return currentBestMatchNode;
}
if (!currentBestMatchNode) {
return candidateNode;
}
return candidateNode.insertionIndex < currentBestMatchNode.insertionIndex
? candidateNode
: currentBestMatchNode;
}
/**
* Creates an empty route tree node with the specified segment.
* This helper function is used during the tree construction.
*
* @param segment - The route segment that this node represents.
* @returns A new, empty route tree node.
*/
private createEmptyRouteTreeNode(segment: string): RouteTreeNode {
return {
segment,
insertionIndex: -1,
children: new Map(),
};
}
}

View File

@ -0,0 +1,101 @@
/**
* @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 { ServerAssets } from '../assets';
import { AngularAppManifest } from '../manifest';
import { joinUrlParts, stripIndexHtmlFromURL } from '../utils/url';
import { getRoutesFromAngularRouterConfig } from './ng-routes';
import { RouteTree, RouteTreeNodeMetadata } from './route-tree';
/**
* Manages the application's server routing logic by building and maintaining a route tree.
*
* This class is responsible for constructing the route tree from the Angular application
* configuration and using it to match incoming requests to the appropriate routes.
*/
export class ServerRouter {
/**
* Creates an instance of the `ServerRouter`.
*
* @param routeTree - An instance of `RouteTree` that holds the routing information.
* The `RouteTree` is used to match request URLs to the appropriate route metadata.
*/
private constructor(private readonly routeTree: RouteTree) {}
/**
* Static property to track the ongoing build promise.
*/
static #extractionPromise: Promise<ServerRouter> | undefined;
/**
* Creates or retrieves a `ServerRouter` instance based on the provided manifest and URL.
*
* If the manifest contains pre-built routes, a new `ServerRouter` is immediately created.
* Otherwise, it builds the router by extracting routes from the Angular configuration
* asynchronously. This method ensures that concurrent builds are prevented by re-using
* the same promise.
*
* @param manifest - An instance of `AngularAppManifest` that contains the route information.
* @param url - The URL for server-side rendering. The URL is needed to configure `ServerPlatformLocation`.
* This is necessary to ensure that API requests for relative paths succeed, which is crucial for correct route extraction.
* [Reference](https://github.com/angular/angular/blob/d608b857c689d17a7ffa33bbb510301014d24a17/packages/platform-server/src/location.ts#L51)
* @returns A promise resolving to a `ServerRouter` instance.
*/
static from(manifest: AngularAppManifest, url: URL): Promise<ServerRouter> {
if (manifest.routes) {
const routeTree = RouteTree.fromObject(manifest.routes);
return Promise.resolve(new ServerRouter(routeTree));
}
// Create and store a new promise for the build process.
// This prevents concurrent builds by re-using the same promise.
ServerRouter.#extractionPromise ??= (async () => {
try {
const routeTree = new RouteTree();
const document = await new ServerAssets(manifest).getIndexServerHtml();
const { baseHref, routes } = await getRoutesFromAngularRouterConfig(
manifest.bootstrap(),
document,
url,
);
for await (let { route, redirectTo } of routes) {
route = joinUrlParts(baseHref, route);
redirectTo = redirectTo === undefined ? undefined : joinUrlParts(baseHref, redirectTo);
routeTree.insert(route, { redirectTo });
}
return new ServerRouter(routeTree);
} finally {
ServerRouter.#extractionPromise = undefined;
}
})();
return ServerRouter.#extractionPromise;
}
/**
* Matches a request URL against the route tree to retrieve route metadata.
*
* This method strips 'index.html' from the URL if it is present and then attempts
* to find a match in the route tree. If a match is found, it returns the associated
* route metadata; otherwise, it returns `undefined`.
*
* @param url - The URL to be matched against the route tree.
* @returns The metadata for the matched route or `undefined` if no match is found.
*/
match(url: URL): RouteTreeNodeMetadata | undefined {
// Strip 'index.html' from URL if present.
// A request to `http://www.example.com/page/index.html` will render the Angular route corresponding to `http://www.example.com/page`.
const { pathname } = stripIndexHtmlFromURL(url);
return this.routeTree.match(decodeURIComponent(pathname));
}
}

View File

@ -8,6 +8,16 @@
import type { ApplicationRef, StaticProvider, Type } from '@angular/core';
import { renderApplication, renderModule } from '@angular/platform-server';
import { stripIndexHtmlFromURL } from './url';
/**
* Represents the bootstrap mechanism for an Angular application.
*
* This type can either be:
* - A reference to an Angular component or module (`Type<unknown>`) that serves as the root of the application.
* - A function that returns a `Promise<ApplicationRef>`, which resolves with the root application reference.
*/
export type AngularBootstrap = Type<unknown> | (() => Promise<ApplicationRef>);
/**
* Renders an Angular application or module to an HTML string.
@ -27,14 +37,21 @@ import { renderApplication, renderModule } from '@angular/platform-server';
*/
export function renderAngular(
html: string,
bootstrap: Type<unknown> | (() => Promise<ApplicationRef>),
url: string,
bootstrap: AngularBootstrap,
url: URL,
platformProviders: StaticProvider[],
): Promise<string> {
// 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();
return isNgModule(bootstrap)
? renderModule(bootstrap, { url, document: html, extraProviders: platformProviders })
? renderModule(bootstrap, {
url: urlToRender,
document: html,
extraProviders: platformProviders,
})
: renderApplication(bootstrap, {
url,
url: urlToRender,
document: html,
platformProviders,
});
@ -48,8 +65,6 @@ export function renderAngular(
* @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.
*/
export function isNgModule(
value: Type<unknown> | (() => Promise<ApplicationRef>),
): value is Type<unknown> {
export function isNgModule(value: AngularBootstrap): value is Type<unknown> {
return typeof value === 'object' && 'ɵmod' in value;
}

View File

@ -0,0 +1,93 @@
/**
* @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
*/
/**
* 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'
* ```
*/
export function stripTrailingSlash(url: string): string {
// Check if the last character of the URL is a slash
return url[url.length - 1] === '/' ? url.slice(0, -1) : 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'
* ```
*/
export function joinUrlParts(...parts: string[]): string {
// Initialize an array with an empty string to always add a leading slash
const normalizeParts: string[] = [''];
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 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'
* ```
*/
export function stripIndexHtmlFromURL(url: URL): 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;
}

View File

@ -5,6 +5,7 @@ load("//tools:defaults.bzl", "ts_library")
ESM_TESTS = [
"app_spec.ts",
"app-engine_spec.ts",
"routes/router_spec.ts",
]
ts_library(

View File

@ -11,7 +11,7 @@ import 'zone.js/node';
import '@angular/compiler';
/* eslint-enable import/no-unassigned-import */
import { Component } from '@angular/core';
import { Component, ɵresetCompiledComponents } from '@angular/core';
import { AngularServerApp } from '../src/app';
import { AngularAppEngine } from '../src/app-engine';
import { setAngularAppEngineManifest } from '../src/manifest';
@ -21,6 +21,13 @@ describe('AngularAppEngine', () => {
let appEngine: AngularAppEngine;
describe('Localized app', () => {
beforeEach(() => {
// Need to clean up GENERATED_COMP_IDS map in `@angular/core`.
// Otherwise an incorrect component ID generation collision detected warning will be displayed.
// See: https://github.com/angular/angular-cli/issues/25924
ɵresetCompiledComponents();
});
beforeAll(() => {
setAngularAppEngineManifest({
// Note: Although we are testing only one locale, we need to configure two or more
@ -31,7 +38,7 @@ describe('AngularAppEngine', () => {
async () => {
@Component({
standalone: true,
selector: 'app-home',
selector: `app-home-${locale}`,
template: `Home works ${locale.toUpperCase()}`,
})
class HomeComponent {}
@ -72,9 +79,7 @@ describe('AngularAppEngine', () => {
expect(await response?.text()).toContain('Home works IT');
});
// TODO: (Angular will render this as it will render all routes even unknown routes)
// ERROR RuntimeError: NG04002: Cannot match any routes. URL Segment: 'unknown/page'
xit('should return null for requests to unknown pages in a locale', async () => {
it('should return null for requests to unknown pages in a locale', async () => {
const request = new Request('https://example.com/it/unknown/page');
const response = await appEngine.render(request);
expect(response).toBeNull();
@ -88,6 +93,13 @@ describe('AngularAppEngine', () => {
});
describe('Non-localized app', () => {
beforeEach(() => {
// Need to clean up GENERATED_COMP_IDS map in `@angular/core`.
// Otherwise an incorrect component ID generation collision detected warning will be displayed.
// See: https://github.com/angular/angular-cli/issues/25924
ɵresetCompiledComponents();
});
beforeAll(() => {
setAngularAppEngineManifest({
entryPoints: new Map([
@ -119,9 +131,7 @@ describe('AngularAppEngine', () => {
expect(response).toBeNull();
});
// TODO: (Angular will render this as it will render all routes even unknown routes)
// ERROR RuntimeError: NG04002: Cannot match any routes. URL Segment: 'unknown/page'
xit('should return null for requests to unknown pages', async () => {
it('should return null for requests to unknown pages', async () => {
const request = new Request('https://example.com/unknown/page');
const response = await appEngine.render(request);
expect(response).toBeNull();

View File

@ -11,7 +11,7 @@ import 'zone.js/node';
import '@angular/compiler';
/* eslint-enable import/no-unassigned-import */
import { Component } from '@angular/core';
import { Component, ɵresetCompiledComponents } from '@angular/core';
import { AngularServerApp } from '../src/app';
import { ServerRenderContext } from '../src/render';
import { setAngularAppTestingManifest } from './testing-utils';
@ -19,6 +19,13 @@ import { setAngularAppTestingManifest } from './testing-utils';
describe('AngularServerApp', () => {
let app: AngularServerApp;
beforeEach(() => {
// Need to clean up GENERATED_COMP_IDS map in `@angular/core`.
// Otherwise an incorrect component ID generation collision detected warning will be displayed.
// See: https://github.com/angular/angular-cli/issues/25924
ɵresetCompiledComponents();
});
beforeAll(() => {
@Component({
standalone: true,
@ -27,7 +34,12 @@ describe('AngularServerApp', () => {
})
class HomeComponent {}
setAngularAppTestingManifest([{ path: 'home', component: HomeComponent }]);
setAngularAppTestingManifest([
{ path: 'home', component: HomeComponent },
{ path: 'redirect', redirectTo: 'home' },
{ path: 'redirect/relative', redirectTo: 'home' },
{ path: 'redirect/absolute', redirectTo: '/home' },
]);
app = new AngularServerApp({
isDevMode: true,
@ -37,7 +49,7 @@ describe('AngularServerApp', () => {
describe('render', () => {
it(`should include 'ng-server-context="ssr"' by default`, async () => {
const response = await app.render(new Request('http://localhost/home'));
expect(await response.text()).toContain('ng-server-context="ssr"');
expect(await response?.text()).toContain('ng-server-context="ssr"');
});
it(`should include the provided 'ng-server-context' value`, async () => {
@ -46,32 +58,35 @@ describe('AngularServerApp', () => {
undefined,
ServerRenderContext.SSG,
);
expect(await response.text()).toContain('ng-server-context="ssg"');
expect(await response?.text()).toContain('ng-server-context="ssg"');
});
it('should correctly render the content for the requested page', async () => {
const response = await app.render(new Request('http://localhost/home'));
expect(await response.text()).toContain('Home works');
expect(await response?.text()).toContain('Home works');
});
it(`should correctly render the content when the URL ends with 'index.html'`, async () => {
const response = await app.render(new Request('http://localhost/home/index.html'));
expect(await response.text()).toContain('Home works');
});
});
describe('getServerAsset', () => {
it('should return the content of an existing asset', async () => {
const content = await app.getServerAsset('index.server.html');
expect(content).toContain('<html>');
expect(await response?.text()).toContain('Home works');
});
it('should throw an error if the asset does not exist', async () => {
await expectAsync(app.getServerAsset('nonexistent.html')).toBeRejectedWith(
jasmine.objectContaining({
message: jasmine.stringMatching(`Server asset 'nonexistent.html' does not exist`),
}),
);
it('should correctly handle top level redirects', async () => {
const response = await app.render(new Request('http://localhost/redirect'));
expect(response?.headers.get('location')).toContain('http://localhost/home');
expect(response?.status).toBe(302);
});
it('should correctly handle relative nested redirects', async () => {
const response = await app.render(new Request('http://localhost/redirect/relative'));
expect(response?.headers.get('location')).toContain('http://localhost/redirect/home');
expect(response?.status).toBe(302);
});
it('should correctly handle absoloute nested redirects', async () => {
const response = await app.render(new Request('http://localhost/redirect/absolute'));
expect(response?.headers.get('location')).toContain('http://localhost/home');
expect(response?.status).toBe(302);
});
});
});

View File

@ -0,0 +1,40 @@
/**
* @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 { ServerAssets } from '../src/assets';
import { AngularAppManifest } from '../src/manifest';
describe('ServerAsset', () => {
let assetManager: ServerAssets;
beforeAll(() => {
assetManager = new ServerAssets({
bootstrap: undefined as never,
assets: {
'index.server.html': async () => '<html>Index</html>',
'index.other.html': async () => '<html>Other</html>',
},
});
});
it('should retrieve and cache the content of index.server.html', async () => {
const content = await assetManager.getIndexServerHtml();
expect(content).toBe('<html>Index</html>');
});
it('should throw an error if the asset path does not exist', async () => {
await expectAsync(assetManager.getServerAsset('nonexistent.html')).toBeRejectedWithError(
"Server asset 'nonexistent.html' does not exist.",
);
});
it('should retrieve the content of index.other.html', async () => {
const content = await assetManager.getServerAsset('index.other.html');
expect(content).toBe('<html>Other</html>');
});
});

View File

@ -0,0 +1,191 @@
/**
* @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 { RouteTree } from '../../src/routes/route-tree';
describe('RouteTree', () => {
let routeTree: RouteTree;
beforeEach(() => {
routeTree = new RouteTree();
});
describe('toObject and fromObject', () => {
it('should convert the route tree to a nested object and back', () => {
routeTree.insert('/home', { redirectTo: '/home-page' });
routeTree.insert('/about', { redirectTo: '/about-page' });
routeTree.insert('/products/:id', {});
routeTree.insert('/api/details', { redirectTo: '/api/details-page' });
const routeTreeObj = routeTree.toObject();
expect(routeTreeObj).toEqual([
{ redirectTo: '/home-page', route: '/home' },
{ redirectTo: '/about-page', route: '/about' },
{ route: '/products/:id' },
{ redirectTo: '/api/details-page', route: '/api/details' },
]);
const newRouteTree = RouteTree.fromObject(routeTreeObj);
expect(newRouteTree.match('/home')).toEqual({ redirectTo: '/home-page', route: '/home' });
expect(newRouteTree.match('/about')).toEqual({ redirectTo: '/about-page', route: '/about' });
expect(newRouteTree.match('/products/123')).toEqual({ route: '/products/:id' });
expect(newRouteTree.match('/api/details')).toEqual({
redirectTo: '/api/details-page',
route: '/api/details',
});
});
it('should handle complex route structures when converting to and from object', () => {
routeTree.insert('/shop/categories/:category/products/:id', { redirectTo: '/shop/products' });
routeTree.insert('/shop/cart', { redirectTo: '/shop/cart-page' });
const routeTreeObj = routeTree.toObject();
const newRouteTree = RouteTree.fromObject(routeTreeObj);
expect(newRouteTree.match('/shop/categories/electronics/products/123')).toEqual({
redirectTo: '/shop/products',
route: '/shop/categories/:category/products/:id',
});
expect(newRouteTree.match('/shop/cart')).toEqual({
redirectTo: '/shop/cart-page',
route: '/shop/cart',
});
});
it('should construct a RouteTree from a nested object representation', () => {
const routeTreeObj = [
{ redirectTo: '/home-page', route: '/home' },
{ redirectTo: '/about-page', route: '/about' },
{
redirectTo: '/api/details-page',
route: '/api/*/details',
},
];
const newRouteTree = RouteTree.fromObject(routeTreeObj);
expect(newRouteTree.match('/home')).toEqual({ redirectTo: '/home-page', route: '/home' });
expect(newRouteTree.match('/about')).toEqual({ redirectTo: '/about-page', route: '/about' });
expect(newRouteTree.match('/api/users/details')).toEqual({
redirectTo: '/api/details-page',
route: '/api/*/details',
});
expect(newRouteTree.match('/nonexistent')).toBeUndefined();
});
it('should handle an empty RouteTree correctly', () => {
const routeTreeObj = routeTree.toObject();
expect(routeTreeObj).toEqual([]);
const newRouteTree = RouteTree.fromObject(routeTreeObj);
expect(newRouteTree.match('/any-path')).toBeUndefined();
});
it('should preserve insertion order when converting to and from object', () => {
routeTree.insert('/first', {});
routeTree.insert('/:id', {});
routeTree.insert('/second', {});
const routeTreeObj = routeTree.toObject();
expect(routeTreeObj).toEqual([{ route: '/first' }, { route: '/:id' }, { route: '/second' }]);
const newRouteTree = RouteTree.fromObject(routeTreeObj);
expect(newRouteTree.match('/first')).toEqual({ route: '/first' });
expect(newRouteTree.match('/second')).toEqual({ route: '/:id' });
expect(newRouteTree.match('/third')).toEqual({ route: '/:id' });
});
});
describe('match', () => {
it('should handle empty routes', () => {
routeTree.insert('', {});
expect(routeTree.match('')).toEqual({ route: '' });
});
it('should insert and match basic routes', () => {
routeTree.insert('/home', {});
routeTree.insert('/about', {});
expect(routeTree.match('/home')).toEqual({ route: '/home' });
expect(routeTree.match('/about')).toEqual({ route: '/about' });
expect(routeTree.match('/contact')).toBeUndefined();
});
it('should handle wildcard segments', () => {
routeTree.insert('/api/users', {});
routeTree.insert('/api/products', {});
routeTree.insert('/api/*/details', {});
expect(routeTree.match('/api/users')).toEqual({ route: '/api/users' });
expect(routeTree.match('/api/products')).toEqual({ route: '/api/products' });
expect(routeTree.match('/api/orders/details')).toEqual({ route: '/api/*/details' });
});
it('should handle catch all (double wildcard) segments', () => {
routeTree.insert('/api/users', {});
routeTree.insert('/api/*/users/**', {});
routeTree.insert('/api/**', {});
expect(routeTree.match('/api/users')).toEqual({ route: '/api/users' });
expect(routeTree.match('/api/products')).toEqual({ route: '/api/**' });
expect(routeTree.match('/api/info/users/details')).toEqual({ route: '/api/*/users/**' });
expect(routeTree.match('/api/user/details')).toEqual({ route: '/api/**' });
});
it('should prioritize earlier insertions in case of conflicts', () => {
routeTree.insert('/blog/*', {});
routeTree.insert('/blog/article', { redirectTo: 'blog' });
expect(routeTree.match('/blog/article')).toEqual({ route: '/blog/*' });
});
it('should handle parameterized segments as wildcards', () => {
routeTree.insert('/users/:id', {});
expect(routeTree.match('/users/123')).toEqual({ route: '/users/:id' });
});
it('should handle complex route structures', () => {
routeTree.insert('/shop/categories/:category', {});
routeTree.insert('/shop/categories/:category/products/:id', {});
expect(routeTree.match('/shop/categories/electronics')).toEqual({
route: '/shop/categories/:category',
});
expect(routeTree.match('/shop/categories/electronics/products/456')).toEqual({
route: '/shop/categories/:category/products/:id',
});
});
it('should return undefined for unmatched routes', () => {
routeTree.insert('/foo', {});
expect(routeTree.match('/bar')).toBeUndefined();
});
it('should handle multiple wildcards in a path', () => {
routeTree.insert('/a/*/b/*/c', {});
expect(routeTree.match('/a/1/b/2/c')).toEqual({ route: '/a/*/b/*/c' });
});
it('should handle trailing slashes', () => {
routeTree.insert('/foo/', {});
expect(routeTree.match('/foo')).toEqual({ route: '/foo' });
expect(routeTree.match('/foo/')).toEqual({ route: '/foo' });
});
it('should handle case-sensitive matching', () => {
routeTree.insert('/case', {});
expect(routeTree.match('/CASE')).toBeUndefined();
});
it('should handle routes with special characters', () => {
routeTree.insert('/path with spaces', {});
routeTree.insert('/path/with/slashes', {});
expect(routeTree.match('/path with spaces')).toEqual({ route: '/path with spaces' });
expect(routeTree.match('/path/with/slashes')).toEqual({ route: '/path/with/slashes' });
});
});
});

View File

@ -0,0 +1,93 @@
/**
* @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 { Component } from '@angular/core';
import { AngularAppManifest, getAngularAppManifest } from '../../src/manifest';
import { ServerRouter } from '../../src/routes/router';
import { setAngularAppTestingManifest } from '../testing-utils';
describe('ServerRouter', () => {
let router: ServerRouter;
let manifest: AngularAppManifest;
beforeAll(() => {
@Component({
standalone: true,
selector: 'app-dummy',
template: `dummy works`,
})
class DummyComponent {}
setAngularAppTestingManifest([
{ path: 'home', component: DummyComponent },
{ path: 'redirect', redirectTo: 'home' },
{ path: 'encoding url', component: DummyComponent },
{ path: 'user/:id', component: DummyComponent },
]);
manifest = getAngularAppManifest();
});
describe('from', () => {
it('should build the route tree', async () => {
router = await ServerRouter.from(manifest, new URL('http://localhost'));
// Check that routes are correctly built
expect(router.match(new URL('http://localhost/home'))).toEqual({
route: '/home',
redirectTo: undefined,
});
expect(router.match(new URL('http://localhost/redirect'))).toEqual({
redirectTo: '/home',
route: '/redirect',
});
expect(router.match(new URL('http://localhost/user/123'))).toEqual({
route: '/user/:id',
redirectTo: undefined,
});
});
it('should return the existing promise if a build from is already in progress', () => {
const promise1 = ServerRouter.from(manifest, new URL('http://localhost'));
const promise2 = ServerRouter.from(manifest, new URL('http://localhost'));
expect(promise1).toBe(promise2); // Ensure both promises are the same
});
});
describe('match', () => {
beforeAll(async () => {
router = await ServerRouter.from(manifest, new URL('http://localhost'));
});
it('should match a URL to the route tree metadata', () => {
const homeMetadata = router.match(new URL('http://localhost/home'));
const redirectMetadata = router.match(new URL('http://localhost/redirect'));
const userMetadata = router.match(new URL('http://localhost/user/123'));
expect(homeMetadata).toEqual({ route: '/home', redirectTo: undefined });
expect(redirectMetadata).toEqual({ redirectTo: '/home', route: '/redirect' });
expect(userMetadata).toEqual({ route: '/user/:id', redirectTo: undefined });
});
it('should correctly match URLs ending with /index.html', () => {
const homeMetadata = router.match(new URL('http://localhost/home/index.html'));
const userMetadata = router.match(new URL('http://localhost/user/123/index.html'));
const redirectMetadata = router.match(new URL('http://localhost/redirect/index.html'));
expect(homeMetadata).toEqual({ route: '/home', redirectTo: undefined });
expect(redirectMetadata).toEqual({ redirectTo: '/home', route: '/redirect' });
expect(userMetadata).toEqual({ route: '/user/:id', redirectTo: undefined });
});
it('should handle encoded URLs', () => {
const encodedUserMetadata = router.match(new URL('http://localhost/encoding%20url'));
expect(encodedUserMetadata).toEqual({ route: '/encoding url', redirectTo: undefined });
});
});
});

View File

@ -29,7 +29,7 @@ export function setAngularAppTestingManifest(routes: Routes, baseHref = ''): voi
`
<html>
<head>
<base href="/${baseHref}/" />
<base href="/${baseHref}" />
</head>
<body>
<app-root></app-root>

View File

@ -0,0 +1,93 @@
/**
* @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 { joinUrlParts, stripIndexHtmlFromURL, stripTrailingSlash } from '../../src/utils/url'; // Adjust the import path as needed
describe('URL Utils', () => {
describe('stripTrailingSlash', () => {
it('should remove trailing slash from URL', () => {
expect(stripTrailingSlash('/path/')).toBe('/path');
});
it('should not modify URL if no trailing slash is present', () => {
expect(stripTrailingSlash('/path')).toBe('/path');
});
it('should handle empty URL', () => {
expect(stripTrailingSlash('')).toBe('');
});
it('should handle URL with only a trailing slash', () => {
expect(stripTrailingSlash('/')).toBe('');
});
});
describe('joinUrlParts', () => {
it('should join multiple URL parts with normalized slashes', () => {
expect(joinUrlParts('', 'path/', '/to/resource')).toBe('/path/to/resource');
});
it('should handle URL parts with leading and trailing slashes', () => {
expect(joinUrlParts('/', '/path/', 'to/resource/')).toBe('/path/to/resource');
});
it('should handle empty URL parts', () => {
expect(joinUrlParts('', '', 'path', '', 'to/resource')).toBe('/path/to/resource');
});
});
describe('stripIndexHtmlFromURL', () => {
it('should remove /index.html from the end of the URL path', () => {
const url = new URL('http://www.example.com/page/index.html');
const result = stripIndexHtmlFromURL(url);
expect(result.href).toBe('http://www.example.com/page');
});
it('should not modify the URL if /index.html is not present', () => {
const url = new URL('http://www.example.com/page');
const result = stripIndexHtmlFromURL(url);
expect(result.href).toBe('http://www.example.com/page');
});
it('should handle URLs without a path', () => {
const url = new URL('http://www.example.com/index.html');
const result = stripIndexHtmlFromURL(url);
expect(result.href).toBe('http://www.example.com/');
});
it('should not modify the URL if /index.html is in the middle of the path', () => {
const url = new URL('http://www.example.com/index.html/page');
const result = stripIndexHtmlFromURL(url);
expect(result.href).toBe('http://www.example.com/index.html/page');
});
it('should handle URLs with query parameters and /index.html at the end', () => {
const url = new URL('http://www.example.com/page/index.html?query=123');
const result = stripIndexHtmlFromURL(url);
expect(result.href).toBe('http://www.example.com/page?query=123');
});
it('should handle URLs with a fragment and /index.html at the end', () => {
const url = new URL('http://www.example.com/page/index.html#section');
const result = stripIndexHtmlFromURL(url);
expect(result.href).toBe('http://www.example.com/page#section');
});
it('should handle URLs with both query parameters and fragments and /index.html at the end', () => {
const url = new URL('http://www.example.com/page/index.html?query=123#section');
const result = stripIndexHtmlFromURL(url);
expect(result.href).toBe('http://www.example.com/page?query=123#section');
});
it('should handle URLs with HTTPS scheme and /index.html at the end', () => {
const url = new URL('https://www.example.com/page/index.html');
const result = stripIndexHtmlFromURL(url);
expect(result.href).toBe('https://www.example.com/page');
});
});
});

View File

@ -566,20 +566,6 @@ __metadata:
languageName: node
linkType: hard
"@angular/compiler@npm:18.2.0-next.2":
version: 18.2.0-next.2
resolution: "@angular/compiler@npm:18.2.0-next.2"
dependencies:
tslib: "npm:^2.3.0"
peerDependencies:
"@angular/core": 18.2.0-next.2
peerDependenciesMeta:
"@angular/core":
optional: true
checksum: 10c0/be26bbe2ec041f1dd353c8ea6eba861367592f71d09e1d12932a5ccf76cc1423124b626baf90a551061a849508e8e6bdc235166f43152edd52dcdbf5232d1e26
languageName: node
linkType: hard
"@angular/compiler@npm:18.2.0-rc.0":
version: 18.2.0-rc.0
resolution: "@angular/compiler@npm:18.2.0-rc.0"
@ -883,22 +869,6 @@ __metadata:
languageName: node
linkType: hard
"@angular/platform-browser@npm:18.2.0-next.2":
version: 18.2.0-next.2
resolution: "@angular/platform-browser@npm:18.2.0-next.2"
dependencies:
tslib: "npm:^2.3.0"
peerDependencies:
"@angular/animations": 18.2.0-next.2
"@angular/common": 18.2.0-next.2
"@angular/core": 18.2.0-next.2
peerDependenciesMeta:
"@angular/animations":
optional: true
checksum: 10c0/065f6205b56a4e4c4e324145708aef1fe0875e4109082f207fb6029ab52bc36685f8c2daf0d5fbe82545b8e1c2fe9ba2625c80f0a59c262bca12a7c45713c32c
languageName: node
linkType: hard
"@angular/platform-browser@npm:18.2.0-rc.0":
version: 18.2.0-rc.0
resolution: "@angular/platform-browser@npm:18.2.0-rc.0"
@ -915,22 +885,6 @@ __metadata:
languageName: node
linkType: hard
"@angular/platform-server@npm:18.2.0-next.2":
version: 18.2.0-next.2
resolution: "@angular/platform-server@npm:18.2.0-next.2"
dependencies:
tslib: "npm:^2.3.0"
xhr2: "npm:^0.2.0"
peerDependencies:
"@angular/animations": 18.2.0-next.2
"@angular/common": 18.2.0-next.2
"@angular/compiler": 18.2.0-next.2
"@angular/core": 18.2.0-next.2
"@angular/platform-browser": 18.2.0-next.2
checksum: 10c0/df599c894f14eca1021898d828ef0bb000f438c6a0ad767a9aceb669f4e491eb3fed9dddecaba936b576ea3b7c072403f4937482f039d260578c7ba8da7e2ade
languageName: node
linkType: hard
"@angular/platform-server@npm:18.2.0-rc.0":
version: 18.2.0-rc.0
resolution: "@angular/platform-server@npm:18.2.0-rc.0"
@ -962,20 +916,6 @@ __metadata:
languageName: unknown
linkType: soft
"@angular/router@npm:18.2.0-next.2":
version: 18.2.0-next.2
resolution: "@angular/router@npm:18.2.0-next.2"
dependencies:
tslib: "npm:^2.3.0"
peerDependencies:
"@angular/common": 18.2.0-next.2
"@angular/core": 18.2.0-next.2
"@angular/platform-browser": 18.2.0-next.2
rxjs: ^6.5.3 || ^7.4.0
checksum: 10c0/ed93e907dc3108e6ad51b1de58f3d574cafb2abf55b9865b9d5c847efd7effad833d7716dab4a5973a4106caf82ebbd1c0be8a27eb7d289eaee5670df4c14db5
languageName: node
linkType: hard
"@angular/router@npm:18.2.0-rc.0":
version: 18.2.0-rc.0
resolution: "@angular/router@npm:18.2.0-rc.0"
@ -1008,17 +948,19 @@ __metadata:
version: 0.0.0-use.local
resolution: "@angular/ssr@workspace:packages/angular/ssr"
dependencies:
"@angular/compiler": "npm:18.2.0-next.2"
"@angular/platform-browser": "npm:18.2.0-next.2"
"@angular/platform-server": "npm:18.2.0-next.2"
"@angular/router": "npm:18.2.0-next.2"
"@angular/common": "npm:18.2.0-rc.0"
"@angular/compiler": "npm:18.2.0-rc.0"
"@angular/core": "npm:18.2.0-rc.0"
"@angular/platform-browser": "npm:18.2.0-rc.0"
"@angular/platform-server": "npm:18.2.0-rc.0"
"@angular/router": "npm:18.2.0-rc.0"
critters: "npm:0.0.24"
mrmime: "npm:2.0.0"
tslib: "npm:^2.3.0"
zone.js: "npm:^0.14.0"
peerDependencies:
"@angular/common": ^18.0.0 || ^18.2.0-next.0
"@angular/core": ^18.0.0 || ^18.2.0-next.0
"@angular/router": ^18.0.0 || ^18.2.0-next.0
languageName: unknown
linkType: soft