diff --git a/goldens/circular-deps/packages.json b/goldens/circular-deps/packages.json index cf73d85041..a0635265fb 100644 --- a/goldens/circular-deps/packages.json +++ b/goldens/circular-deps/packages.json @@ -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"] ] diff --git a/packages/angular/ssr/BUILD.bazel b/packages/angular/ssr/BUILD.bazel index cf4a46bb98..dbd1662aee 100644 --- a/packages/angular/ssr/BUILD.bazel +++ b/packages/angular/ssr/BUILD.bazel @@ -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", ], ) diff --git a/packages/angular/ssr/package.json b/packages/angular/ssr/package.json index 96245fe997..74a2b6aef5 100644 --- a/packages/angular/ssr/package.json +++ b/packages/angular/ssr/package.json @@ -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", diff --git a/packages/angular/ssr/src/app-engine.ts b/packages/angular/ssr/src/app-engine.ts index 6abc6c9b98..3f250e72b3 100644 --- a/packages/angular/ssr/src/app-engine.ts +++ b/packages/angular/ssr/src/app-engine.ts @@ -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 { // 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); -} diff --git a/packages/angular/ssr/src/app.ts b/packages/angular/ssr/src/app.ts index 436393282d..a07a090882 100644 --- a/packages/angular/ssr/src/app.ts +++ b/packages/angular/ssr/src/app.ts @@ -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 { - return render(this, request, serverContext, requestContext); - } + ): Promise { + 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 { - 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); } } diff --git a/packages/angular/ssr/src/assets.ts b/packages/angular/ssr/src/assets.ts new file mode 100644 index 0000000000..3c8542013b --- /dev/null +++ b/packages/angular/ssr/src/assets.ts @@ -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 { + 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 { + return this.getServerAsset('index.server.html'); + } +} diff --git a/packages/angular/ssr/src/manifest.ts b/packages/angular/ssr/src/manifest.ts index 5de9e03ba1..f34586e765 100644 --- a/packages/angular/ssr/src/manifest.ts +++ b/packages/angular/ssr/src/manifest.ts @@ -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 Promise<{ AngularServerApp: typeof AngularServerApp }>>; + readonly entryPoints: Readonly< + Map 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 Promise>; + readonly assets: Readonly Promise>>; /** * 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 | (() => Promise); + 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.`, ); } diff --git a/packages/angular/ssr/src/render.ts b/packages/angular/ssr/src/render.ts index bb4bf1fcb8..2298ac7d54 100644 --- a/packages/angular/ssr/src/render.ts +++ b/packages/angular/ssr/src/render.ts @@ -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, ); } diff --git a/packages/angular/ssr/src/routes/ng-routes.ts b/packages/angular/ssr/src/routes/ng-routes.ts new file mode 100644 index 0000000000..9a635f169a --- /dev/null +++ b/packages/angular/ssr/src/routes/ng-routes.ts @@ -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; +} + +/** + * 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 { + 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 { + // 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(); + } +} diff --git a/packages/angular/ssr/src/routes/route-tree.ts b/packages/angular/ssr/src/routes/route-tree.ts new file mode 100644 index 0000000000..6ad907d6c6 --- /dev/null +++ b/packages/angular/ssr/src/routes/route-tree.ts @@ -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; + +/** + * Represents metadata for a route tree node, excluding the 'route' path segment. + */ +export type RouteTreeNodeMetadataWithoutRoute = Omit; + +/** + * 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; + + /** + * 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 { + 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(), + }; + } +} diff --git a/packages/angular/ssr/src/routes/router.ts b/packages/angular/ssr/src/routes/router.ts new file mode 100644 index 0000000000..c980768fdf --- /dev/null +++ b/packages/angular/ssr/src/routes/router.ts @@ -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 | 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 { + 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)); + } +} diff --git a/packages/angular/ssr/src/utils.ts b/packages/angular/ssr/src/utils/ng.ts similarity index 67% rename from packages/angular/ssr/src/utils.ts rename to packages/angular/ssr/src/utils/ng.ts index aebe1d0e86..8184930620 100644 --- a/packages/angular/ssr/src/utils.ts +++ b/packages/angular/ssr/src/utils/ng.ts @@ -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`) that serves as the root of the application. + * - A function that returns a `Promise`, which resolves with the root application reference. + */ +export type AngularBootstrap = Type | (() => Promise); /** * 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 | (() => Promise), - url: string, + bootstrap: AngularBootstrap, + url: URL, platformProviders: StaticProvider[], ): Promise { + // 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 | (() => Promise), -): value is Type { +export function isNgModule(value: AngularBootstrap): value is Type { return typeof value === 'object' && 'ɵmod' in value; } diff --git a/packages/angular/ssr/src/utils/url.ts b/packages/angular/ssr/src/utils/url.ts new file mode 100644 index 0000000000..9d3d9dc462 --- /dev/null +++ b/packages/angular/ssr/src/utils/url.ts @@ -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; +} diff --git a/packages/angular/ssr/test/BUILD.bazel b/packages/angular/ssr/test/BUILD.bazel index 2353da03ee..abc2a4eefd 100644 --- a/packages/angular/ssr/test/BUILD.bazel +++ b/packages/angular/ssr/test/BUILD.bazel @@ -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( diff --git a/packages/angular/ssr/test/app-engine_spec.ts b/packages/angular/ssr/test/app-engine_spec.ts index 22207640ec..9ccd503161 100644 --- a/packages/angular/ssr/test/app-engine_spec.ts +++ b/packages/angular/ssr/test/app-engine_spec.ts @@ -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(); diff --git a/packages/angular/ssr/test/app_spec.ts b/packages/angular/ssr/test/app_spec.ts index 0eeeb6ada5..2897484b73 100644 --- a/packages/angular/ssr/test/app_spec.ts +++ b/packages/angular/ssr/test/app_spec.ts @@ -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(''); + 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); }); }); }); diff --git a/packages/angular/ssr/test/assets_spec.ts b/packages/angular/ssr/test/assets_spec.ts new file mode 100644 index 0000000000..345e1eb0a8 --- /dev/null +++ b/packages/angular/ssr/test/assets_spec.ts @@ -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 () => 'Index', + 'index.other.html': async () => 'Other', + }, + }); + }); + + it('should retrieve and cache the content of index.server.html', async () => { + const content = await assetManager.getIndexServerHtml(); + expect(content).toBe('Index'); + }); + + 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('Other'); + }); +}); diff --git a/packages/angular/ssr/test/routes/route-tree_spec.ts b/packages/angular/ssr/test/routes/route-tree_spec.ts new file mode 100644 index 0000000000..101f064ee0 --- /dev/null +++ b/packages/angular/ssr/test/routes/route-tree_spec.ts @@ -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' }); + }); + }); +}); diff --git a/packages/angular/ssr/test/routes/router_spec.ts b/packages/angular/ssr/test/routes/router_spec.ts new file mode 100644 index 0000000000..6d2661261d --- /dev/null +++ b/packages/angular/ssr/test/routes/router_spec.ts @@ -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 }); + }); + }); +}); diff --git a/packages/angular/ssr/test/testing-utils.ts b/packages/angular/ssr/test/testing-utils.ts index 23c1e95f4b..f5d1be73dd 100644 --- a/packages/angular/ssr/test/testing-utils.ts +++ b/packages/angular/ssr/test/testing-utils.ts @@ -29,7 +29,7 @@ export function setAngularAppTestingManifest(routes: Routes, baseHref = ''): voi ` - + diff --git a/packages/angular/ssr/test/utils/url_spec.ts b/packages/angular/ssr/test/utils/url_spec.ts new file mode 100644 index 0000000000..a081b064af --- /dev/null +++ b/packages/angular/ssr/test/utils/url_spec.ts @@ -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'); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 38da4d7c7c..0895af1b77 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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