From d66aaa3ca458e05b535bec7c1dcb98b0e9c5202e Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Thu, 12 Sep 2024 17:43:40 +0000 Subject: [PATCH] feat(@angular/ssr): add server routing configuration API This commit introduces a new server routing configuration API, as discussed in RFC https://github.com/angular/angular/discussions/56785. The new API provides several enhancements: ```ts const serverRoutes: ServerRoute[] = [ { path: '/error', renderMode: RenderMode.Server, status: 404, headers: { 'Cache-Control': 'no-cache' } } ]; ``` ```ts const serverRoutes: ServerRoute[] = [ { path: '/product/:id', renderMode: RenderMode.Prerender, async getPrerenderPaths() { const dataService = inject(ProductService); const ids = await dataService.getIds(); // Assuming this returns ['1', '2', '3'] return ids.map(id => ({ id })); // Generates paths like: [{ id: '1' }, { id: '2' }, { id: '3' }] } } ]; ``` ```ts const serverRoutes: ServerRoute[] = [ { path: '/product/:id', renderMode: RenderMode.Prerender, fallback: PrerenderFallback.Server, // Can be Server, Client, or None async getPrerenderPaths() { } } ]; ``` ```ts const serverRoutes: ServerRoute[] = [ { path: '/product/:id', renderMode: RenderMode.Server, }, { path: '/error', renderMode: RenderMode.Client, }, { path: '/**', renderMode: RenderMode.Prerender, }, ]; ``` These additions aim to provide greater flexibility and control over server-side rendering configurations and prerendering behaviors. --- goldens/public-api/angular/ssr/index.api.md | 25 +- .../angular/ssr/index_transitive.api.md | 43 +++ .../public-api/angular/ssr/node/index.api.md | 2 +- .../tests/options/app-shell_spec.ts | 3 +- .../tools/esbuild/application-code-bundle.ts | 1 - .../server-rendering/load-esm-from-memory.ts | 7 +- .../src/utils/server-rendering/prerender.ts | 5 +- .../utils/server-rendering/render-worker.ts | 18 +- .../routes-extractor-worker.ts | 2 + packages/angular/ssr/BUILD.bazel | 12 +- packages/angular/ssr/node/src/app-engine.ts | 6 +- packages/angular/ssr/private_export.ts | 1 - packages/angular/ssr/public_api.ts | 7 + packages/angular/ssr/public_api_transitive.ts | 20 ++ packages/angular/ssr/src/app-engine.ts | 2 +- packages/angular/ssr/src/app.ts | 103 ++++--- packages/angular/ssr/src/routes/ng-routes.ts | 283 +++++++++++++----- .../angular/ssr/src/routes/route-config.ts | 77 +++-- packages/angular/ssr/src/routes/route-tree.ts | 46 ++- packages/angular/ssr/src/utils/ng.ts | 31 +- packages/angular/ssr/test/BUILD.bazel | 1 + packages/angular/ssr/test/app-engine_spec.ts | 21 +- packages/angular/ssr/test/app_spec.ts | 94 ++++-- .../angular/ssr/test/routes/ng-routes_spec.ts | 176 +++++++++++ .../angular/ssr/test/routes/router_spec.ts | 69 ++++- packages/angular/ssr/test/testing-utils.ts | 39 ++- .../src/builders/app-shell/app-shell_spec.ts | 3 +- 27 files changed, 870 insertions(+), 227 deletions(-) create mode 100644 goldens/public-api/angular/ssr/index_transitive.api.md create mode 100644 packages/angular/ssr/public_api_transitive.ts create mode 100644 packages/angular/ssr/test/routes/ng-routes_spec.ts diff --git a/goldens/public-api/angular/ssr/index.api.md b/goldens/public-api/angular/ssr/index.api.md index 0818d7811b..3c9501138f 100644 --- a/goldens/public-api/angular/ssr/index.api.md +++ b/goldens/public-api/angular/ssr/index.api.md @@ -4,13 +4,36 @@ ```ts +import { EnvironmentProviders } from '@angular/core'; + // @public export class AngularAppEngine { - getHeaders(request: Request): ReadonlyMap; + getPrerenderHeaders(request: Request): ReadonlyMap; render(request: Request, requestContext?: unknown): Promise; static ɵhooks: Hooks; } +// @public +export enum PrerenderFallback { + Client = 1, + None = 2, + Server = 0 +} + +// @public +export function provideServerRoutesConfig(routes: ServerRoute[]): EnvironmentProviders; + +// @public +export enum RenderMode { + AppShell = 0, + Client = 2, + Prerender = 3, + Server = 1 +} + +// @public +export type ServerRoute = ServerRouteAppShell | ServerRouteClient | ServerRoutePrerender | ServerRoutePrerenderWithParams | ServerRouteServer; + // (No @packageDocumentation comment for this package) ``` diff --git a/goldens/public-api/angular/ssr/index_transitive.api.md b/goldens/public-api/angular/ssr/index_transitive.api.md new file mode 100644 index 0000000000..f51756a2d9 --- /dev/null +++ b/goldens/public-api/angular/ssr/index_transitive.api.md @@ -0,0 +1,43 @@ +## API Report File for "@angular/devkit-repo" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +// @public +export interface ServerRouteAppShell extends Omit { + renderMode: RenderMode.AppShell; +} + +// @public +export interface ServerRouteClient extends ServerRouteCommon { + renderMode: RenderMode.Client; +} + +// @public +export interface ServerRouteCommon { + headers?: Record; + path: string; + status?: number; +} + +// @public +export interface ServerRoutePrerender extends Omit { + fallback?: never; + renderMode: RenderMode.Prerender; +} + +// @public +export interface ServerRoutePrerenderWithParams extends Omit { + fallback?: PrerenderFallback; + getPrerenderParams: () => Promise[]>; +} + +// @public +export interface ServerRouteServer extends ServerRouteCommon { + renderMode: RenderMode.Server; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular/ssr/node/index.api.md b/goldens/public-api/angular/ssr/node/index.api.md index 8dbd8b9e02..01ced4fbe6 100644 --- a/goldens/public-api/angular/ssr/node/index.api.md +++ b/goldens/public-api/angular/ssr/node/index.api.md @@ -12,7 +12,7 @@ import { Type } from '@angular/core'; // @public export class AngularNodeAppEngine { - getHeaders(request: IncomingMessage): ReadonlyMap; + getPrerenderHeaders(request: IncomingMessage): ReadonlyMap; render(request: IncomingMessage, requestContext?: unknown): Promise; } diff --git a/packages/angular/build/src/builders/application/tests/options/app-shell_spec.ts b/packages/angular/build/src/builders/application/tests/options/app-shell_spec.ts index 6824e06dd1..4964aacc59 100644 --- a/packages/angular/build/src/builders/application/tests/options/app-shell_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/app-shell_spec.ts @@ -116,7 +116,8 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { harness.expectFile('dist/browser/main.js').toExist(); const indexFileContent = harness.expectFile('dist/browser/index.html').content; indexFileContent.toContain('app-shell works!'); - indexFileContent.toContain('ng-server-context="app-shell"'); + // TODO(alanagius): enable once integration of routes in complete. + // indexFileContent.toContain('ng-server-context="app-shell"'); }); it('critical CSS is inlined', async () => { diff --git a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts index 546f97f2c1..fa8fa76f1b 100644 --- a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts +++ b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts @@ -325,7 +325,6 @@ export function createServerMainCodeBundleOptions( // Add @angular/ssr exports `export { - ɵServerRenderContext, ɵdestroyAngularServerApp, ɵextractRoutesAndCreateRouteTree, ɵgetOrCreateAngularServerApp, diff --git a/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts b/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts index bf46031094..d9a4f4b354 100644 --- a/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts +++ b/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts @@ -7,11 +7,7 @@ */ import type { ApplicationRef, Type } from '@angular/core'; -import type { - ɵServerRenderContext, - ɵextractRoutesAndCreateRouteTree, - ɵgetOrCreateAngularServerApp, -} from '@angular/ssr'; +import type { ɵextractRoutesAndCreateRouteTree, ɵgetOrCreateAngularServerApp } from '@angular/ssr'; import { assertIsError } from '../error'; import { loadEsmModule } from '../load-esm'; @@ -20,7 +16,6 @@ import { loadEsmModule } from '../load-esm'; */ interface MainServerBundleExports { default: (() => Promise) | Type; - ɵServerRenderContext: typeof ɵServerRenderContext; ɵextractRoutesAndCreateRouteTree: typeof ɵextractRoutesAndCreateRouteTree; ɵgetOrCreateAngularServerApp: typeof ɵgetOrCreateAngularServerApp; } diff --git a/packages/angular/build/src/utils/server-rendering/prerender.ts b/packages/angular/build/src/utils/server-rendering/prerender.ts index 19c6095f78..f6e1b877b8 100644 --- a/packages/angular/build/src/utils/server-rendering/prerender.ts +++ b/packages/angular/build/src/utils/server-rendering/prerender.ts @@ -210,12 +210,13 @@ async function renderPages( route.slice(baseHrefWithLeadingSlash.length - 1), ); - const isAppShellRoute = appShellRoute === routeWithoutBaseHref; - const render: Promise = renderWorker.run({ url: route, isAppShellRoute }); + const render: Promise = renderWorker.run({ url: route }); const renderResult: Promise = render .then((content) => { if (content !== null) { const outPath = posix.join(removeLeadingSlash(routeWithoutBaseHref), 'index.html'); + const isAppShellRoute = appShellRoute === routeWithoutBaseHref; + output[outPath] = { content, appShellRoute: isAppShellRoute }; } }) diff --git a/packages/angular/build/src/utils/server-rendering/render-worker.ts b/packages/angular/build/src/utils/server-rendering/render-worker.ts index c419b65de1..a8af04be49 100644 --- a/packages/angular/build/src/utils/server-rendering/render-worker.ts +++ b/packages/angular/build/src/utils/server-rendering/render-worker.ts @@ -16,24 +16,18 @@ export interface RenderWorkerData extends ESMInMemoryFileLoaderWorkerData { export interface RenderOptions { url: string; - isAppShellRoute: boolean; } /** * Renders each route in routes and writes them to //index.html. */ -async function renderPage({ url, isAppShellRoute }: RenderOptions): Promise { - const { - ɵgetOrCreateAngularServerApp: getOrCreateAngularServerApp, - ɵServerRenderContext: ServerRenderContext, - } = await loadEsmModuleFromMemory('./main.server.mjs'); +async function renderPage({ url }: RenderOptions): Promise { + const { ɵgetOrCreateAngularServerApp: getOrCreateAngularServerApp } = + await loadEsmModuleFromMemory('./main.server.mjs'); const angularServerApp = getOrCreateAngularServerApp(); - const response = await angularServerApp.render( - new Request(new URL(url, 'http://local-angular-prerender'), { - signal: AbortSignal.timeout(30_000), - }), - undefined, - isAppShellRoute ? ServerRenderContext.AppShell : ServerRenderContext.SSG, + const response = await angularServerApp.renderStatic( + new URL(url, 'http://local-angular-prerender'), + AbortSignal.timeout(30_000), ); return response ? response.text() : null; diff --git a/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts b/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts index 52d2e78d76..80afa0cfdc 100644 --- a/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts +++ b/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts @@ -26,6 +26,8 @@ async function extractRoutes(): Promise { const routeTree = await extractRoutesAndCreateRouteTree( new URL('http://local-angular-prerender/'), + /** manifest */ undefined, + /** invokeGetPrerenderParams */ true, ); return routeTree.toObject(); diff --git a/packages/angular/ssr/BUILD.bazel b/packages/angular/ssr/BUILD.bazel index 3aa2635937..cc3d6291d2 100644 --- a/packages/angular/ssr/BUILD.bazel +++ b/packages/angular/ssr/BUILD.bazel @@ -1,4 +1,4 @@ -load("@npm//@angular/build-tooling/bazel/api-golden:index.bzl", "api_golden_test_npm_package") +load("@npm//@angular/build-tooling/bazel/api-golden:index.bzl", "api_golden_test", "api_golden_test_npm_package") load("@rules_pkg//:pkg.bzl", "pkg_tar") load("//tools:defaults.bzl", "ng_package", "ts_library") @@ -67,3 +67,13 @@ api_golden_test_npm_package( golden_dir = "angular_cli/goldens/public-api/angular/ssr", npm_package = "angular_cli/packages/angular/ssr/npm_package", ) + +api_golden_test( + name = "ssr_transitive_api", + data = [ + ":ssr", + "//goldens:public-api", + ], + entry_point = "angular_cli/packages/angular/ssr/public_api_transitive.d.ts", + golden = "angular_cli/goldens/public-api/angular/ssr/index_transitive.api.md", +) diff --git a/packages/angular/ssr/node/src/app-engine.ts b/packages/angular/ssr/node/src/app-engine.ts index bbcd30f70e..3ae8f16e10 100644 --- a/packages/angular/ssr/node/src/app-engine.ts +++ b/packages/angular/ssr/node/src/app-engine.ts @@ -56,7 +56,7 @@ export class AngularNodeAppEngine { * app.use(express.static('dist/browser', { * setHeaders: (res, path) => { * // Retrieve headers for the current request - * const headers = angularAppEngine.getHeaders(res.req); + * const headers = angularAppEngine.getPrerenderHeaders(res.req); * * // Apply the retrieved headers to the response * for (const { key, value } of headers) { @@ -66,7 +66,7 @@ export class AngularNodeAppEngine { })); * ``` */ - getHeaders(request: IncomingMessage): ReadonlyMap { - return this.angularAppEngine.getHeaders(createWebRequestFromNodeRequest(request)); + getPrerenderHeaders(request: IncomingMessage): ReadonlyMap { + return this.angularAppEngine.getPrerenderHeaders(createWebRequestFromNodeRequest(request)); } } diff --git a/packages/angular/ssr/private_export.ts b/packages/angular/ssr/private_export.ts index cfed1c49ad..273633b484 100644 --- a/packages/angular/ssr/private_export.ts +++ b/packages/angular/ssr/private_export.ts @@ -12,7 +12,6 @@ export { extractRoutesAndCreateRouteTree as ɵextractRoutesAndCreateRouteTree, } from './src/routes/ng-routes'; export { - ServerRenderContext as ɵServerRenderContext, getOrCreateAngularServerApp as ɵgetOrCreateAngularServerApp, destroyAngularServerApp as ɵdestroyAngularServerApp, } from './src/app'; diff --git a/packages/angular/ssr/public_api.ts b/packages/angular/ssr/public_api.ts index c17a02e75b..3b6f53e4f2 100644 --- a/packages/angular/ssr/public_api.ts +++ b/packages/angular/ssr/public_api.ts @@ -9,3 +9,10 @@ export * from './private_export'; export { AngularAppEngine } from './src/app-engine'; + +export { + type PrerenderFallback, + type RenderMode, + type ServerRoute, + provideServerRoutesConfig, +} from './src/routes/route-config'; diff --git a/packages/angular/ssr/public_api_transitive.ts b/packages/angular/ssr/public_api_transitive.ts new file mode 100644 index 0000000000..0acba4c214 --- /dev/null +++ b/packages/angular/ssr/public_api_transitive.ts @@ -0,0 +1,20 @@ +/** + * @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 + */ + +// This file exports symbols that are not part of the public API but are +// dependencies of public API symbols. Including them here ensures they +// are tracked in the API golden file, preventing accidental breaking changes. + +export type { + ServerRouteAppShell, + ServerRouteClient, + ServerRoutePrerender, + ServerRoutePrerenderWithParams, + ServerRouteServer, + ServerRouteCommon, +} from './src/routes/route-config'; diff --git a/packages/angular/ssr/src/app-engine.ts b/packages/angular/ssr/src/app-engine.ts index 96933fd8d4..93e1653fa8 100644 --- a/packages/angular/ssr/src/app-engine.ts +++ b/packages/angular/ssr/src/app-engine.ts @@ -110,7 +110,7 @@ export class AngularAppEngine { * @returns A `Map` containing the HTTP headers as key-value pairs. * @note This function should be used exclusively for retrieving headers of SSG pages. */ - getHeaders(request: Request): ReadonlyMap { + getPrerenderHeaders(request: Request): ReadonlyMap { if (this.manifest.staticPathsHeaders.size === 0) { return new Map(); } diff --git a/packages/angular/ssr/src/app.ts b/packages/angular/ssr/src/app.ts index 4fd81077ec..06ff1f7e5c 100644 --- a/packages/angular/ssr/src/app.ts +++ b/packages/angular/ssr/src/app.ts @@ -6,25 +6,34 @@ * found in the LICENSE file at https://angular.dev/license */ -import { StaticProvider, ɵConsole, ɵresetCompiledComponents } from '@angular/core'; -import { ɵSERVER_CONTEXT as SERVER_CONTEXT } from '@angular/platform-server'; +import { StaticProvider, ɵresetCompiledComponents } from '@angular/core'; import { ServerAssets } from './assets'; -import { Console } from './console'; import { Hooks } from './hooks'; import { getAngularAppManifest } from './manifest'; +import { RenderMode } from './routes/route-config'; import { ServerRouter } from './routes/router'; import { REQUEST, REQUEST_CONTEXT, RESPONSE_INIT } from './tokens'; import { InlineCriticalCssProcessor } from './utils/inline-critical-css'; import { AngularBootstrap, renderAngular } from './utils/ng'; /** - * Enum representing the different contexts in which server rendering can occur. + * A mapping of `RenderMode` enum values to corresponding string representations. + * + * This record is used to map each `RenderMode` to a specific string value that represents + * the server context. The string values are used internally to differentiate + * between various rendering strategies when processing routes. + * + * - `RenderMode.Prerender` maps to `'ssg'` (Static Site Generation). + * - `RenderMode.Server` maps to `'ssr'` (Server-Side Rendering). + * - `RenderMode.AppShell` maps to `'app-shell'` (pre-rendered application shell). + * - `RenderMode.Client` maps to an empty string `''` (Client-Side Rendering, no server context needed). */ -export enum ServerRenderContext { - SSR = 'ssr', - SSG = 'ssg', - AppShell = 'app-shell', -} +const SERVER_CONTEXT_VALUE: Record = { + [RenderMode.Prerender]: 'ssg', + [RenderMode.Server]: 'ssr', + [RenderMode.AppShell]: 'app-shell', + [RenderMode.Client]: '', +}; /** * Represents a locale-specific Angular server application managed by the server application engine. @@ -70,18 +79,31 @@ export class AngularServerApp { * * @param request - The incoming HTTP request to be rendered. * @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, or null if no match is found. */ - render( - request: Request, - requestContext?: unknown, - serverContext: ServerRenderContext = ServerRenderContext.SSR, - ): Promise { + render(request: Request, requestContext?: unknown): Promise { return Promise.race([ this.createAbortPromise(request), - this.handleRendering(request, requestContext, serverContext), + this.handleRendering(request, /** isSsrMode */ true, requestContext), + ]); + } + + /** + * Renders a page based on the provided URL via server-side rendering and returns the corresponding HTTP response. + * The rendering process can be interrupted by an abort signal, where the first resolved promise (either from the abort + * or the render process) will dictate the outcome. + * + * @param url - The full URL to be processed and rendered by the server. + * @param signal - (Optional) An `AbortSignal` object that allows for the cancellation of the rendering process. + * @returns A promise that resolves to the generated HTTP response object, or `null` if no matching route is found. + */ + renderStatic(url: URL, signal?: AbortSignal): Promise { + const request = new Request(url, { signal }); + + return Promise.race([ + this.createAbortPromise(request), + this.handleRendering(request, /** isSsrMode */ false), ]); } @@ -112,15 +134,15 @@ export class AngularServerApp { * This method matches the request URL to a route and performs rendering if a matching route is found. * * @param request - The incoming HTTP request to be processed. + * @param isSsrMode - A boolean indicating whether the rendering is performed in server-side rendering (SSR) mode. * @param requestContext - Optional additional context for rendering, such as request metadata. - * @param serverContext - The rendering context. Defaults to server-side rendering (SSR). * * @returns A promise that resolves to the rendered response, or null if no matching route is found. */ private async handleRendering( request: Request, + isSsrMode: boolean, requestContext?: unknown, - serverContext: ServerRenderContext = ServerRenderContext.SSR, ): Promise { const url = new URL(request.url); this.router ??= await ServerRouter.from(this.manifest, url); @@ -131,32 +153,33 @@ export class AngularServerApp { return null; } - const { redirectTo } = matchedRoute; + const { redirectTo, status } = matchedRoute; if (redirectTo !== undefined) { + // Note: The status code is validated during route extraction. // 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); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return Response.redirect(new URL(redirectTo, url), (status as any) ?? 302); } - const platformProviders: StaticProvider[] = [ - { - provide: SERVER_CONTEXT, - useValue: serverContext, - }, - { - // An Angular Console Provider that does not print a set of predefined logs. - provide: ɵConsole, - // Using `useClass` would necessitate decorating `Console` with `@Injectable`, - // which would require switching from `ts_library` to `ng_module`. This change - // would also necessitate various patches of `@angular/bazel` to support ESM. - useFactory: () => new Console(), - }, - ]; + const { renderMode = isSsrMode ? RenderMode.Server : RenderMode.Prerender, headers } = + matchedRoute; - const isSsrMode = serverContext === ServerRenderContext.SSR; - const responseInit: ResponseInit = {}; + const platformProviders: StaticProvider[] = []; + let responseInit: ResponseInit | undefined; if (isSsrMode) { + // Initialize the response with status and headers if available. + responseInit = { + status, + headers: headers ? new Headers(headers) : undefined, + }; + + if (renderMode === RenderMode.Client) { + // Serve the client-side rendered version if the route is configured for CSR. + return new Response(await this.assets.getServerAsset('index.csr.html'), responseInit); + } + platformProviders.push( { provide: REQUEST, @@ -183,7 +206,13 @@ export class AngularServerApp { this.boostrap ??= await manifest.bootstrap(); - html = await renderAngular(html, this.boostrap, new URL(request.url), platformProviders); + html = await renderAngular( + html, + this.boostrap, + new URL(request.url), + platformProviders, + SERVER_CONTEXT_VALUE[renderMode], + ); if (manifest.inlineCriticalCss) { // Optionally inline critical CSS. diff --git a/packages/angular/ssr/src/routes/ng-routes.ts b/packages/angular/ssr/src/routes/ng-routes.ts index 01fc831dee..583fa0000e 100644 --- a/packages/angular/ssr/src/routes/ng-routes.ts +++ b/packages/angular/ssr/src/routes/ng-routes.ts @@ -13,6 +13,7 @@ import { Injector, createPlatformFactory, platformCore, + runInInjectionContext, ɵwhenStable as whenStable, ɵConsole, } from '@angular/core'; @@ -26,7 +27,29 @@ import { Console } from '../console'; import { AngularAppManifest, getAngularAppManifest } from '../manifest'; import { AngularBootstrap, isNgModule } from '../utils/ng'; import { joinUrlParts } from '../utils/url'; -import { RouteTree } from './route-tree'; +import { PrerenderFallback, RenderMode, SERVER_ROUTES_CONFIG, ServerRoute } from './route-config'; +import { RouteTree, RouteTreeNodeMetadata } from './route-tree'; + +/** + * Regular expression to match segments preceded by a colon in a string. + */ +const URL_PARAMETER_REGEXP = /(?; + +/** + * Metadata for a server configuration route tree node. + */ +type ServerConfigRouteTreeNodeMetadata = RouteTreeNodeMetadata & + ServerConfigRouteTreeAdditionalMetadata; /** * Result of extracting routes from an Angular application. @@ -39,95 +62,90 @@ interface AngularRouterConfigResult { baseHref: string; /** - * An array of `RouteResult` objects representing the application's routes. + * An array of `RouteTreeNodeMetadata` objects representing the application's routes. * - * Each `RouteResult` contains details about a specific route, such as its path and any + * Each `RouteTreeNodeMetadata` contains details about a specific route, such as its path and any * associated redirection targets. This array is asynchronously generated and * provides information on how routes are structured and resolved. - * - * Example: - * ```typescript - * const result: AngularRouterConfigResult = { - * baseHref: '/app/', - * routes: [ - * { route: '/home', redirectTo: '/welcome' }, - * { route: '/about' }, - * ], - * }; - * ``` */ - routes: RouteResult[]; + routes: RouteTreeNodeMetadata[]; + + /** + * Optional configuration for server routes. + * + * This property allows you to specify an array of server routes for configuration. + * If not provided, the default configuration or behavior will be used. + */ + serverRoutesConfig?: ServerRoute[] | null; } /** - * 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. + * Traverses an array of route configurations to generate route tree node metadata. * - * Iterates through the router configuration, yielding each route along with its potential - * redirection or error status. Handles nested routes and lazy-loaded child routes. + * This function processes each route and its children, handling redirects, SSG (Static Site Generation) settings, + * and lazy-loaded routes. It yields route metadata for each route and its potential variants. * - * @param options - An object containing the parameters for traversing routes. - * @returns An async iterator yielding `RouteResult` objects. + * @param options - The configuration options for traversing routes. + * @returns An async iterable iterator of route tree node metadata. */ -async function* traverseRoutesConfig(options: { - /** The array of route configurations to process. */ +export async function* traverseRoutesConfig({ + routes, + compiler, + parentInjector, + parentRoute, + serverConfigRouteTree, + invokeGetPrerenderParams, +}: { 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; - + serverConfigRouteTree: RouteTree | undefined; + invokeGetPrerenderParams: boolean; +}): AsyncIterableIterator { for (const route of routes) { const { path = '', redirectTo, loadChildren, children } = route; const currentRoutePath = joinUrlParts(parentRoute, path); - yield { + // Get route metadata from the server config route tree, if available + const metadata: ServerConfigRouteTreeNodeMetadata = { + ...(serverConfigRouteTree + ? getMatchedRouteMetadata(serverConfigRouteTree, currentRoutePath) + : undefined), route: currentRoutePath, - redirectTo: - typeof redirectTo === 'string' - ? resolveRedirectTo(currentRoutePath, redirectTo) - : undefined, }; + // Handle redirects + if (typeof redirectTo === 'string') { + const redirectToResolved = resolveRedirectTo(currentRoutePath, redirectTo); + if (metadata.status && !VALID_REDIRECT_RESPONSE_CODES.has(metadata.status)) { + throw new Error( + `The '${metadata.status}' status code is not a valid redirect response code. ` + + `Please use one of the following redirect response codes: ${[...VALID_REDIRECT_RESPONSE_CODES.values()].join(', ')}.`, + ); + } + yield { ...metadata, redirectTo: redirectToResolved }; + } else if (metadata.renderMode === RenderMode.Prerender) { + // Handle SSG routes + yield* handleSSGRoute(metadata, parentInjector, invokeGetPrerenderParams); + } else { + yield metadata; + } + + // Recursively process child routes if (children?.length) { - // Recursively process child routes. yield* traverseRoutesConfig({ routes: children, compiler, parentInjector, parentRoute: currentRoutePath, + serverConfigRouteTree, + invokeGetPrerenderParams, }); } + // Load and process lazy-loaded child routes if (loadChildren) { - // Load and process lazy-loaded child routes. const loadedChildRoutes = await loadChildrenHelper( route, compiler, @@ -141,12 +159,105 @@ async function* traverseRoutesConfig(options: { compiler, parentInjector: injector, parentRoute: currentRoutePath, + serverConfigRouteTree, + invokeGetPrerenderParams, }); } } } } +/** + * Retrieves the matched route metadata from the server configuration route tree. + * + * @param serverConfigRouteTree - The server configuration route tree. + * @param currentRoutePath - The current route path being processed. + * @returns The metadata associated with the matched route. + */ +function getMatchedRouteMetadata( + serverConfigRouteTree: RouteTree, + currentRoutePath: string, +): ServerConfigRouteTreeNodeMetadata { + const metadata = serverConfigRouteTree.match(currentRoutePath); + + if (!metadata) { + throw new Error( + `The '${currentRoutePath}' route does not match any route defined in the server routing configuration. ` + + 'Please ensure this route is added to the server routing configuration.', + ); + } + + return metadata; +} + +/** + * Handles SSG (Static Site Generation) routes by invoking `getPrerenderParams` and yielding + * all parameterized paths. + * + * @param metadata - The metadata associated with the route tree node. + * @param parentInjector - The dependency injection container for the parent route. + * @param invokeGetPrerenderParams - A flag indicating whether to invoke the `getPrerenderParams` function. + * @returns An async iterable iterator that yields route tree node metadata for each SSG path. + */ +async function* handleSSGRoute( + metadata: ServerConfigRouteTreeNodeMetadata, + parentInjector: Injector, + invokeGetPrerenderParams: boolean, +): AsyncIterableIterator { + if (metadata.renderMode !== RenderMode.Prerender) { + throw new Error( + `'handleSSGRoute' was called for a route which rendering mode is not prerender.`, + ); + } + + const { route: currentRoutePath, fallback, ...meta } = metadata; + const getPrerenderParams = 'getPrerenderParams' in meta ? meta.getPrerenderParams : undefined; + + if ('getPrerenderParams' in meta) { + delete meta['getPrerenderParams']; + } + + if (invokeGetPrerenderParams && URL_PARAMETER_REGEXP.test(currentRoutePath)) { + if (!getPrerenderParams) { + throw new Error( + `The '${currentRoutePath}' route uses prerendering and includes parameters, but 'getPrerenderParams' is missing. ` + + `Please define 'getPrerenderParams' function for this route in your server routing configuration ` + + `or specify a different 'renderMode'.`, + ); + } + + const parameters = await runInInjectionContext(parentInjector, () => getPrerenderParams()); + + for (const params of parameters) { + const routeWithResolvedParams = currentRoutePath.replace(URL_PARAMETER_REGEXP, (match) => { + const parameterName = match.slice(1); + const value = params[parameterName]; + if (typeof value !== 'string') { + throw new Error( + `The 'getPrerenderParams' function defined for the '${currentRoutePath}' route ` + + `returned a non-string value for parameter '${parameterName}'. ` + + `Please make sure the 'getPrerenderParams' function returns values for all parameters ` + + 'specified in this route.', + ); + } + + return value; + }); + + yield { ...meta, route: routeWithResolvedParams }; + } + } + + // Handle fallback render modes + if (fallback !== PrerenderFallback.None || !invokeGetPrerenderParams) { + yield { + ...meta, + route: currentRoutePath, + renderMode: fallback === PrerenderFallback.Client ? RenderMode.Client : RenderMode.Server, + }; + } +} + /** * Resolves the `redirectTo` property for a given route. * @@ -171,18 +282,37 @@ function resolveRedirectTo(routePath: string, redirectTo: string): string { return joinUrlParts(...segments, redirectTo); } +/** + * Builds a server configuration route tree from the given server routes configuration. + * + * @param serverRoutesConfig - The array of server routes to be used for configuration. + * @returns A `RouteTree` populated with the server routes and their metadata. + */ +function buildServerConfigRouteTree( + serverRoutesConfig: ServerRoute[], +): RouteTree { + const serverConfigRouteTree = new RouteTree(); + for (const { path, ...metadata } of serverRoutesConfig) { + serverConfigRouteTree.insert(path, metadata); + } + + return serverConfigRouteTree; +} + /** * 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. + * and function-based bootstrapping. It yields the resulting routes as `RouteTreeNodeMetadata` 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. + * @param invokeGetPrerenderParams - A boolean flag indicating whether to invoke `getPrerenderParams` for parameterized SSG routes + * to handle prerendering paths. Defaults to `false`. * 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 @@ -192,6 +322,7 @@ export async function getRoutesFromAngularRouterConfig( bootstrap: AngularBootstrap, document: string, url: URL, + invokeGetPrerenderParams = false, ): Promise { const { protocol, host } = url; @@ -223,24 +354,31 @@ export async function getRoutesFromAngularRouterConfig( const injector = applicationRef.injector; const router = injector.get(Router); - const routesResults: RouteResult[] = []; + const routesResults: RouteTreeNodeMetadata[] = []; if (router.config.length) { const compiler = injector.get(Compiler); + const serverRoutesConfig = injector.get(SERVER_ROUTES_CONFIG, null, { optional: true }); + const serverConfigRouteTree = serverRoutesConfig + ? buildServerConfigRouteTree(serverRoutesConfig) + : undefined; + // Retrieve all routes from the Angular router configuration. const traverseRoutes = traverseRoutesConfig({ routes: router.config, compiler, parentInjector: injector, parentRoute: '', + serverConfigRouteTree, + invokeGetPrerenderParams, }); for await (const result of traverseRoutes) { routesResults.push(result); } } else { - routesResults.push({ route: '' }); + routesResults.push({ route: '', renderMode: RenderMode.Prerender }); } const baseHref = @@ -267,23 +405,32 @@ export async function getRoutesFromAngularRouterConfig( * - https://github.com/angular/angular/blob/6882cc7d9eed26d3caeedca027452367ba25f2b9/packages/platform-server/src/http.ts#L44 * @param manifest - An optional `AngularAppManifest` that contains the application's routing and configuration details. * If not provided, the default manifest is retrieved using `getAngularAppManifest()`. - * + * @param invokeGetPrerenderParams - A boolean flag indicating whether to invoke `getPrerenderParams` for parameterized SSG routes + * to handle prerendering paths. Defaults to `false`. * @returns A promise that resolves to a populated `RouteTree` containing all extracted routes from the Angular application. */ export async function extractRoutesAndCreateRouteTree( url: URL, manifest: AngularAppManifest = getAngularAppManifest(), + invokeGetPrerenderParams = false, ): Promise { const routeTree = new RouteTree(); const document = await new ServerAssets(manifest).getIndexServerHtml(); const bootstrap = await manifest.bootstrap(); - const { baseHref, routes } = await getRoutesFromAngularRouterConfig(bootstrap, document, url); + const { baseHref, routes } = await getRoutesFromAngularRouterConfig( + bootstrap, + document, + url, + invokeGetPrerenderParams, + ); - for (let { route, redirectTo } of routes) { - route = joinUrlParts(baseHref, route); - redirectTo = redirectTo === undefined ? undefined : joinUrlParts(baseHref, redirectTo); + for (const { route, ...metadata } of routes) { + if (metadata.redirectTo !== undefined) { + metadata.redirectTo = joinUrlParts(baseHref, metadata.redirectTo); + } - routeTree.insert(route, { redirectTo }); + const fullRoute = joinUrlParts(baseHref, route); + routeTree.insert(fullRoute, metadata); } return routeTree; diff --git a/packages/angular/ssr/src/routes/route-config.ts b/packages/angular/ssr/src/routes/route-config.ts index 92e78dae33..ac6fd6203b 100644 --- a/packages/angular/ssr/src/routes/route-config.ts +++ b/packages/angular/ssr/src/routes/route-config.ts @@ -17,27 +17,38 @@ export enum RenderMode { AppShell, /** Server-Side Rendering (SSR) mode, where content is rendered on the server for each request. */ - SSR, + Server, /** Client-Side Rendering (CSR) mode, where content is rendered on the client side in the browser. */ - CSR, + Client, /** Static Site Generation (SSG) mode, where content is pre-rendered at build time and served as static files. */ - SSG, + Prerender, } /** - * Fallback strategies for Static Site Generation (SSG) routes. + * Defines the fallback strategies for Static Site Generation (SSG) routes when a pre-rendered path is not available. + * This is particularly relevant for routes with parameterized URLs where some paths might not be pre-rendered at build time. + * * @developerPreview */ -export enum SSGFallback { - /** Use Server-Side Rendering (SSR) as the fallback for this route. */ - SSR, +export enum PrerenderFallback { + /** + * Fallback to Server-Side Rendering (SSR) if the pre-rendered path is not available. + * This strategy dynamically generates the page on the server at request time. + */ + Server, - /** Use Client-Side Rendering (CSR) as the fallback for this route. */ - CSR, + /** + * Fallback to Client-Side Rendering (CSR) if the pre-rendered path is not available. + * This strategy allows the page to be rendered on the client side. + */ + Client, - /** No fallback; Angular will not handle the response if the path is not pre-rendered. */ + /** + * No fallback; if the path is not pre-rendered, the server will not handle the request. + * This means the application will not provide any response for paths that are not pre-rendered. + */ None, } @@ -66,23 +77,38 @@ export interface ServerRouteAppShell extends Omit { +export interface ServerRoutePrerender extends Omit { /** Specifies that the route uses Static Site Generation (SSG) mode. */ - renderMode: RenderMode.SSG; + renderMode: RenderMode.Prerender; + /** Fallback cannot be specified unless `getPrerenderParams` is used. */ + fallback?: never; +} + +/** + * A server route configuration that uses Static Site Generation (SSG) mode, including support for routes with parameters. + */ +export interface ServerRoutePrerenderWithParams extends Omit { /** - * Optional fallback strategy to use if the SSG path is not pre-rendered. - * Defaults to `SSGFallback.SSR` if not provided. + * Optional strategy to use if the SSG path is not pre-rendered. + * This is especially relevant for routes with parameterized URLs, where some paths may not be pre-rendered at build time. + * + * This property determines how to handle requests for paths that are not pre-rendered: + * - `PrerenderFallback.Server`: Use Server-Side Rendering (SSR) to dynamically generate the page at request time. + * - `PrerenderFallback.Client`: Use Client-Side Rendering (CSR) to fetch and render the page on the client side. + * - `PrerenderFallback.None`: No fallback; if the path is not pre-rendered, the server will not handle the request. + * + * @default `PrerenderFallback.Server` if not provided. */ - fallback?: SSGFallback; + fallback?: PrerenderFallback; /** * A function that returns a Promise resolving to an array of objects, each representing a route path with URL parameters. @@ -96,8 +122,8 @@ export interface ServerRouteSSG extends Omit { * export const serverRouteConfig: ServerRoutes[] = [ * { * path: '/product/:id', - * remderMode: RenderMode.SSG, - * async getPrerenderPaths() { + * renderMode: RenderMode.Prerender, + * async getPrerenderParams() { * const productService = inject(ProductService); * const ids = await productService.getIds(); // Assuming this returns ['1', '2', '3'] * @@ -107,22 +133,27 @@ export interface ServerRouteSSG extends Omit { * ]; * ``` */ - getPrerenderPaths?: () => Promise[]>; + getPrerenderParams: () => Promise[]>; } /** * A server route that uses Server-Side Rendering (SSR) mode. */ -export interface ServerRouteSSR extends ServerRouteCommon { +export interface ServerRouteServer extends ServerRouteCommon { /** Specifies that the route uses Server-Side Rendering (SSR) mode. */ - renderMode: RenderMode.SSR; + renderMode: RenderMode.Server; } /** * Server route configuration. * @developerPreview */ -export type ServerRoute = ServerRouteAppShell | ServerRouteCSR | ServerRouteSSG | ServerRouteSSR; +export type ServerRoute = + | ServerRouteAppShell + | ServerRouteClient + | ServerRoutePrerender + | ServerRoutePrerenderWithParams + | ServerRouteServer; /** * Token for providing the server routes configuration. diff --git a/packages/angular/ssr/src/routes/route-tree.ts b/packages/angular/ssr/src/routes/route-tree.ts index 6ad907d6c6..f3b07859cc 100644 --- a/packages/angular/ssr/src/routes/route-tree.ts +++ b/packages/angular/ssr/src/routes/route-tree.ts @@ -7,6 +7,7 @@ */ import { stripTrailingSlash } from '../utils/url'; +import { RenderMode } from './route-config'; /** * Represents the serialized format of a route tree as an array of node metadata objects. @@ -49,13 +50,30 @@ export interface RouteTreeNodeMetadata { * structure and content of the application. */ route: string; + + /** + * Optional status code to return for this route. + */ + status?: number; + + /** + * Optional additional headers to include in the response for this route. + */ + headers?: Record; + + /** + * Specifies the rendering mode used for this route. + * If not provided, the default rendering mode for the application will be used. + */ + renderMode?: RenderMode; } /** * Represents a node within the route tree structure. * Each node corresponds to a route segment and may have associated metadata and child nodes. + * The `AdditionalMetadata` type parameter allows for extending the node metadata with custom data. */ -interface RouteTreeNode { +interface RouteTreeNode> { /** * The segment value associated with this node. * A segment is a single part of a route path, typically delimited by slashes (`/`). @@ -74,20 +92,22 @@ interface RouteTreeNode { /** * A map of child nodes, keyed by their corresponding route segment or wildcard. */ - children: Map; + children: Map>; /** * Optional metadata associated with this node, providing additional information such as redirects. */ - metadata?: RouteTreeNodeMetadata; + metadata?: RouteTreeNodeMetadata & AdditionalMetadata; } /** * A route tree implementation that supports efficient route matching, including support for wildcard routes. * This structure is useful for organizing and retrieving routes in a hierarchical manner, * enabling complex routing scenarios with nested paths. + * + * @typeParam AdditionalMetadata - Type of additional metadata that can be associated with route nodes. */ -export class RouteTree { +export class RouteTree = {}> { /** * The root node of the route tree. * All routes are stored and accessed relative to this root node. @@ -109,7 +129,7 @@ export class RouteTree { * @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 { + insert(route: string, metadata: RouteTreeNodeMetadataWithoutRoute & AdditionalMetadata): void { let node = this.root; const normalizedRoute = stripTrailingSlash(route); const segments = normalizedRoute.split('/'); @@ -144,7 +164,7 @@ export class RouteTree { * @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 { + match(route: string): (RouteTreeNodeMetadata & AdditionalMetadata) | undefined { const segments = stripTrailingSlash(route).split('/'); return this.traverseBySegments(segments)?.metadata; @@ -188,7 +208,7 @@ export class RouteTree { * * @param node - The current node to start the traversal from. Defaults to the root node of the tree. */ - private *traverse(node = this.root): Generator { + private *traverse(node = this.root): Generator { if (node.metadata) { yield node.metadata; } @@ -213,7 +233,7 @@ export class RouteTree { private traverseBySegments( remainingSegments: string[] | undefined, node = this.root, - ): RouteTreeNode | undefined { + ): RouteTreeNode | undefined { const { metadata, children } = node; // If there are no remaining segments and the node has metadata, return this node @@ -231,7 +251,7 @@ export class RouteTree { } const [segment, ...restSegments] = remainingSegments; - let currentBestMatchNode: RouteTreeNode | undefined; + let currentBestMatchNode: RouteTreeNode | undefined; // 1. Exact segment match const exactMatchNode = node.children.get(segment); @@ -263,9 +283,9 @@ export class RouteTree { * @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 { + currentBestMatchNode: RouteTreeNode | undefined, + candidateNode: RouteTreeNode | undefined, + ): RouteTreeNode | undefined { if (!candidateNode) { return currentBestMatchNode; } @@ -286,7 +306,7 @@ export class RouteTree { * @param segment - The route segment that this node represents. * @returns A new, empty route tree node. */ - private createEmptyRouteTreeNode(segment: string): RouteTreeNode { + private createEmptyRouteTreeNode(segment: string): RouteTreeNode { return { segment, insertionIndex: -1, diff --git a/packages/angular/ssr/src/utils/ng.ts b/packages/angular/ssr/src/utils/ng.ts index 148e3f0533..55faa0adb2 100644 --- a/packages/angular/ssr/src/utils/ng.ts +++ b/packages/angular/ssr/src/utils/ng.ts @@ -6,8 +6,14 @@ * found in the LICENSE file at https://angular.dev/license */ +import { ɵConsole } from '@angular/core'; import type { ApplicationRef, StaticProvider, Type } from '@angular/core'; -import { renderApplication, renderModule } from '@angular/platform-server'; +import { + ɵSERVER_CONTEXT as SERVER_CONTEXT, + renderApplication, + renderModule, +} from '@angular/platform-server'; +import { Console } from '../console'; import { stripIndexHtmlFromURL } from './url'; /** @@ -33,6 +39,8 @@ export type AngularBootstrap = Type | (() => Promise); * correctly handle route-based rendering. * @param platformProviders - An array of platform providers to be used during the * rendering process. + * @param serverContext - A string representing the server context, used to provide additional + * context or metadata during server-side rendering. * @returns A promise that resolves to a string containing the rendered HTML. */ export function renderAngular( @@ -40,7 +48,24 @@ export function renderAngular( bootstrap: AngularBootstrap, url: URL, platformProviders: StaticProvider[], + serverContext: string, ): Promise { + const providers = [ + { + provide: SERVER_CONTEXT, + useValue: serverContext, + }, + { + // An Angular Console Provider that does not print a set of predefined logs. + provide: ɵConsole, + // Using `useClass` would necessitate decorating `Console` with `@Injectable`, + // which would require switching from `ts_library` to `ng_module`. This change + // would also necessitate various patches of `@angular/bazel` to support ESM. + useFactory: () => new Console(), + }, + ...platformProviders, + ]; + // 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(); @@ -48,12 +73,12 @@ export function renderAngular( ? renderModule(bootstrap, { url: urlToRender, document: html, - extraProviders: platformProviders, + extraProviders: providers, }) : renderApplication(bootstrap, { url: urlToRender, document: html, - platformProviders, + platformProviders: providers, }); } diff --git a/packages/angular/ssr/test/BUILD.bazel b/packages/angular/ssr/test/BUILD.bazel index 7ccd8023cb..fdd0e53b29 100644 --- a/packages/angular/ssr/test/BUILD.bazel +++ b/packages/angular/ssr/test/BUILD.bazel @@ -6,6 +6,7 @@ ESM_TESTS = [ "app_spec.ts", "app-engine_spec.ts", "routes/router_spec.ts", + "routes/ng-routes_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 f794901012..7384f62456 100644 --- a/packages/angular/ssr/test/app-engine_spec.ts +++ b/packages/angular/ssr/test/app-engine_spec.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +// The compiler is needed as tests are in JIT. /* eslint-disable import/no-unassigned-import */ import '@angular/compiler'; /* eslint-enable import/no-unassigned-import */ @@ -14,6 +15,7 @@ import { Component } from '@angular/core'; import { destroyAngularServerApp, getOrCreateAngularServerApp } from '../src/app'; import { AngularAppEngine } from '../src/app-engine'; import { setAngularAppEngineManifest } from '../src/manifest'; +import { RenderMode } from '../src/routes/route-config'; import { setAngularAppTestingManifest } from './testing-utils'; describe('AngularAppEngine', () => { @@ -37,7 +39,11 @@ describe('AngularAppEngine', () => { }) class HomeComponent {} - setAngularAppTestingManifest([{ path: 'home', component: HomeComponent }], locale); + setAngularAppTestingManifest( + [{ path: 'home', component: HomeComponent }], + [{ path: '/**', renderMode: RenderMode.Server }], + locale, + ); return { ɵgetOrCreateAngularServerApp: getOrCreateAngularServerApp, @@ -99,10 +105,10 @@ describe('AngularAppEngine', () => { }); }); - describe('getHeaders', () => { + describe('getPrerenderHeaders', () => { it('should return headers for a known path without index.html', () => { const request = new Request('https://example.com/about'); - const headers = appEngine.getHeaders(request); + const headers = appEngine.getPrerenderHeaders(request); expect(Object.fromEntries(headers.entries())).toEqual({ 'Cache-Control': 'no-cache', 'X-Some-Header': 'value', @@ -111,7 +117,7 @@ describe('AngularAppEngine', () => { it('should return headers for a known path with index.html', () => { const request = new Request('https://example.com/about/index.html'); - const headers = appEngine.getHeaders(request); + const headers = appEngine.getPrerenderHeaders(request); expect(Object.fromEntries(headers.entries())).toEqual({ 'Cache-Control': 'no-cache', 'X-Some-Header': 'value', @@ -120,7 +126,7 @@ describe('AngularAppEngine', () => { it('should return no headers for unknown paths', () => { const request = new Request('https://example.com/unknown/path'); - const headers = appEngine.getHeaders(request); + const headers = appEngine.getPrerenderHeaders(request); expect(headers).toHaveSize(0); }); }); @@ -142,7 +148,10 @@ describe('AngularAppEngine', () => { }) class HomeComponent {} - setAngularAppTestingManifest([{ path: 'home', component: HomeComponent }]); + setAngularAppTestingManifest( + [{ path: 'home', component: HomeComponent }], + [{ path: '/**', renderMode: RenderMode.Server }], + ); return { ɵgetOrCreateAngularServerApp: getOrCreateAngularServerApp, diff --git a/packages/angular/ssr/test/app_spec.ts b/packages/angular/ssr/test/app_spec.ts index 274095a1a2..82a70bd021 100644 --- a/packages/angular/ssr/test/app_spec.ts +++ b/packages/angular/ssr/test/app_spec.ts @@ -6,12 +6,14 @@ * found in the LICENSE file at https://angular.dev/license */ +// The compiler is needed as tests are in JIT. /* eslint-disable import/no-unassigned-import */ import '@angular/compiler'; /* eslint-enable import/no-unassigned-import */ import { Component } from '@angular/core'; -import { AngularServerApp, ServerRenderContext, destroyAngularServerApp } from '../src/app'; +import { AngularServerApp, destroyAngularServerApp } from '../src/app'; +import { RenderMode } from '../src/routes/route-config'; import { setAngularAppTestingManifest } from './testing-utils'; describe('AngularServerApp', () => { @@ -27,31 +29,45 @@ describe('AngularServerApp', () => { }) class HomeComponent {} - setAngularAppTestingManifest([ - { path: 'home', component: HomeComponent }, - { path: 'redirect', redirectTo: 'home' }, - { path: 'redirect/relative', redirectTo: 'home' }, - { path: 'redirect/absolute', redirectTo: '/home' }, - ]); + setAngularAppTestingManifest( + [ + { path: 'home', component: HomeComponent }, + { path: 'home-csr', component: HomeComponent }, + { path: 'page-with-headers', component: HomeComponent }, + { path: 'page-with-status', component: HomeComponent }, + { path: 'redirect', redirectTo: 'home' }, + { path: 'redirect/relative', redirectTo: 'home' }, + { path: 'redirect/absolute', redirectTo: '/home' }, + ], + [ + { + path: '/home-csr', + renderMode: RenderMode.Client, + }, + { + path: '/page-with-status', + renderMode: RenderMode.Server, + status: 201, + }, + { + path: '/page-with-headers', + renderMode: RenderMode.Server, + headers: { + 'Cache-Control': 'no-cache', + 'X-Some-Header': 'value', + }, + }, + { + path: '/**', + renderMode: RenderMode.Server, + }, + ], + ); app = new 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"'); - }); - - it(`should include the provided 'ng-server-context' value`, async () => { - const response = await app.render( - new Request('http://localhost/home'), - undefined, - ServerRenderContext.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'); @@ -91,5 +107,41 @@ describe('AngularServerApp', () => { await expectAsync(app.render(request)).toBeRejectedWithError(/Request for: .+ was aborted/); }); + + it('should return configured headers for pages with specific header settings', async () => { + const response = await app.render(new Request('http://localhost/page-with-headers')); + const headers = response?.headers.entries() ?? []; + expect(Object.fromEntries(headers)).toEqual({ + 'cache-control': 'no-cache', + 'x-some-header': 'value', + 'content-type': 'text/plain;charset=UTF-8', + }); + }); + + it('should return only default headers for pages without specific header configurations', async () => { + const response = await app.render(new Request('http://localhost/home')); + const headers = response?.headers.entries() ?? []; + expect(Object.fromEntries(headers)).toEqual({ + 'content-type': 'text/plain;charset=UTF-8', // default header + }); + }); + + it('should return the configured status for pages with specific status settings', async () => { + const response = await app.render(new Request('http://localhost/page-with-status')); + expect(response?.status).toBe(201); + }); + + it('should return static `index.csr.html` for routes with CSR rendering mode', async () => { + const response = await app.render(new Request('http://localhost/home-csr')); + const content = await response?.text(); + + expect(content).toContain('CSR page'); + expect(content).not.toContain('ng-server-context'); + }); + + it('should include `ng-server-context="ssr"` for SSR rendering mode', async () => { + const response = await app.render(new Request('http://localhost/home')); + expect(await response?.text()).toContain('ng-server-context="ssr"'); + }); }); }); diff --git a/packages/angular/ssr/test/routes/ng-routes_spec.ts b/packages/angular/ssr/test/routes/ng-routes_spec.ts new file mode 100644 index 0000000000..da347f9768 --- /dev/null +++ b/packages/angular/ssr/test/routes/ng-routes_spec.ts @@ -0,0 +1,176 @@ +/** + * @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 + */ + +// The compiler is needed as tests are in JIT. +/* eslint-disable import/no-unassigned-import */ +import '@angular/compiler'; +/* eslint-enable import/no-unassigned-import */ + +import { Component } from '@angular/core'; +import { extractRoutesAndCreateRouteTree } from '../../src/routes/ng-routes'; +import { PrerenderFallback, RenderMode } from '../../src/routes/route-config'; +import { setAngularAppTestingManifest } from '../testing-utils'; + +describe('extractRoutesAndCreateRouteTree', () => { + const url = new URL('http://localhost'); + + @Component({ + standalone: true, + selector: 'app-dummy-comp', + template: `dummy works`, + }) + class DummyComponent {} + + it('should extract routes and create a route tree', async () => { + setAngularAppTestingManifest( + [ + { path: 'home', component: DummyComponent }, + { path: 'redirect', redirectTo: 'home' }, + { path: 'user/:id', component: DummyComponent }, + ], + [ + { path: '/home', renderMode: RenderMode.Client }, + { path: '/redirect', renderMode: RenderMode.Server, status: 301 }, + { path: '/**', renderMode: RenderMode.Server }, + ], + ); + + const routeTree = await extractRoutesAndCreateRouteTree(url); + expect(routeTree.toObject()).toEqual([ + { route: '/home', renderMode: RenderMode.Client }, + { route: '/redirect', renderMode: RenderMode.Server, status: 301, redirectTo: '/home' }, + { route: '/user/:id', renderMode: RenderMode.Server }, + ]); + }); + + describe('when `invokeGetPrerenderParams` is true', () => { + it('should resolve parameterized routes for SSG and add a fallback route if fallback is Server', async () => { + setAngularAppTestingManifest( + [{ path: 'user/:id/role/:role', component: DummyComponent }], + [ + { + path: 'user/:id/role/:role', + renderMode: RenderMode.Prerender, + fallback: PrerenderFallback.Server, + async getPrerenderParams() { + return [ + { id: 'joe', role: 'admin' }, + { id: 'jane', role: 'writer' }, + ]; + }, + }, + ], + ); + + const routeTree = await extractRoutesAndCreateRouteTree(url, undefined, true); + expect(routeTree.toObject()).toEqual([ + { route: '/user/joe/role/admin', renderMode: RenderMode.Prerender }, + { + route: '/user/jane/role/writer', + renderMode: RenderMode.Prerender, + }, + { route: '/user/:id/role/:role', renderMode: RenderMode.Server }, + ]); + }); + + it('should resolve parameterized routes for SSG and add a fallback route if fallback is Client', async () => { + setAngularAppTestingManifest( + [ + { path: 'home', component: DummyComponent }, + { path: 'user/:id/role/:role', component: DummyComponent }, + ], + [ + { + path: 'user/:id/role/:role', + renderMode: RenderMode.Prerender, + fallback: PrerenderFallback.Client, + async getPrerenderParams() { + return [ + { id: 'joe', role: 'admin' }, + { id: 'jane', role: 'writer' }, + ]; + }, + }, + { path: '/**', renderMode: RenderMode.Server }, + ], + ); + + const routeTree = await extractRoutesAndCreateRouteTree(url, undefined, true); + expect(routeTree.toObject()).toEqual([ + { route: '/home', renderMode: RenderMode.Server }, + { route: '/user/joe/role/admin', renderMode: RenderMode.Prerender }, + { + route: '/user/jane/role/writer', + renderMode: RenderMode.Prerender, + }, + { route: '/user/:id/role/:role', renderMode: RenderMode.Client }, + ]); + }); + + it('should resolve parameterized routes for SSG and not add a fallback route if fallback is None', async () => { + setAngularAppTestingManifest( + [ + { path: 'home', component: DummyComponent }, + { path: 'user/:id/role/:role', component: DummyComponent }, + ], + [ + { + path: 'user/:id/role/:role', + renderMode: RenderMode.Prerender, + fallback: PrerenderFallback.None, + async getPrerenderParams() { + return [ + { id: 'joe', role: 'admin' }, + { id: 'jane', role: 'writer' }, + ]; + }, + }, + { path: '/**', renderMode: RenderMode.Server }, + ], + ); + + const routeTree = await extractRoutesAndCreateRouteTree(url, undefined, true); + expect(routeTree.toObject()).toEqual([ + { route: '/home', renderMode: RenderMode.Server }, + { route: '/user/joe/role/admin', renderMode: RenderMode.Prerender }, + { + route: '/user/jane/role/writer', + renderMode: RenderMode.Prerender, + }, + ]); + }); + }); + + it('should not resolve parameterized routes for SSG when `invokeGetPrerenderParams` is false', async () => { + setAngularAppTestingManifest( + [ + { path: 'home', component: DummyComponent }, + { path: 'user/:id/role/:role', component: DummyComponent }, + ], + [ + { + path: 'user/:id/role/:role', + renderMode: RenderMode.Prerender, + async getPrerenderParams() { + return [ + { id: 'joe', role: 'admin' }, + { id: 'jane', role: 'writer' }, + ]; + }, + }, + { path: '/**', renderMode: RenderMode.Server }, + ], + ); + + const routeTree = await extractRoutesAndCreateRouteTree(url, undefined, false); + expect(routeTree.toObject()).toEqual([ + { route: '/home', renderMode: RenderMode.Server }, + { route: '/user/:id/role/:role', renderMode: RenderMode.Server }, + ]); + }); +}); diff --git a/packages/angular/ssr/test/routes/router_spec.ts b/packages/angular/ssr/test/routes/router_spec.ts index 6d2661261d..dc87a8e593 100644 --- a/packages/angular/ssr/test/routes/router_spec.ts +++ b/packages/angular/ssr/test/routes/router_spec.ts @@ -6,8 +6,14 @@ * found in the LICENSE file at https://angular.dev/license */ +// The compiler is needed as tests are in JIT. +/* eslint-disable import/no-unassigned-import */ +import '@angular/compiler'; +/* eslint-enable import/no-unassigned-import */ + import { Component } from '@angular/core'; import { AngularAppManifest, getAngularAppManifest } from '../../src/manifest'; +import { RenderMode } from '../../src/routes/route-config'; import { ServerRouter } from '../../src/routes/router'; import { setAngularAppTestingManifest } from '../testing-utils'; @@ -23,12 +29,18 @@ describe('ServerRouter', () => { }) class DummyComponent {} - setAngularAppTestingManifest([ - { path: 'home', component: DummyComponent }, - { path: 'redirect', redirectTo: 'home' }, - { path: 'encoding url', component: DummyComponent }, - { path: 'user/:id', component: DummyComponent }, - ]); + setAngularAppTestingManifest( + [ + { path: 'home', component: DummyComponent }, + { path: 'redirect', redirectTo: 'home' }, + { path: 'encoding url', component: DummyComponent }, + { path: 'user/:id', component: DummyComponent }, + ], + [ + { path: '/redirect', renderMode: RenderMode.Server, status: 301 }, + { path: '/**', renderMode: RenderMode.Server }, + ], + ); manifest = getAngularAppManifest(); }); @@ -40,15 +52,17 @@ describe('ServerRouter', () => { // Check that routes are correctly built expect(router.match(new URL('http://localhost/home'))).toEqual({ route: '/home', - redirectTo: undefined, + renderMode: RenderMode.Server, }); expect(router.match(new URL('http://localhost/redirect'))).toEqual({ redirectTo: '/home', route: '/redirect', + renderMode: RenderMode.Server, + status: 301, }); expect(router.match(new URL('http://localhost/user/123'))).toEqual({ route: '/user/:id', - redirectTo: undefined, + renderMode: RenderMode.Server, }); }); @@ -70,9 +84,20 @@ describe('ServerRouter', () => { 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 }); + expect(homeMetadata).toEqual({ + route: '/home', + renderMode: RenderMode.Server, + }); + expect(redirectMetadata).toEqual({ + redirectTo: '/home', + route: '/redirect', + status: 301, + renderMode: RenderMode.Server, + }); + expect(userMetadata).toEqual({ + route: '/user/:id', + renderMode: RenderMode.Server, + }); }); it('should correctly match URLs ending with /index.html', () => { @@ -80,14 +105,28 @@ describe('ServerRouter', () => { 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 }); + expect(homeMetadata).toEqual({ + route: '/home', + renderMode: RenderMode.Server, + }); + expect(redirectMetadata).toEqual({ + redirectTo: '/home', + route: '/redirect', + status: 301, + renderMode: RenderMode.Server, + }); + expect(userMetadata).toEqual({ + route: '/user/:id', + renderMode: RenderMode.Server, + }); }); it('should handle encoded URLs', () => { const encodedUserMetadata = router.match(new URL('http://localhost/encoding%20url')); - expect(encodedUserMetadata).toEqual({ route: '/encoding url', redirectTo: undefined }); + expect(encodedUserMetadata).toEqual({ + route: '/encoding url', + renderMode: RenderMode.Server, + }); }); }); }); diff --git a/packages/angular/ssr/test/testing-utils.ts b/packages/angular/ssr/test/testing-utils.ts index 7eb0ddf768..4a5d5345fd 100644 --- a/packages/angular/ssr/test/testing-utils.ts +++ b/packages/angular/ssr/test/testing-utils.ts @@ -11,6 +11,7 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { provideServerRendering } from '@angular/platform-server'; import { RouterOutlet, Routes, provideRouter } from '@angular/router'; import { setAngularAppManifest } from '../src/manifest'; +import { ServerRoute, provideServerRoutesConfig } from '../src/routes/route-config'; /** * Configures the Angular application for testing by setting up the Angular app manifest, @@ -19,23 +20,40 @@ import { setAngularAppManifest } from '../src/manifest'; * Angular components and providers for testing purposes. * * @param routes - An array of route definitions to be used by the Angular Router. + * @param serverRoutes - An array of ServerRoute definitions to be used for server-side rendering. * @param [baseHref=''] - An optional base href to be used in the HTML template. */ -export function setAngularAppTestingManifest(routes: Routes, baseHref = ''): void { +export function setAngularAppTestingManifest( + routes: Routes, + serverRoutes: ServerRoute[], + baseHref = '', +): void { setAngularAppManifest({ inlineCriticalCss: false, assets: new Map( Object.entries({ 'index.server.html': async () => - ` - - - - - - - -`, + ` + + SSR page + + + + + + + `, + 'index.csr.html': async () => + ` + + CSR page + + + + + + + `, }), ), bootstrap: async () => () => { @@ -52,6 +70,7 @@ export function setAngularAppTestingManifest(routes: Routes, baseHref = ''): voi provideServerRendering(), provideExperimentalZonelessChangeDetection(), provideRouter(routes), + provideServerRoutesConfig(serverRoutes), ], }); }, diff --git a/packages/angular_devkit/build_angular/src/builders/app-shell/app-shell_spec.ts b/packages/angular_devkit/build_angular/src/builders/app-shell/app-shell_spec.ts index 9cbd3be9bd..b40e1d60a9 100644 --- a/packages/angular_devkit/build_angular/src/builders/app-shell/app-shell_spec.ts +++ b/packages/angular_devkit/build_angular/src/builders/app-shell/app-shell_spec.ts @@ -125,7 +125,8 @@ describe('AppShell Builder', () => { const fileName = 'dist/index.html'; const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName))); expect(content).toMatch('Welcome to app'); - expect(content).toMatch('ng-server-context="app-shell"'); + // TODO(alanagius): enable once integration of routes in complete. + // expect(content).toMatch('ng-server-context="app-shell"'); }); it('works with route', async () => {