From df4e1d3607c2d5bf71d1234fa730e63cd6ab594b Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Fri, 1 Nov 2024 08:32:02 +0000 Subject: [PATCH] fix(@angular/ssr): enable serving of prerendered pages in the App Engine This commit implements the capability for the App Engine to serve prerendered pages directly. Previously, we relied on frameworks like Express for this functionality, which resulted in inconsistent redirects for directories where in some cases a trailing slash was added to the route. **Note:** This change applies only when using the new SSR APIs. When using the `CommonEngine`, a 3rd party static serve middleware is still required. (cherry picked from commit 481ccdbc5a3a0dd013230e9a7c25d909b7a39cc3) --- goldens/public-api/angular/ssr/index.api.md | 3 +- .../public-api/angular/ssr/node/index.api.md | 3 +- .../application/execute-post-bundle.ts | 3 +- .../tools/vite/middlewares/ssr-middleware.ts | 2 +- .../src/utils/server-rendering/manifest.ts | 10 +- packages/angular/ssr/node/src/app-engine.ts | 50 +---- packages/angular/ssr/node/src/request.ts | 9 +- packages/angular/ssr/src/app-engine.ts | 54 +++--- packages/angular/ssr/src/app.ts | 181 ++++++++++++++---- packages/angular/ssr/src/assets.ts | 10 + packages/angular/ssr/src/manifest.ts | 20 +- packages/angular/ssr/test/app-engine_spec.ts | 56 +----- packages/angular/ssr/test/app_spec.ts | 167 ++++++++++------ packages/angular/ssr/test/testing-utils.ts | 4 +- .../application-builder/server.ts.template | 16 +- ...tes-output-mode-server-platform-neutral.ts | 2 +- ...er-routes-output-mode-static-http-calls.ts | 2 +- .../e2e/tests/vite/ssr-entry-express.ts | 2 +- .../e2e/tests/vite/ssr-entry-fastify.ts | 2 +- .../legacy-cli/e2e/tests/vite/ssr-entry-h3.ts | 2 +- .../e2e/tests/vite/ssr-entry-hono.ts | 2 +- tests/legacy-cli/e2e/utils/packages.ts | 31 +-- 22 files changed, 357 insertions(+), 274 deletions(-) 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); } }