mirror of
https://github.com/angular/angular-cli.git
synced 2025-05-17 02:54:21 +08:00
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:
parent
37693c40e3
commit
bca5683893
@ -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"]
|
||||
]
|
||||
|
@ -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",
|
||||
],
|
||||
)
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
47
packages/angular/ssr/src/assets.ts
Normal file
47
packages/angular/ssr/src/assets.ts
Normal 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');
|
||||
}
|
||||
}
|
@ -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.`,
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
239
packages/angular/ssr/src/routes/ng-routes.ts
Normal file
239
packages/angular/ssr/src/routes/ng-routes.ts
Normal 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();
|
||||
}
|
||||
}
|
296
packages/angular/ssr/src/routes/route-tree.ts
Normal file
296
packages/angular/ssr/src/routes/route-tree.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
101
packages/angular/ssr/src/routes/router.ts
Normal file
101
packages/angular/ssr/src/routes/router.ts
Normal 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));
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
93
packages/angular/ssr/src/utils/url.ts
Normal file
93
packages/angular/ssr/src/utils/url.ts
Normal 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;
|
||||
}
|
@ -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(
|
||||
|
@ -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();
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
40
packages/angular/ssr/test/assets_spec.ts
Normal file
40
packages/angular/ssr/test/assets_spec.ts
Normal 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>');
|
||||
});
|
||||
});
|
191
packages/angular/ssr/test/routes/route-tree_spec.ts
Normal file
191
packages/angular/ssr/test/routes/route-tree_spec.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
});
|
93
packages/angular/ssr/test/routes/router_spec.ts
Normal file
93
packages/angular/ssr/test/routes/router_spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
@ -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>
|
||||
|
93
packages/angular/ssr/test/utils/url_spec.ts
Normal file
93
packages/angular/ssr/test/utils/url_spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
72
yarn.lock
72
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
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user