Skip to content

Commit 3343b4b

Browse files
committed
perf(@angular/ssr): integrate ETags for prerendered pages
Leverages the new developer preview API to serve prerendered pages with ETags, enabling effective caching and content validation for optimized performance.
1 parent 481ccdb commit 3343b4b

File tree

9 files changed

+128
-97
lines changed

9 files changed

+128
-97
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
@@ -213,7 +211,7 @@ export async function executeBuild(
213211
if (serverEntryPoint) {
214212
executionResult.addOutputFile(
215213
SERVER_APP_ENGINE_MANIFEST_FILENAME,
216-
generateAngularServerAppEngineManifest(i18nOptions, baseHref, undefined),
214+
generateAngularServerAppEngineManifest(i18nOptions, baseHref),
217215
BuildOutputFileType.ServerRoot,
218216
);
219217
}
@@ -246,26 +244,11 @@ export async function executeBuild(
246244
executionResult.assetFiles.push(...result.additionalAssets);
247245
}
248246

249-
if (serverEntryPoint) {
250-
const prerenderedRoutes = executionResult.prerenderedRoutes;
251-
252-
// Regenerate the manifest to append prerendered routes data. This is only needed if SSR is enabled.
253-
if (outputMode === OutputMode.Server && Object.keys(prerenderedRoutes).length) {
254-
const manifest = executionResult.outputFiles.find(
255-
(f) => f.path === SERVER_APP_ENGINE_MANIFEST_FILENAME,
256-
);
257-
assert(manifest, `${SERVER_APP_ENGINE_MANIFEST_FILENAME} was not found in output files.`);
258-
manifest.contents = new TextEncoder().encode(
259-
generateAngularServerAppEngineManifest(i18nOptions, baseHref, prerenderedRoutes),
260-
);
261-
}
262-
263-
executionResult.addOutputFile(
264-
'prerendered-routes.json',
265-
JSON.stringify({ routes: prerenderedRoutes }, null, 2),
266-
BuildOutputFileType.Root,
267-
);
268-
}
247+
executionResult.addOutputFile(
248+
'prerendered-routes.json',
249+
JSON.stringify({ routes: executionResult.prerenderedRoutes }, null, 2),
250+
BuildOutputFileType.Root,
251+
);
269252

270253
// Write metafile if stats option is enabled
271254
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: 6 additions & 6 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.
@@ -26,13 +26,13 @@ export class ServerAssets {
2626
* @returns A promise that resolves to the asset content as a string.
2727
* @throws Error If the asset path is not found in the manifest, an error is thrown.
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'.
51+
* @returns A promise that resolves to 'index.server.html'.
5252
* @throws Error If there is an issue retrieving the asset.
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: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,24 @@ 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 an asset on the server.
1414
*/
15-
export type ServerAsset = () => Promise<string>;
15+
export interface ServerAsset {
16+
/**
17+
* The text representation of the asset.
18+
*/
19+
text: () => Promise<string>;
20+
21+
/**
22+
* The hash of the asset, used for integrity checks.
23+
*/
24+
hash: string;
25+
26+
/**
27+
* The length of the contents in bytes.
28+
*/
29+
size: number;
30+
}
1631

1732
/**
1833
* 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)