diff --git a/goldens/public-api/angular/ssr/index.api.md b/goldens/public-api/angular/ssr/index.api.md index d62bf7cf2d1c..f8db8663c89b 100644 --- a/goldens/public-api/angular/ssr/index.api.md +++ b/goldens/public-api/angular/ssr/index.api.md @@ -8,8 +8,7 @@ import { EnvironmentProviders } from '@angular/core'; // @public export class AngularAppEngine { - getPrerenderHeaders(request: Request): ReadonlyMap; - render(request: Request, requestContext?: unknown): Promise; + handle(request: Request, requestContext?: unknown): Promise; static ɵhooks: Hooks; } diff --git a/goldens/public-api/angular/ssr/node/index.api.md b/goldens/public-api/angular/ssr/node/index.api.md index 84fb4f129929..27e3a967804e 100644 --- a/goldens/public-api/angular/ssr/node/index.api.md +++ b/goldens/public-api/angular/ssr/node/index.api.md @@ -12,8 +12,7 @@ import { Type } from '@angular/core'; // @public export class AngularNodeAppEngine { - getPrerenderHeaders(request: IncomingMessage): ReadonlyMap; - render(request: IncomingMessage, requestContext?: unknown): Promise; + handle(request: IncomingMessage, requestContext?: unknown): Promise; } // @public diff --git a/packages/angular/build/src/builders/application/execute-post-bundle.ts b/packages/angular/build/src/builders/application/execute-post-bundle.ts index 211ab0a798c0..bb2fb2e17b4d 100644 --- a/packages/angular/build/src/builders/application/execute-post-bundle.ts +++ b/packages/angular/build/src/builders/application/execute-post-bundle.ts @@ -175,7 +175,8 @@ export async function executePostBundleSteps( switch (metadata.renderMode) { case RouteRenderMode.Prerender: case /* Legacy building mode */ undefined: { - if (!metadata.redirectTo || outputMode === OutputMode.Static) { + if (!metadata.redirectTo) { + serializableRouteTreeNodeForManifest.push(metadata); prerenderedRoutes[metadata.route] = { headers: metadata.headers }; } break; diff --git a/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts index 9917537fa290..1f9b309f41bf 100644 --- a/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts +++ b/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts @@ -59,7 +59,7 @@ export function createAngularSsrInternalMiddleware( const webReq = new Request(createWebRequestFromNodeRequest(req), { signal: AbortSignal.timeout(30_000), }); - const webRes = await angularServerApp.render(webReq); + const webRes = await angularServerApp.handle(webReq); if (!webRes) { return next(); } diff --git a/packages/angular/build/src/utils/server-rendering/manifest.ts b/packages/angular/build/src/utils/server-rendering/manifest.ts index c2c295d74a7a..ae8afb8fc875 100644 --- a/packages/angular/build/src/utils/server-rendering/manifest.ts +++ b/packages/angular/build/src/utils/server-rendering/manifest.ts @@ -6,9 +6,8 @@ * found in the LICENSE file at https://angular.dev/license */ +import { extname } from 'node:path'; import { - INDEX_HTML_CSR, - INDEX_HTML_SERVER, NormalizedApplicationBuildOptions, getLocaleBaseHref, } from '../../builders/application/options'; @@ -135,11 +134,8 @@ export function generateAngularServerAppManifest( ): string { const serverAssetsContent: string[] = []; for (const file of [...additionalHtmlOutputFiles.values(), ...outputFiles]) { - if ( - file.path === INDEX_HTML_SERVER || - file.path === INDEX_HTML_CSR || - (inlineCriticalCss && file.path.endsWith('.css')) - ) { + const extension = extname(file.path); + if (extension === '.html' || (inlineCriticalCss && extension === '.css')) { serverAssetsContent.push(`['${file.path}', async () => \`${escapeUnsafeChars(file.text)}\`]`); } } diff --git a/packages/angular/ssr/node/src/app-engine.ts b/packages/angular/ssr/node/src/app-engine.ts index abfbe2944650..cca409f6621a 100644 --- a/packages/angular/ssr/node/src/app-engine.ts +++ b/packages/angular/ssr/node/src/app-engine.ts @@ -24,49 +24,19 @@ export class AngularNodeAppEngine { private readonly angularAppEngine = new AngularAppEngine(); /** - * Renders an HTTP response based on the incoming request using the Angular server application. + * Handles an incoming HTTP request by serving prerendered content, performing server-side rendering, + * or delivering a static file for client-side rendered routes based on the `RenderMode` setting. * - * 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`. + * @param request - The HTTP request to handle. + * @param requestContext - Optional context for rendering, such as metadata associated with the request. + * @returns A promise that resolves to the resulting HTTP response object, or `null` if no matching Angular route is found. * - * 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. + * @note A request to `https://www.example.com/page/index.html` will serve or render the Angular route + * corresponding to `https://www.example.com/page`. */ - render(request: IncomingMessage, requestContext?: unknown): Promise { - return this.angularAppEngine.render(createWebRequestFromNodeRequest(request), requestContext); - } + async handle(request: IncomingMessage, requestContext?: unknown): Promise { + const webRequest = createWebRequestFromNodeRequest(request); - /** - * 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.getPrerenderHeaders(res.req); - * - * // Apply the retrieved headers to the response - * for (const [key, value] of headers) { - * res.setHeader(key, value); - * } - * } - })); - * ``` - */ - getPrerenderHeaders(request: IncomingMessage): ReadonlyMap { - return this.angularAppEngine.getPrerenderHeaders(createWebRequestFromNodeRequest(request)); + return this.angularAppEngine.handle(webRequest, requestContext); } } diff --git a/packages/angular/ssr/node/src/request.ts b/packages/angular/ssr/node/src/request.ts index 644c8c6084e7..38433781026c 100644 --- a/packages/angular/ssr/node/src/request.ts +++ b/packages/angular/ssr/node/src/request.ts @@ -56,7 +56,12 @@ function createRequestHeaders(nodeHeaders: IncomingHttpHeaders): Headers { * @returns A `URL` object representing the request URL. */ function createRequestUrl(nodeRequest: IncomingMessage): URL { - const { headers, socket, url = '' } = nodeRequest; + const { + headers, + socket, + url = '', + originalUrl, + } = nodeRequest as IncomingMessage & { originalUrl?: string }; const protocol = headers['x-forwarded-proto'] ?? ('encrypted' in socket && socket.encrypted ? 'https' : 'http'); const hostname = headers['x-forwarded-host'] ?? headers.host ?? headers[':authority']; @@ -71,5 +76,5 @@ function createRequestUrl(nodeRequest: IncomingMessage): URL { hostnameWithPort += `:${port}`; } - return new URL(url, `${protocol}://${hostnameWithPort}`); + return new URL(originalUrl ?? url, `${protocol}://${hostnameWithPort}`); } diff --git a/packages/angular/ssr/src/app-engine.ts b/packages/angular/ssr/src/app-engine.ts index 1017c3327098..a01de04d9f6a 100644 --- a/packages/angular/ssr/src/app-engine.ts +++ b/packages/angular/ssr/src/app-engine.ts @@ -53,21 +53,34 @@ export class AngularAppEngine { private readonly entryPointsCache = new Map>(); /** - * Renders a response for the given HTTP request using the server application. + * Handles an incoming HTTP request by serving prerendered content, performing server-side rendering, + * or delivering a static file for client-side rendered routes based on the `RenderMode` setting. * - * This method processes the request, determines the appropriate route and rendering context, - * and returns an HTTP response. + * @param request - The HTTP request to handle. + * @param requestContext - Optional context for rendering, such as metadata associated with the request. + * @returns A promise that resolves to the resulting HTTP response object, or `null` if no matching Angular route is found. * - * 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 + * @note A request to `https://www.example.com/page/index.html` will serve or render the Angular route * corresponding to `https://www.example.com/page`. + */ + async handle(request: Request, requestContext?: unknown): Promise { + const serverApp = await this.getAngularServerAppForRequest(request); + + return serverApp ? serverApp.handle(request, requestContext) : null; + } + + /** + * Retrieves the Angular server application instance for a given request. + * + * This method checks if the request URL corresponds to an Angular application entry point. + * If so, it initializes or retrieves an instance of the Angular server application for that entry point. + * Requests that resemble file requests (except for `/index.html`) are skipped. * - * @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. + * @param request - The incoming HTTP request object. + * @returns A promise that resolves to an `AngularServerApp` instance if a valid entry point is found, + * or `null` if no entry point matches the request URL. */ - async render(request: Request, requestContext?: unknown): Promise { + private async getAngularServerAppForRequest(request: Request): Promise { // Skip if the request looks like a file but not `/index.html`. const url = new URL(request.url); const entryPoint = await this.getEntryPointExportsForUrl(url); @@ -82,26 +95,7 @@ export class AngularAppEngine { const serverApp = getOrCreateAngularServerApp() as AngularServerApp; serverApp.hooks = this.hooks; - return serverApp.render(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. - */ - getPrerenderHeaders(request: Request): ReadonlyMap { - if (this.manifest.staticPathsHeaders.size === 0) { - return new Map(); - } - - const { pathname } = stripIndexHtmlFromURL(new URL(request.url)); - const headers = this.manifest.staticPathsHeaders.get(stripTrailingSlash(pathname)); - - return new Map(headers); + return serverApp; } /** diff --git a/packages/angular/ssr/src/app.ts b/packages/angular/ssr/src/app.ts index 4008b09f6e12..65ace593e03f 100644 --- a/packages/angular/ssr/src/app.ts +++ b/packages/angular/ssr/src/app.ts @@ -12,11 +12,19 @@ import { ServerAssets } from './assets'; import { Hooks } from './hooks'; import { getAngularAppManifest } from './manifest'; import { RenderMode } from './routes/route-config'; +import { RouteTreeNodeMetadata } from './routes/route-tree'; import { ServerRouter } from './routes/router'; import { sha256 } from './utils/crypto'; import { InlineCriticalCssProcessor } from './utils/inline-critical-css'; import { LRUCache } from './utils/lru-cache'; import { AngularBootstrap, renderAngular } from './utils/ng'; +import { joinUrlParts, stripIndexHtmlFromURL, stripLeadingSlash } from './utils/url'; + +/** + * The default maximum age in seconds. + * Represents the total number of seconds in a 365-day period. + */ +const DEFAULT_MAX_AGE = 365 * 24 * 60 * 60; /** * Maximum number of critical CSS entries the cache can store. @@ -89,23 +97,6 @@ export class AngularServerApp { */ private readonly criticalCssLRUCache = new LRUCache(MAX_INLINE_CSS_CACHE_ENTRIES); - /** - * Renders a response for the given HTTP request using the server application. - * - * This method processes the request and returns a response based on the specified rendering context. - * - * @param request - The incoming HTTP request to be rendered. - * @param requestContext - Optional additional context for rendering, such as request metadata. - * - * @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): Promise { - return Promise.race([ - this.createAbortPromise(request), - 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 @@ -118,40 +109,147 @@ export class AngularServerApp { renderStatic(url: URL, signal?: AbortSignal): Promise { const request = new Request(url, { signal }); - return Promise.race([ - this.createAbortPromise(request), - this.handleRendering(request, /** isSsrMode */ false), - ]); + return this.handleAbortableRendering(request, /** isSsrMode */ false); } /** - * Creates a promise that rejects when the request is aborted. + * Handles an incoming HTTP request by serving prerendered content, performing server-side rendering, + * or delivering a static file for client-side rendered routes based on the `RenderMode` setting. * - * @param request - The HTTP request to monitor for abortion. - * @returns A promise that never resolves but rejects with an `AbortError` if the request is aborted. + * @param request - The HTTP request to handle. + * @param requestContext - Optional context for rendering, such as metadata associated with the request. + * @returns A promise that resolves to the resulting HTTP response object, or `null` if no matching Angular route is found. + * + * @note A request to `https://www.example.com/page/index.html` will serve or render the Angular route + * corresponding to `https://www.example.com/page`. */ - private createAbortPromise(request: Request): Promise { - return new Promise((_, reject) => { - request.signal.addEventListener( - 'abort', - () => { - const abortError = new Error( - `Request for: ${request.url} was aborted.\n${request.signal.reason}`, - ); - abortError.name = 'AbortError'; - reject(abortError); - }, - { once: true }, - ); + async handle(request: Request, requestContext?: unknown): Promise { + const url = new URL(request.url); + this.router ??= await ServerRouter.from(this.manifest, url); + + const matchedRoute = this.router.match(url); + if (!matchedRoute) { + // Not a known Angular route. + return null; + } + + if (matchedRoute.renderMode === RenderMode.Prerender) { + const response = await this.handleServe(request, matchedRoute); + if (response) { + return response; + } + } + + return this.handleAbortableRendering( + request, + /** isSsrMode */ true, + matchedRoute, + requestContext, + ); + } + + /** + * Retrieves the matched route for the incoming request based on the request URL. + * + * @param request - The incoming HTTP request to match against routes. + * @returns A promise that resolves to the matched route metadata or `undefined` if no route matches. + */ + private async getMatchedRoute(request: Request): Promise { + this.router ??= await ServerRouter.from(this.manifest, new URL(request.url)); + + return this.router.match(new URL(request.url)); + } + + /** + * Handles serving a prerendered static asset if available for the matched route. + * + * @param request - The incoming HTTP request for serving a static page. + * @param matchedRoute - Optional parameter representing the metadata of the matched route for rendering. + * If not provided, the method attempts to find a matching route based on the request URL. + * @returns A promise that resolves to a `Response` object if the prerendered page is found, or `null`. + */ + private async handleServe( + request: Request, + matchedRoute?: RouteTreeNodeMetadata, + ): Promise { + matchedRoute ??= await this.getMatchedRoute(request); + if (!matchedRoute) { + return null; + } + + const { headers, renderMode } = matchedRoute; + if (renderMode !== RenderMode.Prerender) { + return null; + } + + const { pathname } = stripIndexHtmlFromURL(new URL(request.url)); + const assetPath = stripLeadingSlash(joinUrlParts(pathname, 'index.html')); + if (!this.assets.hasServerAsset(assetPath)) { + return null; + } + + // TODO(alanagius): handle etags + + const content = await this.assets.getServerAsset(assetPath); + + return new Response(content, { + headers: { + 'Content-Type': 'text/html;charset=UTF-8', + // 30 days in seconds + 'Cache-Control': `max-age=${DEFAULT_MAX_AGE}`, + ...headers, + }, }); } + /** + * Handles the server-side rendering process for the given HTTP request, allowing for abortion + * of the rendering if the request is aborted. 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. It includes a signal to monitor + * for abortion events. + * @param isSsrMode - A boolean indicating whether the rendering is performed in server-side + * rendering (SSR) mode. + * @param matchedRoute - Optional parameter representing the metadata of the matched route for + * rendering. If not provided, the method attempts to find a matching route based on the request URL. + * @param requestContext - Optional additional context for rendering, such as request metadata. + * + * @returns A promise that resolves to the rendered response, or null if no matching route is found. + * If the request is aborted, the promise will reject with an `AbortError`. + */ + private async handleAbortableRendering( + request: Request, + isSsrMode: boolean, + matchedRoute?: RouteTreeNodeMetadata, + requestContext?: unknown, + ): Promise { + return Promise.race([ + new Promise((_, reject) => { + request.signal.addEventListener( + 'abort', + () => { + const abortError = new Error( + `Request for: ${request.url} was aborted.\n${request.signal.reason}`, + ); + abortError.name = 'AbortError'; + reject(abortError); + }, + { once: true }, + ); + }), + this.handleRendering(request, isSsrMode, matchedRoute, requestContext), + ]); + } + /** * Handles the server-side rendering process for the given HTTP request. * 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 matchedRoute - Optional parameter representing the metadata of the matched route for rendering. + * If not provided, the method attempts to find a matching route based on the request URL. * @param requestContext - Optional additional context for rendering, such as request metadata. * * @returns A promise that resolves to the rendered response, or null if no matching route is found. @@ -159,18 +257,17 @@ export class AngularServerApp { private async handleRendering( request: Request, isSsrMode: boolean, + matchedRoute?: RouteTreeNodeMetadata, requestContext?: unknown, ): Promise { - const url = new URL(request.url); - this.router ??= await ServerRouter.from(this.manifest, url); - - const matchedRoute = this.router.match(url); + matchedRoute ??= await this.getMatchedRoute(request); if (!matchedRoute) { - // Not a known Angular route. return null; } const { redirectTo, status } = matchedRoute; + const url = new URL(request.url); + if (redirectTo !== undefined) { // Note: The status code is validated during route extraction. // 302 Found is used by default for redirections diff --git a/packages/angular/ssr/src/assets.ts b/packages/angular/ssr/src/assets.ts index d60f1c4588e5..7dff917645b8 100644 --- a/packages/angular/ssr/src/assets.ts +++ b/packages/angular/ssr/src/assets.ts @@ -35,6 +35,16 @@ export class ServerAssets { return asset(); } + /** + * Checks if a specific server-side asset exists. + * + * @param path - The path to the server asset. + * @returns A boolean indicating whether the asset exists. + */ + hasServerAsset(path: string): boolean { + return this.manifest.assets.has(path); + } + /** * Retrieves and caches the content of 'index.server.html'. * diff --git a/packages/angular/ssr/src/manifest.ts b/packages/angular/ssr/src/manifest.ts index f20a04581c3b..004c7e82bbb5 100644 --- a/packages/angular/ssr/src/manifest.ts +++ b/packages/angular/ssr/src/manifest.ts @@ -9,6 +9,11 @@ import type { SerializableRouteTreeNode } from './routes/route-tree'; import { AngularBootstrap } from './utils/ng'; +/** + * A function that returns a promise resolving to the file contents of the asset. + */ +export type ServerAsset = () => Promise; + /** * Represents the exports of an Angular server application entry point. */ @@ -43,19 +48,6 @@ 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: ReadonlyMap< - string, - readonly [headerName: string, headerValue: string][] - >; } /** @@ -68,7 +60,7 @@ export interface AngularAppManifest { * - `key`: The path of the asset. * - `value`: A function returning a promise that resolves to the file contents of the asset. */ - readonly assets: ReadonlyMap Promise>; + readonly assets: ReadonlyMap; /** * The bootstrap mechanism for the server application. diff --git a/packages/angular/ssr/test/app-engine_spec.ts b/packages/angular/ssr/test/app-engine_spec.ts index 0e27776a368e..cf4e1d0d11a8 100644 --- a/packages/angular/ssr/test/app-engine_spec.ts +++ b/packages/angular/ssr/test/app-engine_spec.ts @@ -53,15 +53,6 @@ describe('AngularAppEngine', () => { ]), ), basePath: '', - staticPathsHeaders: new Map([ - [ - '/about', - [ - ['Cache-Control', 'no-cache'], - ['X-Some-Header', 'value'], - ], - ], - ]), }); appEngine = new AngularAppEngine(); @@ -70,66 +61,40 @@ describe('AngularAppEngine', () => { 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); + const response = await appEngine.handle(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); + const response = await appEngine.handle(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); + const response = await appEngine.handle(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); + const response = await appEngine.handle(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); + const response = await appEngine.handle(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); + const response = await appEngine.handle(request); expect(response).toBeNull(); }); }); - - describe('getPrerenderHeaders', () => { - it('should return headers for a known path without index.html', () => { - const request = new Request('https://example.com/about'); - const headers = appEngine.getPrerenderHeaders(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.getPrerenderHeaders(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.getPrerenderHeaders(request); - expect(headers).toHaveSize(0); - }); - }); }); describe('Non-localized app', () => { @@ -161,7 +126,6 @@ describe('AngularAppEngine', () => { ], ]), basePath: '', - staticPathsHeaders: new Map(), }); appEngine = new AngularAppEngine(); @@ -169,25 +133,25 @@ describe('AngularAppEngine', () => { it('should return null for requests to file-like resources', async () => { const request = new Request('https://example.com/logo.png'); - const response = await appEngine.render(request); + const response = await appEngine.handle(request); expect(response).toBeNull(); }); 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); + const response = await appEngine.handle(request); expect(response).toBeNull(); }); it('should return a rendered page for known paths', async () => { const request = new Request('https://example.com/home'); - const response = await appEngine.render(request); + const response = await appEngine.handle(request); expect(await response?.text()).toContain('Home works'); }); it('should correctly render the content when the URL ends with "index.html"', async () => { const request = new Request('https://example.com/home/index.html'); - const response = await appEngine.render(request); + const response = await appEngine.handle(request); expect(await response?.text()).toContain('Home works'); }); }); diff --git a/packages/angular/ssr/test/app_spec.ts b/packages/angular/ssr/test/app_spec.ts index 58873aed8690..e4d1832283d0 100644 --- a/packages/angular/ssr/test/app_spec.ts +++ b/packages/angular/ssr/test/app_spec.ts @@ -33,6 +33,7 @@ describe('AngularServerApp', () => { [ { path: 'home', component: HomeComponent }, { path: 'home-csr', component: HomeComponent }, + { path: 'home-ssg', component: HomeComponent }, { path: 'page-with-headers', component: HomeComponent }, { path: 'page-with-status', component: HomeComponent }, { path: 'redirect', redirectTo: 'home' }, @@ -44,6 +45,13 @@ describe('AngularServerApp', () => { path: 'home-csr', renderMode: RenderMode.Client, }, + { + path: 'home-ssg', + renderMode: RenderMode.Prerender, + headers: { + 'X-Some-Header': 'value', + }, + }, { path: 'page-with-status', renderMode: RenderMode.Server, @@ -62,86 +70,129 @@ describe('AngularServerApp', () => { renderMode: RenderMode.Server, }, ], + undefined, + { + 'home-ssg/index.html': async () => + ` + + SSG home page + + + + Home SSG works + + + `, + }, ); app = new AngularServerApp(); }); - describe('render', () => { - 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'); - }); + describe('handle', () => { + describe('CSR and SSG pages', () => { + it('should correctly render the content for the requested page', async () => { + const response = await app.handle(new Request('http://localhost/home')); + 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'); - }); + it(`should correctly render the content when the URL ends with 'index.html'`, async () => { + const response = await app.handle(new Request('http://localhost/home/index.html')); + expect(await response?.text()).toContain('Home works'); + }); - 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 top level redirects', async () => { + const response = await app.handle(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 relative nested redirects', async () => { + const response = await app.handle(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 absolute 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); - }); + it('should correctly handle absolute nested redirects', async () => { + const response = await app.handle(new Request('http://localhost/redirect/absolute')); + expect(response?.headers.get('location')).toContain('http://localhost/home'); + expect(response?.status).toBe(302); + }); - it('should handle request abortion gracefully', async () => { - const controller = new AbortController(); - const request = new Request('http://localhost/home', { signal: controller.signal }); + it('should handle request abortion gracefully', async () => { + const controller = new AbortController(); + const request = new Request('http://localhost/home', { signal: controller.signal }); - // Schedule the abortion of the request in the next microtask - queueMicrotask(() => { - controller.abort(); + // Schedule the abortion of the request in the next microtask + queueMicrotask(() => { + controller.abort(); + }); + + await expectAsync(app.handle(request)).toBeRejectedWithError(/Request for: .+ was aborted/); }); - 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.handle(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/html;charset=UTF-8', + }); + }); - 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/html;charset=UTF-8', + it('should return only default headers for pages without specific header configurations', async () => { + const response = await app.handle(new Request('http://localhost/home')); + const headers = response?.headers.entries() ?? []; + expect(Object.fromEntries(headers)).toEqual({ + 'content-type': 'text/html;charset=UTF-8', // default header + }); }); - }); - 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/html;charset=UTF-8', // default header + it('should return the configured status for pages with specific status settings', async () => { + const response = await app.handle(new Request('http://localhost/page-with-status')); + expect(response?.status).toBe(201); }); - }); - 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.handle(new Request('http://localhost/home-csr')); + const content = await response?.text(); - 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'); + }); - 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.handle(new Request('http://localhost/home')); + expect(await response?.text()).toContain('ng-server-context="ssr"'); + }); }); - 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"'); + describe('SSG pages', () => { + it('should correctly serve the content for the requested prerendered page', async () => { + const response = await app.handle(new Request('http://localhost/home-ssg')); + expect(await response?.text()).toContain('Home SSG works'); + }); + + it(`should correctly serve the content for the requested prerendered page when the URL ends with 'index.html'`, async () => { + const response = await app.handle(new Request('http://localhost/home-ssg/index.html')); + expect(await response?.text()).toContain('Home SSG works'); + }); + + it('should return configured headers for pages with specific header settings', async () => { + const response = await app.handle(new Request('http://localhost/home-ssg')); + const headers = response?.headers.entries() ?? []; + expect(Object.fromEntries(headers)).toEqual({ + 'cache-control': 'max-age=31536000', + 'x-some-header': 'value', + 'content-type': 'text/html;charset=UTF-8', + }); + }); + + it('should return null for a non-prerendered page', async () => { + const response = await app.handle(new Request('http://localhost/unknown')); + expect(response).toBeNull(); + }); }); }); }); diff --git a/packages/angular/ssr/test/testing-utils.ts b/packages/angular/ssr/test/testing-utils.ts index 4a5d5345fdc1..005b108585d3 100644 --- a/packages/angular/ssr/test/testing-utils.ts +++ b/packages/angular/ssr/test/testing-utils.ts @@ -10,7 +10,7 @@ import { Component, provideExperimentalZonelessChangeDetection } from '@angular/ 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 { AngularAppManifest, ServerAsset, setAngularAppManifest } from '../src/manifest'; import { ServerRoute, provideServerRoutesConfig } from '../src/routes/route-config'; /** @@ -27,11 +27,13 @@ export function setAngularAppTestingManifest( routes: Routes, serverRoutes: ServerRoute[], baseHref = '', + additionalServerAssets: Record = {}, ): void { setAngularAppManifest({ inlineCriticalCss: false, assets: new Map( Object.entries({ + ...additionalServerAssets, 'index.server.html': async () => ` diff --git a/packages/schematics/angular/ssr/files/application-builder/server.ts.template b/packages/schematics/angular/ssr/files/application-builder/server.ts.template index e54727a29d0d..877173580ff0 100644 --- a/packages/schematics/angular/ssr/files/application-builder/server.ts.template +++ b/packages/schematics/angular/ssr/files/application-builder/server.ts.template @@ -29,26 +29,20 @@ const angularApp = new AngularNodeAppEngine(); /** * Serve static files from /<%= browserDistDirectory %> */ -app.get( - '**', +app.use( express.static(browserDistFolder, { maxAge: '1y', - index: 'index.html', - setHeaders: (res) => { - const headers = angularApp.getPrerenderHeaders(res.req); - for (const [key, value] of headers) { - res.setHeader(key, value); - } - }, + index: false, + redirect: false, }), ); /** * Handle all other requests by rendering the Angular application. */ -app.get('**', (req, res, next) => { +app.use('/**', (req, res, next) => { angularApp - .render(req) + .handle(req) .then((response) => response ? writeResponseToNodeResponse(response, res) : next(), ) diff --git a/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-output-mode-server-platform-neutral.ts b/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-output-mode-server-platform-neutral.ts index bde15ed715cd..fd7b7eea2de0 100644 --- a/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-output-mode-server-platform-neutral.ts +++ b/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-output-mode-server-platform-neutral.ts @@ -78,7 +78,7 @@ export default async function () { router.use( '/**', - defineEventHandler((event) => angularAppEngine.render(toWebRequest(event))), + defineEventHandler((event) => angularAppEngine.handle(toWebRequest(event))), ); app.use(router); diff --git a/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-output-mode-static-http-calls.ts b/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-output-mode-static-http-calls.ts index baba83e00fbd..734f15e666e3 100644 --- a/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-output-mode-static-http-calls.ts +++ b/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-output-mode-static-http-calls.ts @@ -94,7 +94,7 @@ export default async function () { })); server.get('**', (req, res, next) => { - angularNodeAppEngine.render(req) + angularNodeAppEngine.handle(req) .then((response) => response ? writeResponseToNodeResponse(response, res) : next()) .catch(next); }); diff --git a/tests/legacy-cli/e2e/tests/vite/ssr-entry-express.ts b/tests/legacy-cli/e2e/tests/vite/ssr-entry-express.ts index e5521fe44123..ef222d7e6940 100644 --- a/tests/legacy-cli/e2e/tests/vite/ssr-entry-express.ts +++ b/tests/legacy-cli/e2e/tests/vite/ssr-entry-express.ts @@ -63,7 +63,7 @@ export default async function () { })); server.get('**', (req, res, next) => { - angularNodeAppEngine.render(req) + angularNodeAppEngine.handle(req) .then((response) => response ? writeResponseToNodeResponse(response, res) : next()) .catch(next); }); diff --git a/tests/legacy-cli/e2e/tests/vite/ssr-entry-fastify.ts b/tests/legacy-cli/e2e/tests/vite/ssr-entry-fastify.ts index 2b206302196a..c811287023c6 100644 --- a/tests/legacy-cli/e2e/tests/vite/ssr-entry-fastify.ts +++ b/tests/legacy-cli/e2e/tests/vite/ssr-entry-fastify.ts @@ -54,7 +54,7 @@ export default async function () { server.get('/api/*', (req, reply) => reply.send({ hello: 'foo' })); server.get('*', async (req, reply) => { try { - const response = await angularNodeAppEngine.render(req.raw); + const response = await angularNodeAppEngine.handle(req.raw); if (response) { await writeResponseToNodeResponse(response, reply.raw); } else { diff --git a/tests/legacy-cli/e2e/tests/vite/ssr-entry-h3.ts b/tests/legacy-cli/e2e/tests/vite/ssr-entry-h3.ts index b93f0bd60997..34072abf371d 100644 --- a/tests/legacy-cli/e2e/tests/vite/ssr-entry-h3.ts +++ b/tests/legacy-cli/e2e/tests/vite/ssr-entry-h3.ts @@ -60,7 +60,7 @@ export default async function () { router.use( '/**', - defineEventHandler((event) => angularAppEngine.render(toWebRequest(event))), + defineEventHandler((event) => angularAppEngine.handle(toWebRequest(event))), ); server.use(router); diff --git a/tests/legacy-cli/e2e/tests/vite/ssr-entry-hono.ts b/tests/legacy-cli/e2e/tests/vite/ssr-entry-hono.ts index f566dc9f9d35..7ddc04b90492 100644 --- a/tests/legacy-cli/e2e/tests/vite/ssr-entry-hono.ts +++ b/tests/legacy-cli/e2e/tests/vite/ssr-entry-hono.ts @@ -54,7 +54,7 @@ export default async function () { server.get('/api/*', (c) => c.json({ hello: 'foo' })); server.get('/*', async (c) => { - const res = await angularAppEngine.render(c.req.raw); + const res = await angularAppEngine.handle(c.req.raw); return res || undefined }); diff --git a/tests/legacy-cli/e2e/utils/packages.ts b/tests/legacy-cli/e2e/utils/packages.ts index f43656f704fc..087d771f14b8 100644 --- a/tests/legacy-cli/e2e/utils/packages.ts +++ b/tests/legacy-cli/e2e/utils/packages.ts @@ -32,7 +32,7 @@ export async function installWorkspacePackages(options?: { force?: boolean }): P } } -export async function installPackage(specifier: string, registry?: string): Promise { +export function installPackage(specifier: string, registry?: string): Promise { const registryOption = registry ? [`--registry=${registry}`] : []; switch (getActivePackageManager()) { case 'npm': @@ -46,16 +46,25 @@ export async function installPackage(specifier: string, registry?: string): Prom } } -export async function uninstallPackage(name: string): Promise { - switch (getActivePackageManager()) { - case 'npm': - return silentNpm('uninstall', name); - case 'yarn': - return silentYarn('remove', name); - case 'bun': - return silentBun('remove', name); - case 'pnpm': - return silentPnpm('remove', name); +export async function uninstallPackage(name: string): Promise { + try { + switch (getActivePackageManager()) { + case 'npm': + await silentNpm('uninstall', name); + break; + case 'yarn': + await silentYarn('remove', name); + break; + case 'bun': + await silentBun('remove', name); + break; + case 'pnpm': + await silentPnpm('remove', name); + break; + } + } catch (e) { + // Yarn throws an error when trying to remove a package that is not installed. + console.error(e); } }