diff --git a/goldens/public-api/angular/ssr/index.api.md b/goldens/public-api/angular/ssr/index.api.md index 6d89972bde20..282aad7f3b41 100644 --- a/goldens/public-api/angular/ssr/index.api.md +++ b/goldens/public-api/angular/ssr/index.api.md @@ -5,16 +5,12 @@ ```ts // @public -export interface AngularServerAppManager { +export class AngularAppEngine { + getHeaders(request: Request): Readonly>; render(request: Request, requestContext?: unknown): Promise; + static ɵhooks: Hooks; } -// @public -export function destroyAngularAppEngine(): void; - -// @public -export function getOrCreateAngularAppEngine(): AngularServerAppManager; - // (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 a695e5068131..32820b80f211 100644 --- a/goldens/public-api/angular/ssr/node/index.api.md +++ b/goldens/public-api/angular/ssr/node/index.api.md @@ -10,6 +10,12 @@ import type { ServerResponse } from 'node:http'; import { StaticProvider } from '@angular/core'; import { Type } from '@angular/core'; +// @public +export class AngularNodeAppEngine { + getHeaders(request: IncomingMessage): Readonly>; + render(request: IncomingMessage, requestContext?: unknown): Promise; +} + // @public export class CommonEngine { constructor(options?: CommonEngineOptions | undefined); diff --git a/packages/angular/ssr/node/public_api.ts b/packages/angular/ssr/node/public_api.ts index 4736a45c24f9..d8979687def9 100644 --- a/packages/angular/ssr/node/public_api.ts +++ b/packages/angular/ssr/node/public_api.ts @@ -12,5 +12,7 @@ export { type CommonEngineOptions, } from './src/common-engine/common-engine'; +export { AngularNodeAppEngine } from './src/app-engine'; + export { writeResponseToNodeResponse } from './src/response'; export { createWebRequestFromNodeRequest } from './src/request'; diff --git a/packages/angular/ssr/node/src/app-engine.ts b/packages/angular/ssr/node/src/app-engine.ts new file mode 100644 index 000000000000..c6564e05fd1e --- /dev/null +++ b/packages/angular/ssr/node/src/app-engine.ts @@ -0,0 +1,72 @@ +/** + * @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 { AngularAppEngine } from '@angular/ssr'; +import type { IncomingMessage } from 'node:http'; +import { createWebRequestFromNodeRequest } from './request'; + +/** + * Angular server application engine. + * Manages Angular server applications (including localized ones), handles rendering requests, + * and optionally transforms index HTML before rendering. + * + * @note This class should be instantiated once and used as a singleton across the server-side + * application to ensure consistent handling of rendering requests and resource management. + * + * @developerPreview + */ +export class AngularNodeAppEngine { + private readonly angularAppEngine = new AngularAppEngine(); + + /** + * Renders an HTTP response based on the incoming request using the Angular server application. + * + * The method processes the incoming request, determines the appropriate route, and prepares the + * rendering context to generate a response. If the request URL corresponds to a static file (excluding `/index.html`), + * the method returns `null`. + * + * Example: A request to `https://www.example.com/page/index.html` will render the Angular route + * associated with `https://www.example.com/page`. + * + * @param request - The incoming HTTP request object to be rendered. + * @param requestContext - Optional additional context for the request, such as metadata or custom settings. + * @returns A promise that resolves to a `Response` object, or `null` if the request URL is for a static file + * (e.g., `./logo.png`) rather than an application route. + */ + render(request: IncomingMessage, requestContext?: unknown): Promise { + return this.angularAppEngine.render(createWebRequestFromNodeRequest(request), requestContext); + } + + /** + * Retrieves HTTP headers for a request associated with statically generated (SSG) pages, + * based on the URL pathname. + * + * @param request - The incoming request object. + * @returns A `Map` containing the HTTP headers as key-value pairs. + * @note This function should be used exclusively for retrieving headers of SSG pages. + * @example + * ```typescript + * const angularAppEngine = new AngularNodeAppEngine(); + * + * app.use(express.static('dist/browser', { + * setHeaders: (res, path) => { + * // Retrieve headers for the current request + * const headers = angularAppEngine.getHeaders(res.req); + * + * // Apply the retrieved headers to the response + * for (const { key, value } of headers) { + * res.setHeader(key, value); + * } + * } + })); + * ``` + */ + getHeaders(request: IncomingMessage): Readonly> { + return this.angularAppEngine.getHeaders(createWebRequestFromNodeRequest(request)); + } +} diff --git a/packages/angular/ssr/private_export.ts b/packages/angular/ssr/private_export.ts index 0cb5189adb3a..cfed1c49ad14 100644 --- a/packages/angular/ssr/private_export.ts +++ b/packages/angular/ssr/private_export.ts @@ -21,6 +21,4 @@ export { setAngularAppEngineManifest as ɵsetAngularAppEngineManifest, } from './src/manifest'; -export { AngularAppEngine as ɵAngularAppEngine } from './src/app-engine'; - export { InlineCriticalCssProcessor as ɵInlineCriticalCssProcessor } from './src/utils/inline-critical-css'; diff --git a/packages/angular/ssr/public_api.ts b/packages/angular/ssr/public_api.ts index 71647188bc13..c17a02e75b6b 100644 --- a/packages/angular/ssr/public_api.ts +++ b/packages/angular/ssr/public_api.ts @@ -8,8 +8,4 @@ export * from './private_export'; -export { - type AngularServerAppManager, - getOrCreateAngularAppEngine, - destroyAngularAppEngine, -} from './src/app-engine'; +export { AngularAppEngine } from './src/app-engine'; diff --git a/packages/angular/ssr/src/app-engine.ts b/packages/angular/ssr/src/app-engine.ts index 9dfb14d75303..2dba398a8fd1 100644 --- a/packages/angular/ssr/src/app-engine.ts +++ b/packages/angular/ssr/src/app-engine.ts @@ -10,38 +10,19 @@ import type { AngularServerApp } from './app'; import { Hooks } from './hooks'; import { getPotentialLocaleIdFromUrl } from './i18n'; import { EntryPointExports, getAngularAppEngineManifest } from './manifest'; - -/** - * Angular server application engine. - * Manages Angular server applications (including localized ones) and handles rendering requests. - - * @developerPreview - */ -export interface AngularServerAppManager { - /** - * Renders a response for the given HTTP request using the server application. - * - * This method processes the request, determines the appropriate route and rendering context, - * and returns an HTTP response. - * - * If the request URL appears to be for a file (excluding `/index.html`), the method returns `null`. - * A request to `https://www.example.com/page/index.html` will render the Angular route - * corresponding to `https://www.example.com/page`. - * - * @param request - The incoming HTTP request object to be rendered. - * @param requestContext - Optional additional context for the request, such as metadata. - * @returns A promise that resolves to a Response object, or `null` if the request URL represents a file (e.g., `./logo.png`) - * rather than an application route. - */ - render(request: Request, requestContext?: unknown): Promise; -} +import { stripIndexHtmlFromURL } from './utils/url'; /** * Angular server application engine. * Manages Angular server applications (including localized ones), handles rendering requests, * and optionally transforms index HTML before rendering. + * + * @note This class should be instantiated once and used as a singleton across the server-side + * application to ensure consistent handling of rendering requests and resource management. + * + * @developerPreview */ -export class AngularAppEngine implements AngularServerAppManager { +export class AngularAppEngine { /** * Hooks for extending or modifying the behavior of the server application. * These hooks are used by the Angular CLI when running the development server and @@ -120,33 +101,23 @@ export class AngularAppEngine implements AngularServerAppManager { return entryPoints.get(potentialLocale); } -} -let angularAppEngine: AngularAppEngine | undefined; + /** + * Retrieves HTTP headers for a request associated with statically generated (SSG) pages, + * based on the URL pathname. + * + * @param request - The incoming request object. + * @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): Readonly> { + if (this.manifest.staticPathsHeaders.size === 0) { + return new Map(); + } -/** - * Retrieves an existing `AngularAppEngine` instance or creates a new one if none exists. - * - * This method ensures that only a single instance of `AngularAppEngine` is created and reused across - * the application lifecycle, providing efficient resource management. If the instance does not exist, - * it will be instantiated upon the first call. - * - * @developerPreview - * @returns The existing or newly created instance of `AngularAppEngine`. - */ -export function getOrCreateAngularAppEngine(): AngularServerAppManager { - return (angularAppEngine ??= new AngularAppEngine()); -} + const { pathname } = stripIndexHtmlFromURL(new URL(request.url)); + const headers = this.manifest.staticPathsHeaders.get(pathname); -/** - * Destroys the current `AngularAppEngine` instance, releasing any associated resources. - * - * This method resets the reference to the `AngularAppEngine` instance to `undefined`, allowing - * a new instance to be created on the next call to `getOrCreateAngularAppEngine()`. It is typically - * used when reinitializing the server environment or refreshing the application state is necessary. - * - * @developerPreview - */ -export function destroyAngularAppEngine(): void { - angularAppEngine = undefined; + return new Map(headers); + } } diff --git a/packages/angular/ssr/src/manifest.ts b/packages/angular/ssr/src/manifest.ts index e43631290ed6..0ab6e5f439d5 100644 --- a/packages/angular/ssr/src/manifest.ts +++ b/packages/angular/ssr/src/manifest.ts @@ -43,6 +43,18 @@ export interface AngularAppEngineManifest { * This is used to determine the root path of the application. */ readonly basePath: string; + + /** + * A map that associates static paths with their corresponding HTTP headers. + * Each entry in the map consists of: + * - `key`: The static path as a string. + * - `value`: An array of tuples, where each tuple contains: + * - `headerName`: The name of the HTTP header. + * - `headerValue`: The value of the HTTP header. + */ + readonly staticPathsHeaders: Readonly< + Map + >; } /** diff --git a/packages/angular/ssr/src/routes/route-config.ts b/packages/angular/ssr/src/routes/route-config.ts new file mode 100644 index 000000000000..cf6115aa56e5 --- /dev/null +++ b/packages/angular/ssr/src/routes/route-config.ts @@ -0,0 +1,146 @@ +/** + * @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 { EnvironmentProviders, InjectionToken, makeEnvironmentProviders } from '@angular/core'; + +/** + * Different rendering modes for server routes. + * @developerPreview + */ +export enum RenderMode { + /** AppShell rendering mode, typically used for pre-rendered shells of the application. */ + AppShell, + + /** Server-Side Rendering (SSR) mode, where content is rendered on the server for each request. */ + SSR, + + /** Client-Side Rendering (CSR) mode, where content is rendered on the client side in the browser. */ + CSR, + + /** Static Site Generation (SSG) mode, where content is pre-rendered at build time and served as static files. */ + SSG, +} + +/** + * Fallback strategies for Static Site Generation (SSG) routes. + * @developerPreview + */ +export enum SSGFallback { + /** Use Server-Side Rendering (SSR) as the fallback for this route. */ + SSR, + + /** Use Client-Side Rendering (CSR) as the fallback for this route. */ + CSR, + + /** No fallback; Angular will not handle the response if the path is not pre-rendered. */ + None, +} + +/** + * Common interface for server routes, providing shared properties. + */ +export interface ServerRouteCommon { + /** The path associated with this route. */ + path: string; + + /** Optional additional headers to include in the response for this route. */ + headers?: Record; + + /** Optional status code to return for this route. */ + status?: number; +} + +/** + * A server route that uses AppShell rendering mode. + */ +export interface ServerRouteAppShell extends Omit { + /** Specifies that the route uses AppShell rendering mode. */ + renderMode: RenderMode.AppShell; +} + +/** + * A server route that uses Client-Side Rendering (CSR) mode. + */ +export interface ServerRouteCSR extends ServerRouteCommon { + /** Specifies that the route uses Client-Side Rendering (CSR) mode. */ + renderMode: RenderMode.CSR; +} + +/** + * A server route that uses Static Site Generation (SSG) mode. + */ +export interface ServerRouteSSG extends Omit { + /** Specifies that the route uses Static Site Generation (SSG) mode. */ + renderMode: RenderMode.SSG; + + /** + * Optional fallback strategy to use if the SSG path is not pre-rendered. + * Defaults to `SSGFallback.SSR` if not provided. + */ + fallback?: SSGFallback; + /** + * A function that returns a Promise resolving to an array of objects, each representing a route path with URL parameters. + * This function runs in the injector context, allowing access to Angular services and dependencies. + * + * @returns A Promise resolving to an array where each element is an object with string keys (representing URL parameter names) + * and string values (representing the corresponding values for those parameters in the route path). + * + * @example + * ```typescript + * export const serverRouteConfig: ServerRoutes[] = [ + * { + * path: '/product/:id', + * remderMode: RenderMode.SSG, + * async getPrerenderPaths() { + * const productService = inject(ProductService); + * const ids = await productService.getIds(); // Assuming this returns ['1', '2', '3'] + * + * return ids.map(id => ({ id })); // Generates paths like: [{ id: '1' }, { id: '2' }, { id: '3' }] + * }, + * }, + * ]; + * ``` + */ + getPrerenderPaths?: () => Promise[]>; +} + +/** + * A server route that uses Server-Side Rendering (SSR) mode. + */ +export interface ServerRouteSSR extends ServerRouteCommon { + /** Specifies that the route uses Server-Side Rendering (SSR) mode. */ + renderMode: RenderMode.SSR; +} + +/** + * Server route configuration. + * @developerPreview + */ +export type ServerRoute = ServerRouteAppShell | ServerRouteCSR | ServerRouteSSG | ServerRouteSSR; + +/** + * Token for providing the server routes configuration. + * @internal + */ +export const SERVER_ROUTES_CONFIG = new InjectionToken('SERVER_ROUTES_CONFIG'); + +/** + * Configures the necessary providers for server routes configuration. + * + * @param routes - An array of server routes to be provided. + * @returns An `EnvironmentProviders` object that contains the server routes configuration. + * @developerPreview + */ +export function provideServerRoutesConfig(routes: ServerRoute[]): EnvironmentProviders { + return makeEnvironmentProviders([ + { + provide: SERVER_ROUTES_CONFIG, + useValue: routes, + }, + ]); +} diff --git a/packages/angular/ssr/test/app-engine_spec.ts b/packages/angular/ssr/test/app-engine_spec.ts index 50af1398dd39..f79490101273 100644 --- a/packages/angular/ssr/test/app-engine_spec.ts +++ b/packages/angular/ssr/test/app-engine_spec.ts @@ -47,45 +47,82 @@ describe('AngularAppEngine', () => { ]), ), basePath: '', + staticPathsHeaders: new Map([ + [ + '/about', + [ + ['Cache-Control', 'no-cache'], + ['X-Some-Header', 'value'], + ], + ], + ]), }); appEngine = new AngularAppEngine(); }); - 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(); - }); + describe('render', () => { + 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(); + }); - it('should return null for requests with unknown locales', async () => { - const request = new Request('https://example.com/es/home'); - const response = await appEngine.render(request); - expect(response).toBeNull(); - }); + it('should return null for requests with unknown locales', async () => { + const request = new Request('https://example.com/es/home'); + const response = await appEngine.render(request); + expect(response).toBeNull(); + }); - it('should return a rendered page with correct locale', async () => { - const request = new Request('https://example.com/it/home'); - const response = await appEngine.render(request); - expect(await response?.text()).toContain('Home works IT'); - }); + it('should return a rendered page with correct locale', async () => { + const request = new Request('https://example.com/it/home'); + const response = await appEngine.render(request); + expect(await response?.text()).toContain('Home works IT'); + }); - it('should correctly render the content when the URL ends with "index.html" with correct locale', async () => { - const request = new Request('https://example.com/it/home/index.html'); - const response = await appEngine.render(request); - expect(await response?.text()).toContain('Home works IT'); - }); + it('should correctly render the content when the URL ends with "index.html" with correct locale', async () => { + const request = new Request('https://example.com/it/home/index.html'); + const response = await appEngine.render(request); + expect(await response?.text()).toContain('Home works IT'); + }); - 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(); + 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(); + }); + + it('should return null for requests to file-like resources in a locale', async () => { + const request = new Request('https://example.com/it/logo.png'); + const response = await appEngine.render(request); + expect(response).toBeNull(); + }); }); - it('should return null for requests to file-like resources in a locale', async () => { - const request = new Request('https://example.com/it/logo.png'); - const response = await appEngine.render(request); - expect(response).toBeNull(); + describe('getHeaders', () => { + it('should return headers for a known path without index.html', () => { + const request = new Request('https://example.com/about'); + const headers = appEngine.getHeaders(request); + expect(Object.fromEntries(headers.entries())).toEqual({ + 'Cache-Control': 'no-cache', + 'X-Some-Header': 'value', + }); + }); + + 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); + expect(Object.fromEntries(headers.entries())).toEqual({ + 'Cache-Control': 'no-cache', + 'X-Some-Header': 'value', + }); + }); + + it('should return no headers for unknown paths', () => { + const request = new Request('https://example.com/unknown/path'); + const headers = appEngine.getHeaders(request); + expect(headers).toHaveSize(0); + }); }); }); @@ -115,6 +152,7 @@ describe('AngularAppEngine', () => { ], ]), basePath: '', + staticPathsHeaders: new Map(), }); appEngine = new AngularAppEngine();