Skip to content

Commit df4e1d3

Browse files
committed
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 481ccdb)
1 parent 4ae1fd2 commit df4e1d3

File tree

22 files changed

+357
-274
lines changed

22 files changed

+357
-274
lines changed

goldens/public-api/angular/ssr/index.api.md

+1-2
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@ import { EnvironmentProviders } from '@angular/core';
88

99
// @public
1010
export class AngularAppEngine {
11-
getPrerenderHeaders(request: Request): ReadonlyMap<string, string>;
12-
render(request: Request, requestContext?: unknown): Promise<Response | null>;
11+
handle(request: Request, requestContext?: unknown): Promise<Response | null>;
1312
static ɵhooks: Hooks;
1413
}
1514

goldens/public-api/angular/ssr/node/index.api.md

+1-2
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@ import { Type } from '@angular/core';
1212

1313
// @public
1414
export class AngularNodeAppEngine {
15-
getPrerenderHeaders(request: IncomingMessage): ReadonlyMap<string, string>;
16-
render(request: IncomingMessage, requestContext?: unknown): Promise<Response | null>;
15+
handle(request: IncomingMessage, requestContext?: unknown): Promise<Response | null>;
1716
}
1817

1918
// @public

packages/angular/build/src/builders/application/execute-post-bundle.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,8 @@ export async function executePostBundleSteps(
175175
switch (metadata.renderMode) {
176176
case RouteRenderMode.Prerender:
177177
case /* Legacy building mode */ undefined: {
178-
if (!metadata.redirectTo || outputMode === OutputMode.Static) {
178+
if (!metadata.redirectTo) {
179+
serializableRouteTreeNodeForManifest.push(metadata);
179180
prerenderedRoutes[metadata.route] = { headers: metadata.headers };
180181
}
181182
break;

packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export function createAngularSsrInternalMiddleware(
5959
const webReq = new Request(createWebRequestFromNodeRequest(req), {
6060
signal: AbortSignal.timeout(30_000),
6161
});
62-
const webRes = await angularServerApp.render(webReq);
62+
const webRes = await angularServerApp.handle(webReq);
6363
if (!webRes) {
6464
return next();
6565
}

packages/angular/build/src/utils/server-rendering/manifest.ts

+3-7
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,8 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
import { extname } from 'node:path';
910
import {
10-
INDEX_HTML_CSR,
11-
INDEX_HTML_SERVER,
1211
NormalizedApplicationBuildOptions,
1312
getLocaleBaseHref,
1413
} from '../../builders/application/options';
@@ -135,11 +134,8 @@ export function generateAngularServerAppManifest(
135134
): string {
136135
const serverAssetsContent: string[] = [];
137136
for (const file of [...additionalHtmlOutputFiles.values(), ...outputFiles]) {
138-
if (
139-
file.path === INDEX_HTML_SERVER ||
140-
file.path === INDEX_HTML_CSR ||
141-
(inlineCriticalCss && file.path.endsWith('.css'))
142-
) {
137+
const extension = extname(file.path);
138+
if (extension === '.html' || (inlineCriticalCss && extension === '.css')) {
143139
serverAssetsContent.push(`['${file.path}', async () => \`${escapeUnsafeChars(file.text)}\`]`);
144140
}
145141
}

packages/angular/ssr/node/src/app-engine.ts

+10-40
Original file line numberDiff line numberDiff line change
@@ -24,49 +24,19 @@ export class AngularNodeAppEngine {
2424
private readonly angularAppEngine = new AngularAppEngine();
2525

2626
/**
27-
* Renders an HTTP response based on the incoming request using the Angular server application.
27+
* Handles an incoming HTTP request by serving prerendered content, performing server-side rendering,
28+
* or delivering a static file for client-side rendered routes based on the `RenderMode` setting.
2829
*
29-
* The method processes the incoming request, determines the appropriate route, and prepares the
30-
* rendering context to generate a response. If the request URL corresponds to a static file (excluding `/index.html`),
31-
* the method returns `null`.
30+
* @param request - The HTTP request to handle.
31+
* @param requestContext - Optional context for rendering, such as metadata associated with the request.
32+
* @returns A promise that resolves to the resulting HTTP response object, or `null` if no matching Angular route is found.
3233
*
33-
* Example: A request to `https://www.example.com/page/index.html` will render the Angular route
34-
* associated with `https://www.example.com/page`.
35-
*
36-
* @param request - The incoming HTTP request object to be rendered.
37-
* @param requestContext - Optional additional context for the request, such as metadata or custom settings.
38-
* @returns A promise that resolves to a `Response` object, or `null` if the request URL is for a static file
39-
* (e.g., `./logo.png`) rather than an application route.
34+
* @note A request to `https://www.example.com/page/index.html` will serve or render the Angular route
35+
* corresponding to `https://www.example.com/page`.
4036
*/
41-
render(request: IncomingMessage, requestContext?: unknown): Promise<Response | null> {
42-
return this.angularAppEngine.render(createWebRequestFromNodeRequest(request), requestContext);
43-
}
37+
async handle(request: IncomingMessage, requestContext?: unknown): Promise<Response | null> {
38+
const webRequest = createWebRequestFromNodeRequest(request);
4439

45-
/**
46-
* Retrieves HTTP headers for a request associated with statically generated (SSG) pages,
47-
* based on the URL pathname.
48-
*
49-
* @param request - The incoming request object.
50-
* @returns A `Map` containing the HTTP headers as key-value pairs.
51-
* @note This function should be used exclusively for retrieving headers of SSG pages.
52-
* @example
53-
* ```typescript
54-
* const angularAppEngine = new AngularNodeAppEngine();
55-
*
56-
* app.use(express.static('dist/browser', {
57-
* setHeaders: (res, path) => {
58-
* // Retrieve headers for the current request
59-
* const headers = angularAppEngine.getPrerenderHeaders(res.req);
60-
*
61-
* // Apply the retrieved headers to the response
62-
* for (const [key, value] of headers) {
63-
* res.setHeader(key, value);
64-
* }
65-
* }
66-
}));
67-
* ```
68-
*/
69-
getPrerenderHeaders(request: IncomingMessage): ReadonlyMap<string, string> {
70-
return this.angularAppEngine.getPrerenderHeaders(createWebRequestFromNodeRequest(request));
40+
return this.angularAppEngine.handle(webRequest, requestContext);
7141
}
7242
}

packages/angular/ssr/node/src/request.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,12 @@ function createRequestHeaders(nodeHeaders: IncomingHttpHeaders): Headers {
5656
* @returns A `URL` object representing the request URL.
5757
*/
5858
function createRequestUrl(nodeRequest: IncomingMessage): URL {
59-
const { headers, socket, url = '' } = nodeRequest;
59+
const {
60+
headers,
61+
socket,
62+
url = '',
63+
originalUrl,
64+
} = nodeRequest as IncomingMessage & { originalUrl?: string };
6065
const protocol =
6166
headers['x-forwarded-proto'] ?? ('encrypted' in socket && socket.encrypted ? 'https' : 'http');
6267
const hostname = headers['x-forwarded-host'] ?? headers.host ?? headers[':authority'];
@@ -71,5 +76,5 @@ function createRequestUrl(nodeRequest: IncomingMessage): URL {
7176
hostnameWithPort += `:${port}`;
7277
}
7378

74-
return new URL(url, `${protocol}://${hostnameWithPort}`);
79+
return new URL(originalUrl ?? url, `${protocol}://${hostnameWithPort}`);
7580
}

packages/angular/ssr/src/app-engine.ts

+24-30
Original file line numberDiff line numberDiff line change
@@ -53,21 +53,34 @@ export class AngularAppEngine {
5353
private readonly entryPointsCache = new Map<string, Promise<EntryPointExports>>();
5454

5555
/**
56-
* Renders a response for the given HTTP request using the server application.
56+
* Handles an incoming HTTP request by serving prerendered content, performing server-side rendering,
57+
* or delivering a static file for client-side rendered routes based on the `RenderMode` setting.
5758
*
58-
* This method processes the request, determines the appropriate route and rendering context,
59-
* and returns an HTTP response.
59+
* @param request - The HTTP request to handle.
60+
* @param requestContext - Optional context for rendering, such as metadata associated with the request.
61+
* @returns A promise that resolves to the resulting HTTP response object, or `null` if no matching Angular route is found.
6062
*
61-
* If the request URL appears to be for a file (excluding `/index.html`), the method returns `null`.
62-
* A request to `https://www.example.com/page/index.html` will render the Angular route
63+
* @note A request to `https://www.example.com/page/index.html` will serve or render the Angular route
6364
* corresponding to `https://www.example.com/page`.
65+
*/
66+
async handle(request: Request, requestContext?: unknown): Promise<Response | null> {
67+
const serverApp = await this.getAngularServerAppForRequest(request);
68+
69+
return serverApp ? serverApp.handle(request, requestContext) : null;
70+
}
71+
72+
/**
73+
* Retrieves the Angular server application instance for a given request.
74+
*
75+
* This method checks if the request URL corresponds to an Angular application entry point.
76+
* If so, it initializes or retrieves an instance of the Angular server application for that entry point.
77+
* Requests that resemble file requests (except for `/index.html`) are skipped.
6478
*
65-
* @param request - The incoming HTTP request object to be rendered.
66-
* @param requestContext - Optional additional context for the request, such as metadata.
67-
* @returns A promise that resolves to a Response object, or `null` if the request URL represents a file (e.g., `./logo.png`)
68-
* rather than an application route.
79+
* @param request - The incoming HTTP request object.
80+
* @returns A promise that resolves to an `AngularServerApp` instance if a valid entry point is found,
81+
* or `null` if no entry point matches the request URL.
6982
*/
70-
async render(request: Request, requestContext?: unknown): Promise<Response | null> {
83+
private async getAngularServerAppForRequest(request: Request): Promise<AngularServerApp | null> {
7184
// Skip if the request looks like a file but not `/index.html`.
7285
const url = new URL(request.url);
7386
const entryPoint = await this.getEntryPointExportsForUrl(url);
@@ -82,26 +95,7 @@ export class AngularAppEngine {
8295
const serverApp = getOrCreateAngularServerApp() as AngularServerApp;
8396
serverApp.hooks = this.hooks;
8497

85-
return serverApp.render(request, requestContext);
86-
}
87-
88-
/**
89-
* Retrieves HTTP headers for a request associated with statically generated (SSG) pages,
90-
* based on the URL pathname.
91-
*
92-
* @param request - The incoming request object.
93-
* @returns A `Map` containing the HTTP headers as key-value pairs.
94-
* @note This function should be used exclusively for retrieving headers of SSG pages.
95-
*/
96-
getPrerenderHeaders(request: Request): ReadonlyMap<string, string> {
97-
if (this.manifest.staticPathsHeaders.size === 0) {
98-
return new Map();
99-
}
100-
101-
const { pathname } = stripIndexHtmlFromURL(new URL(request.url));
102-
const headers = this.manifest.staticPathsHeaders.get(stripTrailingSlash(pathname));
103-
104-
return new Map(headers);
98+
return serverApp;
10599
}
106100

107101
/**

0 commit comments

Comments
 (0)