Skip to content

Commit

Permalink
perf(@angular/ssr): integrate ETags for prerendered pages
Browse files Browse the repository at this point in the history
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)
  • Loading branch information
alan-agius4 committed Nov 1, 2024
1 parent b847d44 commit f460b91
Show file tree
Hide file tree
Showing 9 changed files with 134 additions and 101 deletions.
29 changes: 6 additions & 23 deletions packages/angular/build/src/builders/application/execute-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
*/

import { BuilderContext } from '@angular-devkit/architect';
import assert from 'node:assert';
import { SourceFileCache } from '../../tools/esbuild/angular/source-file-cache';
import { generateBudgetStats } from '../../tools/esbuild/budget-stats';
import {
Expand Down Expand Up @@ -36,7 +35,6 @@ import { optimizeChunks } from './chunk-optimizer';
import { executePostBundleSteps } from './execute-post-bundle';
import { inlineI18n, loadActiveTranslations } from './i18n';
import { NormalizedApplicationBuildOptions } from './options';
import { OutputMode } from './schema';
import { createComponentStyleBundler, setupBundlerContexts } from './setup-bundling';

// eslint-disable-next-line max-lines-per-function
Expand Down Expand Up @@ -224,7 +222,7 @@ export async function executeBuild(
if (serverEntryPoint) {
executionResult.addOutputFile(
SERVER_APP_ENGINE_MANIFEST_FILENAME,
generateAngularServerAppEngineManifest(i18nOptions, baseHref, undefined),
generateAngularServerAppEngineManifest(i18nOptions, baseHref),
BuildOutputFileType.ServerRoot,
);
}
Expand Down Expand Up @@ -257,26 +255,11 @@ export async function executeBuild(
executionResult.assetFiles.push(...result.additionalAssets);
}

if (serverEntryPoint) {
const prerenderedRoutes = executionResult.prerenderedRoutes;

// Regenerate the manifest to append prerendered routes data. This is only needed if SSR is enabled.
if (outputMode === OutputMode.Server && Object.keys(prerenderedRoutes).length) {
const manifest = executionResult.outputFiles.find(
(f) => f.path === SERVER_APP_ENGINE_MANIFEST_FILENAME,
);
assert(manifest, `${SERVER_APP_ENGINE_MANIFEST_FILENAME} was not found in output files.`);
manifest.contents = new TextEncoder().encode(
generateAngularServerAppEngineManifest(i18nOptions, baseHref, prerenderedRoutes),
);
}

executionResult.addOutputFile(
'prerendered-routes.json',
JSON.stringify({ routes: prerenderedRoutes }, null, 2),
BuildOutputFileType.Root,
);
}
executionResult.addOutputFile(
'prerendered-routes.json',
JSON.stringify({ routes: executionResult.prerenderedRoutes }, null, 2),
BuildOutputFileType.Root,
);

// Write metafile if stats option is enabled
if (options.stats) {
Expand Down
22 changes: 3 additions & 19 deletions packages/angular/build/src/utils/server-rendering/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,10 @@ function escapeUnsafeChars(str: string): string {
* includes settings for inlining locales and determining the output structure.
* @param baseHref - The base HREF for the application. This is used to set the base URL
* for all relative URLs in the application.
* @param perenderedRoutes - A record mapping static paths to their associated data.
* @returns A string representing the content of the SSR server manifest for App Engine.
*/
export function generateAngularServerAppEngineManifest(
i18nOptions: NormalizedApplicationBuildOptions['i18nOptions'],
baseHref: string | undefined,
perenderedRoutes: PrerenderedRoutesRecord | undefined = {},
): string {
const entryPointsContent: string[] = [];

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

const staticHeaders: string[] = [];
for (const [path, { headers }] of Object.entries(perenderedRoutes)) {
if (!headers) {
continue;
}

const headersValues: string[] = [];
for (const [name, value] of Object.entries(headers)) {
headersValues.push(`['${name}', '${encodeURIComponent(value)}']`);
}

staticHeaders.push(`['${path}', [${headersValues.join(', ')}]]`);
}

const manifestContent = `
export default {
basePath: '${baseHref ?? '/'}',
entryPoints: new Map([${entryPointsContent.join(', \n')}]),
staticPathsHeaders: new Map([${staticHeaders.join(', \n')}]),
};
`;

Expand Down Expand Up @@ -136,7 +118,9 @@ export function generateAngularServerAppManifest(
for (const file of [...additionalHtmlOutputFiles.values(), ...outputFiles]) {
const extension = extname(file.path);
if (extension === '.html' || (inlineCriticalCss && extension === '.css')) {
serverAssetsContent.push(`['${file.path}', async () => \`${escapeUnsafeChars(file.text)}\`]`);
serverAssetsContent.push(
`['${file.path}', { size: ${file.size}, hash: '${file.hash}', text: async () => \`${escapeUnsafeChars(file.text)}\`}]`,
);
}
}

Expand Down
41 changes: 19 additions & 22 deletions packages/angular/ssr/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,6 @@ import { LRUCache } from './utils/lru-cache';
import { AngularBootstrap, renderAngular } from './utils/ng';
import { joinUrlParts, stripIndexHtmlFromURL, stripLeadingSlash } from './utils/url';

/**
* The default maximum age in seconds.
* Represents the total number of seconds in a 365-day period.
*/
const DEFAULT_MAX_AGE = 365 * 24 * 60 * 60;

/**
* Maximum number of critical CSS entries the cache can store.
* This value determines the capacity of the LRU (Least Recently Used) cache, which stores critical CSS for pages.
Expand Down Expand Up @@ -188,18 +182,19 @@ export class AngularServerApp {
return null;
}

// TODO(alanagius): handle etags

const content = await this.assets.getServerAsset(assetPath);

return new Response(content, {
headers: {
'Content-Type': 'text/html;charset=UTF-8',
// 30 days in seconds
'Cache-Control': `max-age=${DEFAULT_MAX_AGE}`,
...headers,
},
});
const { text, hash, size } = this.assets.getServerAsset(assetPath);
const etag = `"${hash}"`;

return request.headers.get('if-none-match') === etag
? new Response(undefined, { status: 304, statusText: 'Not Modified' })
: new Response(await text(), {
headers: {
'Content-Length': size.toString(),
'ETag': etag,
'Content-Type': 'text/html;charset=UTF-8',
...headers,
},
});
}

/**
Expand Down Expand Up @@ -309,8 +304,10 @@ export class AngularServerApp {
},
);
} else if (renderMode === RenderMode.Client) {
// Serve the client-side rendered version if the route is configured for CSR.
return new Response(await this.assets.getServerAsset('index.csr.html'), responseInit);
return new Response(
await this.assets.getServerAsset('index.csr.html').text(),
responseInit,
);
}
}

Expand All @@ -327,7 +324,7 @@ export class AngularServerApp {
});
}

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

return this.assets.getServerAsset(fileName);
return this.assets.getServerAsset(fileName).text();
});

// TODO(alanagius): remove once Node.js version 18 is no longer supported.
Expand Down
20 changes: 10 additions & 10 deletions packages/angular/ssr/src/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/

import { AngularAppManifest } from './manifest';
import { AngularAppManifest, ServerAsset } from './manifest';

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

return asset();
return asset;
}

/**
Expand All @@ -46,12 +46,12 @@ export class ServerAssets {
}

/**
* Retrieves and caches the content of 'index.server.html'.
* Retrieves the asset for 'index.server.html'.
*
* @returns A promise that resolves to the content of 'index.server.html'.
* @throws Error If there is an issue retrieving the asset.
* @returns The `ServerAsset` object for 'index.server.html'.
* @throws Error - Throws an error if 'index.server.html' does not exist.
*/
getIndexServerHtml(): Promise<string> {
getIndexServerHtml(): ServerAsset {
return this.getServerAsset('index.server.html');
}
}
21 changes: 19 additions & 2 deletions packages/angular/ssr/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,26 @@ import type { SerializableRouteTreeNode } from './routes/route-tree';
import { AngularBootstrap } from './utils/ng';

/**
* A function that returns a promise resolving to the file contents of the asset.
* Represents of a server asset stored in the manifest.
*/
export type ServerAsset = () => Promise<string>;
export interface ServerAsset {
/**
* Retrieves the text content of the asset.
*
* @returns A promise that resolves to the asset's content as a string.
*/
text: () => Promise<string>;

/**
* A hash string representing the asset's content.
*/
hash: string;

/**
* The size of the asset's content in bytes.
*/
size: number;
}

/**
* Represents the exports of an Angular server application entry point.
Expand Down
2 changes: 1 addition & 1 deletion packages/angular/ssr/src/routes/ng-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -516,7 +516,7 @@ export async function extractRoutesAndCreateRouteTree(
includePrerenderFallbackRoutes = true,
): Promise<{ routeTree: RouteTree; errors: string[] }> {
const routeTree = new RouteTree();
const document = await new ServerAssets(manifest).getIndexServerHtml();
const document = await new ServerAssets(manifest).getIndexServerHtml().text();
const bootstrap = await manifest.bootstrap();
const { baseHref, routes, errors } = await getRoutesFromAngularRouterConfig(
bootstrap,
Expand Down
61 changes: 49 additions & 12 deletions packages/angular/ssr/test/app_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,17 +72,21 @@ describe('AngularServerApp', () => {
],
undefined,
{
'home-ssg/index.html': async () =>
`<html>
<head>
<title>SSG home page</title>
<base href="/" />
</head>
<body>
<app-root>Home SSG works</app-root>
</body>
</html>
`,
'home-ssg/index.html': {
text: async () =>
`<html>
<head>
<title>SSG home page</title>
<base href="/" />
</head>
<body>
<app-root>Home SSG works</app-root>
</body>
</html>
`,
size: 28,
hash: 'f799132d0a09e0fef93c68a12e443527700eb59e6f67fcb7854c3a60ff082fde',
},
},
);

Expand Down Expand Up @@ -183,7 +187,40 @@ describe('AngularServerApp', () => {
const response = await app.handle(new Request('http://localhost/home-ssg'));
const headers = response?.headers.entries() ?? [];
expect(Object.fromEntries(headers)).toEqual({
'cache-control': 'max-age=31536000',
'etag': '"f799132d0a09e0fef93c68a12e443527700eb59e6f67fcb7854c3a60ff082fde"',
'content-length': '28',
'x-some-header': 'value',
'content-type': 'text/html;charset=UTF-8',
});
});

it('should return 304 Not Modified when ETag matches', async () => {
const url = 'http://localhost/home-ssg';

const initialResponse = await app.handle(new Request(url));
const etag = initialResponse?.headers.get('etag');

expect(etag).toBeDefined();

const conditionalResponse = await app.handle(
new Request(url, {
headers: {
'If-None-Match': etag as string,
},
}),
);

// Check that the response status is 304 Not Modified
expect(conditionalResponse?.status).toBe(304);
expect(await conditionalResponse?.text()).toBe('');
});

it('should return configured headers for pages with specific header settings', async () => {
const response = await app.handle(new Request('http://localhost/home-ssg'));
const headers = response?.headers.entries() ?? [];
expect(Object.fromEntries(headers)).toEqual({
'etag': '"f799132d0a09e0fef93c68a12e443527700eb59e6f67fcb7854c3a60ff082fde"',
'content-length': '28',
'x-some-header': 'value',
'content-type': 'text/html;charset=UTF-8',
});
Expand Down
22 changes: 15 additions & 7 deletions packages/angular/ssr/test/assets_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,34 @@ describe('ServerAsset', () => {
bootstrap: undefined as never,
assets: new Map(
Object.entries({
'index.server.html': async () => '<html>Index</html>',
'index.other.html': async () => '<html>Other</html>',
'index.server.html': {
text: async () => '<html>Index</html>',
size: 18,
hash: 'f799132d0a09e0fef93c68a12e443527700eb59e6f67fcb7854c3a60ff082fde',
},
'index.other.html': {
text: async () => '<html>Other</html>',
size: 18,
hash: '4a455a99366921d396f5d51c7253c4678764f5e9487f2c27baaa0f33553c8ce3',
},
}),
),
});
});

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

it('should throw an error if the asset path does not exist', async () => {
await expectAsync(assetManager.getServerAsset('nonexistent.html')).toBeRejectedWithError(
it('should throw an error if the asset path does not exist', () => {
expect(() => assetManager.getServerAsset('nonexistent.html')).toThrowError(
"Server asset 'nonexistent.html' does not exist.",
);
});

it('should retrieve the content of index.other.html', async () => {
const content = await assetManager.getServerAsset('index.other.html');
expect(content).toBe('<html>Other</html>');
const asset = await assetManager.getServerAsset('index.other.html').text();
expect(asset).toBe('<html>Other</html>');
});
});
Loading

0 comments on commit f460b91

Please sign in to comment.