Skip to content

Commit b1c6fd6

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 8e2829c commit b1c6fd6

File tree

13 files changed

+224
-108
lines changed

13 files changed

+224
-108
lines changed

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ import { EnvironmentProviders } from '@angular/core';
88

99
// @public
1010
export class AngularAppEngine {
11-
getPrerenderHeaders(request: Request): ReadonlyMap<string, string>;
11+
process(request: Request, requestContext?: unknown): Promise<Response | null>;
1212
render(request: Request, requestContext?: unknown): Promise<Response | null>;
13+
serve(request: Request): Promise<Response | null>;
1314
static ɵhooks: Hooks;
1415
}
1516

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import { Type } from '@angular/core';
1212

1313
// @public
1414
export class AngularNodeAppEngine {
15-
getPrerenderHeaders(request: IncomingMessage): ReadonlyMap<string, string>;
15+
process(request: IncomingMessage, requestContext?: unknown): Promise<Response | null>;
1616
render(request: IncomingMessage, requestContext?: unknown): Promise<Response | null>;
17+
serve(request: IncomingMessage): Promise<Response | null>;
1718
}
1819

1920
// @public

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,13 +175,15 @@ 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;
182183
}
183184
case RouteRenderMode.Server:
184185
case RouteRenderMode.Client:
186+
case RouteRenderMode.AppShell:
185187
serializableRouteTreeNodeForManifest.push(metadata);
186188

187189
break;

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

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -135,11 +135,7 @@ export function generateAngularServerAppManifest(
135135
): string {
136136
const serverAssetsContent: string[] = [];
137137
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-
) {
138+
if (file.path.endsWith('.html') || (inlineCriticalCss && file.path.endsWith('.css'))) {
143139
serverAssetsContent.push(`['${file.path}', async () => \`${escapeUnsafeChars(file.text)}\`]`);
144140
}
145141
}

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

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -43,30 +43,37 @@ export class AngularNodeAppEngine {
4343
}
4444

4545
/**
46-
* Retrieves HTTP headers for a request associated with statically generated (SSG) pages,
47-
* based on the URL pathname.
46+
* Serves a prerendered page for a given request.
4847
*
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();
48+
* This method examines the incoming request to determine if it corresponds to a known prerendered page.
49+
* If a matching page is found, it returns a `Response` object containing the page content; otherwise, it returns `null`.
5550
*
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);
51+
* @param request - The incoming HTTP request for a prerendered page.
52+
* @returns A promise that resolves to a `Response` object containing the prerendered page content, or `null`
53+
* if no matching page is found.
54+
*/
55+
serve(request: IncomingMessage): Promise<Response | null> {
56+
return this.angularAppEngine.serve(createWebRequestFromNodeRequest(request));
57+
}
58+
59+
/**
60+
* Handles incoming HTTP requests by serving prerendered content or rendering the page.
6061
*
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));
62+
* This method first attempts to serve a prerendered page. If the prerendered page is not available,
63+
* it falls back to rendering the requested page using server-side rendering. The function returns
64+
* a promise that resolves to the appropriate HTTP response.
65+
*
66+
* @param request - The incoming HTTP request for a prerendered page.
67+
* @param requestContext - Optional additional context for rendering, such as request metadata.
68+
* @returns A promise that resolves to the HTTP response object resulting from the request handling,
69+
* or null if no matching content is found.
70+
*/
71+
async process(request: IncomingMessage, requestContext?: unknown): Promise<Response | null> {
72+
const webRequest = createWebRequestFromNodeRequest(request);
73+
74+
return (
75+
(await this.angularAppEngine.serve(webRequest)) ??
76+
(await this.angularAppEngine.render(webRequest, requestContext))
77+
);
7178
}
7279
}

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

