Skip to content

Commit 505521e

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.
1 parent 7c6cb95 commit 505521e

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

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

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

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

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

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

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

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

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