Skip to content

Commit f460b91

Browse files
committed
perf(@angular/ssr): integrate ETags for prerendered pages
When using the new developer preview API to serve prerendered pages, ETags are added automatically, enabling efficient caching and content validation for improved performance. (cherry picked from commit 505521e)
1 parent b847d44 commit f460b91

File tree

9 files changed

+134
-101
lines changed

9 files changed

+134
-101
lines changed

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

+6-23
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
*/
88

99
import { BuilderContext } from '@angular-devkit/architect';
10-
import assert from 'node:assert';
1110
import { SourceFileCache } from '../../tools/esbuild/angular/source-file-cache';
1211
import { generateBudgetStats } from '../../tools/esbuild/budget-stats';
1312
import {
@@ -36,7 +35,6 @@ import { optimizeChunks } from './chunk-optimizer';
3635
import { executePostBundleSteps } from './execute-post-bundle';
3736
import { inlineI18n, loadActiveTranslations } from './i18n';
3837
import { NormalizedApplicationBuildOptions } from './options';
39-
import { OutputMode } from './schema';
4038
import { createComponentStyleBundler, setupBundlerContexts } from './setup-bundling';
4139

4240
// eslint-disable-next-line max-lines-per-function
@@ -224,7 +222,7 @@ export async function executeBuild(
224222
if (serverEntryPoint) {
225223
executionResult.addOutputFile(
226224
SERVER_APP_ENGINE_MANIFEST_FILENAME,
227-
generateAngularServerAppEngineManifest(i18nOptions, baseHref, undefined),
225+
generateAngularServerAppEngineManifest(i18nOptions, baseHref),
228226
BuildOutputFileType.ServerRoot,
229227
);
230228
}
@@ -257,26 +255,11 @@ export async function executeBuild(
257255
executionResult.assetFiles.push(...result.additionalAssets);
258256
}
259257

260-
if (serverEntryPoint) {
261-
const prerenderedRoutes = executionResult.prerenderedRoutes;
262-
263-
// Regenerate the manifest to append prerendered routes data. This is only needed if SSR is enabled.
264-
if (outputMode === OutputMode.Server && Object.keys(prerenderedRoutes).length) {
265-
const manifest = executionResult.outputFiles.find(
266-
(f) => f.path === SERVER_APP_ENGINE_MANIFEST_FILENAME,
267-
);
268-
assert(manifest, `${SERVER_APP_ENGINE_MANIFEST_FILENAME} was not found in output files.`);
269-
manifest.contents = new TextEncoder().encode(
270-
generateAngularServerAppEngineManifest(i18nOptions, baseHref, prerenderedRoutes),
271-
);
272-
}
273-
274-
executionResult.addOutputFile(
275-
'prerendered-routes.json',
276-
JSON.stringify({ routes: prerenderedRoutes }, null, 2),
277-
BuildOutputFileType.Root,
278-
);
279-
}
258+
executionResult.addOutputFile(
259+
'prerendered-routes.json',
260+
JSON.stringify({ routes: executionResult.prerenderedRoutes }, null, 2),
261+
BuildOutputFileType.Root,
262+
);
280263

281264
// Write metafile if stats option is enabled
282265
if (options.stats) {

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

+3-19
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,10 @@ function escapeUnsafeChars(str: string): string {
5050
* includes settings for inlining locales and determining the output structure.
5151
* @param baseHref - The base HREF for the application. This is used to set the base URL
5252
* for all relative URLs in the application.
53-
* @param perenderedRoutes - A record mapping static paths to their associated data.
54-
* @returns A string representing the content of the SSR server manifest for App Engine.
5553
*/
5654
export function generateAngularServerAppEngineManifest(
5755
i18nOptions: NormalizedApplicationBuildOptions['i18nOptions'],
5856
baseHref: string | undefined,
59-
perenderedRoutes: PrerenderedRoutesRecord | undefined = {},
6057
): string {
6158
const entryPointsContent: string[] = [];
6259

@@ -78,25 +75,10 @@ export function generateAngularServerAppEngineManifest(
7875
entryPointsContent.push(`['', () => import('./${MAIN_SERVER_OUTPUT_FILENAME}')]`);
7976
}
8077

81-
const staticHeaders: string[] = [];
82-
for (const [path, { headers }] of Object.entries(perenderedRoutes)) {
83-
if (!headers) {
84-
continue;
85-
}
86-
87-
const headersValues: string[] = [];
88-
for (const [name, value] of Object.entries(headers)) {
89-
headersValues.push(`['${name}', '${encodeURIComponent(value)}']`);
90-
}
91-
92-
staticHeaders.push(`['${path}', [${headersValues.join(', ')}]]`);
93-
}
94-
9578
const manifestContent = `
9679
export default {
9780
basePath: '${baseHref ?? '/'}',
9881
entryPoints: new Map([${entryPointsContent.join(', \n')}]),
99-
staticPathsHeaders: new Map([${staticHeaders.join(', \n')}]),
10082
};
10183
`;
10284

@@ -136,7 +118,9 @@ export function generateAngularServerAppManifest(
136118
for (const file of [...additionalHtmlOutputFiles.values(), ...outputFiles]) {
137119
const extension = extname(file.path);
138120
if (extension === '.html' || (inlineCriticalCss && extension === '.css')) {
139-
serverAssetsContent.push(`['${file.path}', async () => \`${escapeUnsafeChars(file.text)}\`]`);
121+
serverAssetsContent.push(
122+
`['${file.path}', { size: ${file.size}, hash: '${file.hash}', text: async () => \`${escapeUnsafeChars(file.text)}\`}]`,
123+
);
140124
}
141125
}
142126

packages/angular/ssr/src/app.ts

+19-22
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,6 @@ import { LRUCache } from './utils/lru-cache';
2020
import { AngularBootstrap, renderAngular } from './utils/ng';
2121
import { joinUrlParts, stripIndexHtmlFromURL, stripLeadingSlash } from './utils/url';
2222

23-
/**
24-
* The default maximum age in seconds.
25-
* Represents the total number of seconds in a 365-day period.
26-
*/
27-
const DEFAULT_MAX_AGE = 365 * 24 * 60 * 60;
28-
2923
/**
3024
* Maximum number of critical CSS entries the cache can store.
3125
* This value determines the capacity of the LRU (Least Recently Used) cache, which stores critical CSS for pages.
@@ -188,18 +182,19 @@ export class AngularServerApp {
188182
return null;
189183
}
190184

191-
// TODO(alanagius): handle etags
192-
193-
const content = await this.assets.getServerAsset(assetPath);
194-
195-
return new Response(content, {
196-
headers: {
197-
'Content-Type': 'text/html;charset=UTF-8',
198-
// 30 days in seconds
199-
'Cache-Control': `max-age=${DEFAULT_MAX_AGE}`,
200-
...headers,
201-
},
202-
});
185+
const { text, hash, size } = this.assets.getServerAsset(assetPath);
186+
const etag = `"${hash}"`;
187+
188+
return request.headers.get('if-none-match') === etag
189+
? new Response(undefined, { status: 304, statusText: 'Not Modified' })
190+
: new Response(await text(), {
191+
headers: {
192+
'Content-Length': size.toString(),
193+
'ETag': etag,
194+
'Content-Type': 'text/html;charset=UTF-8',
195+
...headers,
196+
},
197+
});
203198
}
204199

205200
/**
@@ -309,8 +304,10 @@ export class AngularServerApp {
309304
},
310305
);
311306
} else if (renderMode === RenderMode.Client) {
312-
// Serve the client-side rendered version if the route is configured for CSR.
313-
return new Response(await this.assets.getServerAsset('index.csr.html'), responseInit);
307+
return new Response(
308+
await this.assets.getServerAsset('index.csr.html').text(),
309+
responseInit,
310+
);
314311
}
315312
}
316313

@@ -327,7 +324,7 @@ export class AngularServerApp {
327324
});
328325
}
329326

330-
let html = await assets.getIndexServerHtml();
327+
let html = await assets.getIndexServerHtml().text();
331328
// Skip extra microtask if there are no pre hooks.
332329
if (hooks.has('html:transform:pre')) {
333330
html = await hooks.run('html:transform:pre', { html, url });
@@ -348,7 +345,7 @@ export class AngularServerApp {
348345
this.inlineCriticalCssProcessor ??= new InlineCriticalCssProcessor((path: string) => {
349346
const fileName = path.split('/').pop() ?? path;
350347

351-
return this.assets.getServerAsset(fileName);
348+
return this.assets.getServerAsset(fileName).text();
352349
});
353350

354351
// TODO(alanagius): remove once Node.js version 18 is no longer supported.

packages/angular/ssr/src/assets.ts

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

9-
import { AngularAppManifest } from './manifest';
9+
import { AngularAppManifest, ServerAsset } from './manifest';
1010

1111
/**
1212
* Manages server-side assets.
@@ -22,17 +22,17 @@ export class ServerAssets {
2222
/**
2323
* Retrieves the content of a server-side asset using its path.
2424
*
25-
* @param path - The path to the server asset.
26-
* @returns A promise that resolves to the asset content as a string.
27-
* @throws Error If the asset path is not found in the manifest, an error is thrown.
25+
* @param path - The path to the server asset within the manifest.
26+
* @returns The server asset associated with the provided path, as a `ServerAsset` object.
27+
* @throws Error - Throws an error if the asset does not exist.
2828
*/
29-
async getServerAsset(path: string): Promise<string> {
29+
getServerAsset(path: string): ServerAsset {
3030
const asset = this.manifest.assets.get(path);
3131
if (!asset) {
3232
throw new Error(`Server asset '${path}' does not exist.`);
3333
}
3434

35-
return asset();
35+
return asset;
3636
}
3737

3838
/**
@@ -46,12 +46,12 @@ export class ServerAssets {
4646
}
4747

4848
/**
49-
* Retrieves and caches the content of 'index.server.html'.
49+
* Retrieves the asset for 'index.server.html'.
5050
*
51-
* @returns A promise that resolves to the content of 'index.server.html'.
52-
* @throws Error If there is an issue retrieving the asset.
51+
* @returns The `ServerAsset` object for 'index.server.html'.
52+
* @throws Error - Throws an error if 'index.server.html' does not exist.
5353
*/
54-
getIndexServerHtml(): Promise<string> {
54+
getIndexServerHtml(): ServerAsset {
5555
return this.getServerAsset('index.server.html');
5656
}
5757
}

packages/angular/ssr/src/manifest.ts

+19-2
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,26 @@ import type { SerializableRouteTreeNode } from './routes/route-tree';
1010
import { AngularBootstrap } from './utils/ng';
1111

1212
/**
13-
* A function that returns a promise resolving to the file contents of the asset.
13+
* Represents of a server asset stored in the manifest.
1414
*/
15-
export type ServerAsset = () => Promise<string>;
15+
export interface ServerAsset {
16+
/**
17+
* Retrieves the text content of the asset.
18+
*
19+
* @returns A promise that resolves to the asset's content as a string.
20+
*/
21+
text: () => Promise<string>;
22+
23+
/**
24+
* A hash string representing the asset's content.
25+
*/
26+
hash: string;
27+
28+
/**
29+
* The size of the asset's content in bytes.
30+
*/
31+
size: number;
32+
}
1633

1734
/**
1835
* Represents the exports of an Angular server application entry point.

packages/angular/ssr/src/routes/ng-routes.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -516,7 +516,7 @@ export async function extractRoutesAndCreateRouteTree(
516516
includePrerenderFallbackRoutes = true,
517517
): Promise<{ routeTree: RouteTree; errors: string[] }> {
518518
const routeTree = new RouteTree();
519-
const document = await new ServerAssets(manifest).getIndexServerHtml();
519+
const document = await new ServerAssets(manifest).getIndexServerHtml().text();
520520
const bootstrap = await manifest.bootstrap();
521521
const { baseHref, routes, errors } = await getRoutesFromAngularRouterConfig(
522522
bootstrap,

packages/angular/ssr/test/app_spec.ts

+49-12
Original file line numberDiff line numberDiff line change
@@ -72,17 +72,21 @@ describe('AngularServerApp', () => {
7272
],
7373
undefined,
7474
{
75-
'home-ssg/index.html': async () =>
76-
`<html>
77-
<head>
78-
<title>SSG home page</title>
79-
<base href="/" />
80-
</head>
81-
<body>
82-
<app-root>Home SSG works</app-root>
83-
</body>
84-
</html>
85-
`,
75+
'home-ssg/index.html': {
76+
text: async () =>
77+
`<html>
78+
<head>
79+
<title>SSG home page</title>
80+
<base href="/" />
81+
</head>
82+
<body>
83+
<app-root>Home SSG works</app-root>
84+
</body>
85+
</html>
86+
`,
87+
size: 28,
88+
hash: 'f799132d0a09e0fef93c68a12e443527700eb59e6f67fcb7854c3a60ff082fde',
89+
},
8690
},
8791
);
8892

@@ -183,7 +187,40 @@ describe('AngularServerApp', () => {
183187
const response = await app.handle(new Request('http://localhost/home-ssg'));
184188
const headers = response?.headers.entries() ?? [];
185189
expect(Object.fromEntries(headers)).toEqual({
186-
'cache-control': 'max-age=31536000',
190+
'etag': '"f799132d0a09e0fef93c68a12e443527700eb59e6f67fcb7854c3a60ff082fde"',
191+
'content-length': '28',
192+
'x-some-header': 'value',
193+
'content-type': 'text/html;charset=UTF-8',
194+
});
195+
});
196+
197+
it('should return 304 Not Modified when ETag matches', async () => {
198+
const url = 'http://localhost/home-ssg';
199+
200+
const initialResponse = await app.handle(new Request(url));
201+
const etag = initialResponse?.headers.get('etag');
202+
203+
expect(etag).toBeDefined();
204+
205+
const conditionalResponse = await app.handle(
206+
new Request(url, {
207+
headers: {
208+
'If-None-Match': etag as string,
209+
},
210+
}),
211+
);
212+
213+
// Check that the response status is 304 Not Modified
214+
expect(conditionalResponse?.status).toBe(304);
215+
expect(await conditionalResponse?.text()).toBe('');
216+
});
217+
218+
it('should return configured headers for pages with specific header settings', async () => {
219+
const response = await app.handle(new Request('http://localhost/home-ssg'));
220+
const headers = response?.headers.entries() ?? [];
221+
expect(Object.fromEntries(headers)).toEqual({
222+
'etag': '"f799132d0a09e0fef93c68a12e443527700eb59e6f67fcb7854c3a60ff082fde"',
223+
'content-length': '28',
187224
'x-some-header': 'value',
188225
'content-type': 'text/html;charset=UTF-8',
189226
});

packages/angular/ssr/test/assets_spec.ts

+15-7
Original file line numberDiff line numberDiff line change
@@ -16,26 +16,34 @@ describe('ServerAsset', () => {
1616
bootstrap: undefined as never,
1717
assets: new Map(
1818
Object.entries({
19-
'index.server.html': async () => '<html>Index</html>',
20-
'index.other.html': async () => '<html>Other</html>',
19+
'index.server.html': {
20+
text: async () => '<html>Index</html>',
21+
size: 18,
22+
hash: 'f799132d0a09e0fef93c68a12e443527700eb59e6f67fcb7854c3a60ff082fde',
23+
},
24+
'index.other.html': {
25+
text: async () => '<html>Other</html>',
26+
size: 18,
27+
hash: '4a455a99366921d396f5d51c7253c4678764f5e9487f2c27baaa0f33553c8ce3',
28+
},
2129
}),
2230
),
2331
});
2432
});
2533

2634
it('should retrieve and cache the content of index.server.html', async () => {
27-
const content = await assetManager.getIndexServerHtml();
35+
const content = await assetManager.getIndexServerHtml().text();
2836
expect(content).toBe('<html>Index</html>');
2937
});
3038

31-
it('should throw an error if the asset path does not exist', async () => {
32-
await expectAsync(assetManager.getServerAsset('nonexistent.html')).toBeRejectedWithError(
39+
it('should throw an error if the asset path does not exist', () => {
40+
expect(() => assetManager.getServerAsset('nonexistent.html')).toThrowError(
3341
"Server asset 'nonexistent.html' does not exist.",
3442
);
3543
});
3644

3745
it('should retrieve the content of index.other.html', async () => {
38-
const content = await assetManager.getServerAsset('index.other.html');
39-
expect(content).toBe('<html>Other</html>');
46+
const asset = await assetManager.getServerAsset('index.other.html').text();
47+
expect(asset).toBe('<html>Other</html>');
4048
});
4149
});

0 commit comments

Comments
 (0)