Lines changed: 55 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,60 @@ export class AngularAppEngine {
6868
* rather than an application route.
6969
*/
7070
async render(request: Request, requestContext?: unknown): Promise<Response | null> {
71+
const serverApp = await this.getAngularServerAppForRequest(request);
72+
73+
return serverApp ? serverApp.render(request, requestContext) : null;
74+
}
75+
76+
/**
77+
* Serves a prerendered page for a given request.
78+
*
79+
* This method examines the incoming request to determine if it corresponds to a known prerendered page.
80+
* If a matching page is found, it returns a `Response` object containing the page content; otherwise, it returns `null`.
81+
*
82+
* @param request - The incoming HTTP request object to be rendered.
83+
* @returns A promise that resolves to a `Response` object containing the prerendered page content, or `null`
84+
* if no matching page is found.
85+
**/
86+
async serve(request: Request): Promise<Response | null> {
87+
const serverApp = await this.getAngularServerAppForRequest(request);
88+
89+
return serverApp ? serverApp.serve(request) : null;
90+
}
91+
92+
/**
93+
* Handles incoming HTTP requests by serving prerendered content or rendering the page.
94+
*
95+
* This method first attempts to serve a prerendered page. If the prerendered page is not available,
96+
* it falls back to rendering the requested page using server-side rendering. The function returns
97+
* a promise that resolves to the appropriate HTTP response.
98+
*
99+
* @param request - The incoming HTTP request to be processed.
100+
* @param requestContext - Optional additional context for rendering, such as request metadata.
101+
* @returns A promise that resolves to the HTTP response object resulting from the request handling,
102+
* or null if no matching content is found.
103+
*/
104+
async process(request: Request, requestContext?: unknown): Promise<Response | null> {
105+
const serverApp = await this.getAngularServerAppForRequest(request);
106+
if (!serverApp) {
107+
return null;
108+
}
109+
110+
return (await serverApp.serve(request)) ?? (await serverApp.render(request, requestContext));
111+
}
112+
113+
/**
114+
* Retrieves the Angular server application instance for a given request.
115+
*
116+
* This method checks if the request URL corresponds to an Angular application entry point.
117+
* If so, it initializes or retrieves an instance of the Angular server application for that entry point.
118+
* Requests that resemble file requests (except for `/index.html`) are skipped.
119+
*
120+
* @param request - The incoming HTTP request object.
121+
* @returns A promise that resolves to an `AngularServerApp` instance if a valid entry point is found,
122+
* or `null` if no entry point matches the request URL.
123+
*/
124+
private async getAngularServerAppForRequest(request: Request): Promise<AngularServerApp | null> {
71125
// Skip if the request looks like a file but not `/index.html`.
72126
const url = new URL(request.url);
73127
const entryPoint = await this.getEntryPointExportsForUrl(url);
@@ -82,26 +136,7 @@ export class AngularAppEngine {
82136
const serverApp = getOrCreateAngularServerApp() as AngularServerApp;
83137
serverApp.hooks = this.hooks;
84138

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);
139+
return serverApp;
105140
}
106141

107142
/**

packages/angular/ssr/src/app.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ import { sha256 } from './utils/crypto';
1717
import { InlineCriticalCssProcessor } from './utils/inline-critical-css';
1818
import { LRUCache } from './utils/lru-cache';
1919
import { AngularBootstrap, renderAngular } from './utils/ng';
20+
import { joinUrlParts, stripIndexHtmlFromURL, stripLeadingSlash } from './utils/url';
21+
22+
/**
23+
* The default maximum age in seconds.
24+
* Represents the total number of seconds in a 30-day period.
25+
*/
26+
const DEFAULT_MAX_AGE = 30 * 24 * 60 * 60;
2027

2128
/**
2229
* Maximum number of critical CSS entries the cache can store.
@@ -124,6 +131,66 @@ export class AngularServerApp {
124131
]);
125132
}
126133

134+
/**
135+
* Serves a static, prerendered page for the incoming request if available.
136+
*
137+
* This method attempts to match the incoming request URL with a prerendered route in the application.
138+
* If the route is found and is configured for prerendering, the corresponding static asset is retrieved
139+
* and returned as a `Response` object with the appropriate HTTP headers.
140+
*
141+
* @param request - The incoming HTTP request for a potential static page.
142+
* @returns A promise that resolves to a `Response` object containing the prerendered page content
143+
* if available, or `null` if the request does not match a prerendered route or asset.
144+
*/
145+
async serve(request: Request): Promise<Response | null> {
146+
const url = stripIndexHtmlFromURL(new URL(request.url));
147+
this.router ??= await ServerRouter.from(this.manifest, url);
148+
149+
const matchedRoute = this.router.match(new URL(request.url));
150+
if (!matchedRoute) {
151+
return null;
152+
}
153+
154+
const { headers, renderMode } = matchedRoute;
155+
if (renderMode !== RenderMode.Prerender) {
156+
return null;
157+
}
158+
159+
const assetPath = stripLeadingSlash(joinUrlParts(url.pathname, 'index.html'));
160+
if (!this.assets.hasServerAsset(assetPath)) {
161+
return null;
162+
}
163+
164+
// TODO(alanagius): handle etags
165+
166+
const content = await this.assets.getServerAsset(assetPath);
167+
168+
return new Response(content, {
169+
headers: {
170+
'Content-Type': 'text/html;charset=UTF-8',
171+
// 30 days in seconds
172+
'Cache-Control': `max-age=${DEFAULT_MAX_AGE}`,
173+
...headers,
174+
},
175+
});
176+
}
177+
178+
/**
179+
* Handles incoming HTTP requests by serving prerendered content or rendering the page.
180+
*
181+
* This method first attempts to serve a prerendered page. If the prerendered page is not available,
182+
* it falls back to rendering the requested page using server-side rendering. The function returns
183+
* a promise that resolves to the appropriate HTTP response.
184+
*
185+
* @param request - The incoming HTTP request to be processed.
186+
* @param requestContext - Optional additional context for rendering, such as request metadata.
187+
* @returns A promise that resolves to the HTTP response object resulting from the request handling,
188+
* or null if no matching content is found.
189+
*/
190+
async process(request: Request, requestContext?: unknown): Promise<Response | null> {
191+
return (await this.serve(request)) ?? (await this.render(request, requestContext));
192+
}
193+
127194
/**
128195
* Creates a promise that rejects when the request is aborted.
129196
*

packages/angular/ssr/src/assets.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ export class ServerAssets {
3535
return asset();
3636
}
3737

38+
/**
39+
* Checks if a specific server-side asset exists.
40+
*
41+
* @param path - The path to the server asset.
42+
* @returns A boolean indicating whether the asset exists.
43+
*/
44+
hasServerAsset(path: string): boolean {
45+
return this.manifest.assets.has(path);
46+
}
47+
3848
/**
3949
* Retrieves and caches the content of 'index.server.html'.
4050
*

packages/angular/ssr/src/manifest.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -43,19 +43,6 @@ export interface AngularAppEngineManifest {
4343
* This is used to determine the root path of the application.
4444
*/
4545
readonly basePath: string;
46-
47-
/**
48-
* A map that associates static paths with their corresponding HTTP headers.
49-
* Each entry in the map consists of:
50-
* - `key`: The static path as a string.
51-
* - `value`: An array of tuples, where each tuple contains:
52-
* - `headerName`: The name of the HTTP header.
53-
* - `headerValue`: The value of the HTTP header.
54-
*/
55-
readonly staticPathsHeaders: ReadonlyMap<
56-
string,
57-
readonly [headerName: string, headerValue: string][]
58-
>;
5946
}
6047

