Skip to content

Commit 1a6c78b

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.
1 parent d6dfce1 commit 1a6c78b

File tree

20 files changed

+336
-257
lines changed

20 files changed

+336
-257
lines changed

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

Lines changed: 1 addition & 2 deletions
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

Lines changed: 1 addition & 2 deletions
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

Lines changed: 2 additions & 1 deletion
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

Lines changed: 1 addition & 1 deletion
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

Lines changed: 3 additions & 7 deletions
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

Lines changed: 12 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -24,49 +24,22 @@ 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 or performing server-side rendering.
2828
*
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`.
29+
* This method prioritizes serving a prerendered version of the requested page. If no prerendered content is available,
30+
* it falls back on server-side rendering to dynamically generate the page. The function returns a promise that resolves
31+
* to the appropriate HTTP response.
3232
*
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`.
33+
* @param request - The HTTP request to handle.
34+
* @param requestContext - Optional context for rendering, such as metadata associated with the request.
35+
* @returns A promise that resolves to the resulting HTTP response object, or `null` if no matching Angular route is found.
3536
*
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.
37+
* @note A request to `https://www.example.com/page/index.html` will serve or render the Angular route
38+
* corresponding to `https://www.example.com/page`.
4039
*/
41-
render(request: IncomingMessage, requestContext?: unknown): Promise<Response | null> {
42-
return this.angularAppEngine.render(createWebRequestFromNodeRequest(request), requestContext);
43-
}
40+
async handle(request: IncomingMessage, requestContext?: unknown): Promise<Response | null> {
41+
const webRequest = createWebRequestFromNodeRequest(request);
4442

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));
43+
return this.angularAppEngine.handle(webRequest, requestContext);
7144
}
7245
}

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

Lines changed: 27 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -53,21 +53,37 @@ 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 or performing server-side rendering.
5757
*
58-
* This method processes the request, determines the appropriate route and rendering context,
59-
* and returns an HTTP response.
58+
* This method prioritizes serving a prerendered version of the requested page. If no prerendered content is available,
59+
* it falls back on server-side rendering to dynamically generate the page. The function returns a promise that resolves
60+
* to the appropriate HTTP response.
6061
*
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
62+
* @param request - The HTTP request to handle.
63+
* @param requestContext - Optional context for rendering, such as metadata associated with the request.
64+
* @returns A promise that resolves to the resulting HTTP response object, or `null` if no matching Angular route is found.
65+
*
66+
* @note A request to `https://www.example.com/page/index.html` will serve or render the Angular route
6367
* corresponding to `https://www.example.com/page`.
68+
*/
69+
async handle(request: Request, requestContext?: unknown): Promise<Response | null> {
70+
const serverApp = await this.getAngularServerAppForRequest(request);
71+
72+
return serverApp ? serverApp.handle(request, requestContext) : null;
73+
}
74+
75+
/**
76+
* Retrieves the Angular server application instance for a given request.
6477
*
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.
78+
* This method checks if the request URL corresponds to an Angular application entry point.
79+
* If so, it initializes or retrieves an instance of the Angular server application for that entry point.
80+
* Requests that resemble file requests (except for `/index.html`) are skipped.
81+
*
82+
* @param request - The incoming HTTP request object.
83+
* @returns A promise that resolves to an `AngularServerApp` instance if a valid entry point is found,
84+
* or `null` if no entry point matches the request URL.
6985
*/
70-
async render(request: Request, requestContext?: unknown): Promise<Response | null> {
86+
private async getAngularServerAppForRequest(request: Request): Promise<AngularServerApp | null> {
7187
// Skip if the request looks like a file but not `/index.html`.
7288
const url = new URL(request.url);
7389
const entryPoint = await this.getEntryPointExportsForUrl(url);
@@ -82,26 +98,7 @@ export class AngularAppEngine {
8298
const serverApp = getOrCreateAngularServerApp() as AngularServerApp;
8399
serverApp.hooks = this.hooks;
84100

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);
101+
return serverApp;
105102
}
106103

107104
/**

0 commit comments

Comments
 (0)