6148
/**

packages/angular/ssr/test/app-engine_spec.ts

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,6 @@ describe('AngularAppEngine', () => {
5353
]),
5454
),
5555
basePath: '',
56-
staticPathsHeaders: new Map([
57-
[
58-
'/about',
59-
[
60-
['Cache-Control', 'no-cache'],
61-
['X-Some-Header', 'value'],
62-
],
63-
],
64-
]),
6556
});
6657

6758
appEngine = new AngularAppEngine();
@@ -104,32 +95,6 @@ describe('AngularAppEngine', () => {
10495
expect(response).toBeNull();
10596
});
10697
});
107-
108-
describe('getPrerenderHeaders', () => {
109-
it('should return headers for a known path without index.html', () => {
110-
const request = new Request('https://example.com/about');
111-
const headers = appEngine.getPrerenderHeaders(request);
112-
expect(Object.fromEntries(headers.entries())).toEqual({
113-
'Cache-Control': 'no-cache',
114-
'X-Some-Header': 'value',
115-
});
116-
});
117-
118-
it('should return headers for a known path with index.html', () => {
119-
const request = new Request('https://example.com/about/index.html');
120-
const headers = appEngine.getPrerenderHeaders(request);
121-
expect(Object.fromEntries(headers.entries())).toEqual({
122-
'Cache-Control': 'no-cache',
123-
'X-Some-Header': 'value',
124-
});
125-
});
126-
127-
it('should return no headers for unknown paths', () => {
128-
const request = new Request('https://example.com/unknown/path');
129-
const headers = appEngine.getPrerenderHeaders(request);
130-
expect(headers).toHaveSize(0);
131-
});
132-
});
13398
});
13499

135100
describe('Non-localized app', () => {
@@ -161,7 +126,6 @@ describe('AngularAppEngine', () => {
161126
],
162127
]),
163128
basePath: '',
164-
staticPathsHeaders: new Map(),
165129
});
166130

167131
appEngine = new AngularAppEngine();

0 commit comments

Comments
 (0)