Skip to content

Commit 5c6aa18

Browse files
committed
perf(@angular/ssr): pre-compile critical CSS plans at build time
Previously, critical CSS inlining in `@angular/ssr` was executed dynamically at runtime by parsing HTML and stylesheets on each request (or caching processed HTML in an in-memory LRU cache keyed by SHA-256 content hashes). This incurred significant CPU and latency overhead during request processing and precluded efficient stream processing. This commit updates the critical CSS workflow to pre-compile stylesheet plans during the build step via Beasties' compiler (`compileSheet` and `encodePlan`). These compact plans are stored in the server application manifest (`criticalCssPlans`) along with any CSP nonce. At runtime, `AngularServerApp` initializes Beasties' runtime processor with the pre-compiled plans, allowing fast single-pass string and stream processing without re-parsing stylesheets, calculating content hashes, or maintaining a runtime LRU cache. Consequently, `InlineCriticalCssProcessor`, `LRUCache`, and `crypto.ts` utility classes have been removed from `@angular/ssr`.
1 parent 8cfd070 commit 5c6aa18

27 files changed

Lines changed: 352 additions & 1275 deletions

package.json

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,6 @@
6666
"@eslint/compat": "2.1.0",
6767
"@eslint/eslintrc": "3.3.6",
6868
"@eslint/js": "10.0.1",
69-
"@rollup/plugin-alias": "^6.0.0",
7069
"@rollup/plugin-commonjs": "^29.0.0",
7170
"@rollup/plugin-json": "^6.1.0",
7271
"@rollup/plugin-node-resolve": "16.0.3",
@@ -122,14 +121,12 @@
122121
"puppeteer": "25.9.0",
123122
"quicktype-core": "26.0.0",
124123
"rollup": "4.63.0",
125-
"rollup-license-plugin": "~3.2.0",
126124
"rollup-plugin-dts": "6.5.1",
127125
"rollup-plugin-sourcemaps2": "0.5.8",
128126
"semver": "7.8.5",
129127
"source-map-support": "0.5.21",
130128
"tslib": "2.8.1",
131129
"undici": "8.10.0",
132-
"unenv": "^1.10.0",
133130
"verdaccio": "6.10.0",
134131
"verdaccio-auth-memory": "^13.0.0",
135132
"zone.js": "^0.16.0"

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ export async function executePostBundleSteps(
123123
// Create server manifest
124124
const initialFilesPaths = new Set(initialFiles.keys());
125125
if (serverEntryPoint && (outputMode || prerenderOptions || appShellOptions || ssrOptions)) {
126-
const { manifestContent, serverAssetsChunks } = generateAngularServerAppManifest(
126+
const { manifestContent, serverAssetsChunks } = await generateAngularServerAppManifest(
127127
additionalHtmlOutputFiles,
128128
outputFiles,
129129
optimizationOptions.styles.inlineCritical ?? false,
@@ -209,7 +209,7 @@ export async function executePostBundleSteps(
209209
const manifest = additionalOutputFiles.find((f) => f.path === SERVER_APP_MANIFEST_FILENAME);
210210
assert(manifest, `${SERVER_APP_MANIFEST_FILENAME} was not found in output files.`);
211211

212-
const { manifestContent, serverAssetsChunks } = generateAngularServerAppManifest(
212+
const { manifestContent, serverAssetsChunks } = await generateAngularServerAppManifest(
213213
additionalHtmlOutputFiles,
214214
outputFiles,
215215
optimizationOptions.styles.inlineCritical ?? false,

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

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,16 @@
99
import type { Metafile } from 'esbuild';
1010
import { Buffer } from 'node:buffer';
1111
import { extname } from 'node:path';
12-
import { NormalizedApplicationBuildOptions } from '../../builders/application/options';
12+
import {
13+
INDEX_HTML_SERVER,
14+
NormalizedApplicationBuildOptions,
15+
} from '../../builders/application/options';
1316
import {
1417
type BuildOutputFile,
1518
BuildOutputFileType,
1619
createOutputFile,
1720
} from '../../tools/esbuild/bundler-files';
21+
import { joinUrlParts } from '../url';
1822

1923
export const SERVER_APP_MANIFEST_FILENAME = 'angular-app-manifest.mjs';
2024
export const SERVER_APP_ENGINE_MANIFEST_FILENAME = 'angular-app-engine-manifest.mjs';
@@ -33,11 +37,6 @@ export const SERVER_GENERATED_EXTERNALS = new Set([
3337
'./' + SERVER_APP_ENGINE_MANIFEST_FILENAME,
3438
]);
3539

36-
interface FilesMapping {
37-
path: string;
38-
dynamicImport: boolean;
39-
}
40-
4140
const MAIN_SERVER_OUTPUT_FILENAME = 'main.server.mjs';
4241

4342
/**
@@ -143,7 +142,7 @@ export default {
143142
* - `manifestContent`: A string of the SSR manifest content.
144143
* - `serverAssetsChunks`: An array of build output files containing the generated assets for the server.
145144
*/
146-
export function generateAngularServerAppManifest(
145+
export async function generateAngularServerAppManifest(
147146
additionalHtmlOutputFiles: Map<string, BuildOutputFile>,
148147
outputFiles: BuildOutputFile[],
149148
inlineCriticalCss: boolean,
@@ -153,16 +152,23 @@ export function generateAngularServerAppManifest(
153152
initialFiles: Set<string>,
154153
metafile: Metafile,
155154
publicPath: string | undefined,
156-
): {
155+
): Promise<{
157156
manifestContent: string;
158157
serverAssetsChunks: BuildOutputFile[];
159-
} {
158+
}> {
160159
const serverAssetsChunks: BuildOutputFile[] = [];
161160
const serverAssets: Record<string, string> = {};
161+
const criticalCssPlans: unknown[] = [];
162+
let nonce: string | undefined;
163+
164+
// TODO(alanagius): This is done here as we do not use module resolution bundler/node16
165+
const { compileSheet, encodePlan } = (await import(
166+
'beasties/compiler' as string
167+
)) as typeof import('beasties/compiler', { with: { 'resolution-mode': 'import' } });
162168

163169
for (const file of [...additionalHtmlOutputFiles.values(), ...outputFiles]) {
164170
const extension = extname(file.path);
165-
if (extension === '.html' || (inlineCriticalCss && extension === '.css')) {
171+
if (extension === '.html') {
166172
const jsChunkFilePath = `assets-chunks/${file.path.replace(/[./]/g, '_')}.mjs`;
167173
const escapedContent = escapeUnsafeChars(file.text);
168174

@@ -185,9 +191,19 @@ export function generateAngularServerAppManifest(
185191

186192
serverAssets[file.path] =
187193
`{size: ${size}, hash: '${file.hash}', text: () => import('./${jsChunkFilePath}').then(m => m.default)}`;
194+
} else if (inlineCriticalCss && extension === '.css') {
195+
const sheet = compileSheet(file.text, {
196+
href: joinUrlParts(publicPath ?? '', file.path),
197+
});
198+
criticalCssPlans.push(encodePlan(sheet));
188199
}
189200
}
190201

202+
const indexHtml = additionalHtmlOutputFiles.get(INDEX_HTML_SERVER)?.text;
203+
if (indexHtml) {
204+
nonce = findNonce(indexHtml);
205+
}
206+
191207
// When routes have been extracted, mappings are no longer needed, as preloads will be included in the metadata.
192208
const entryPointToBrowserMapping = routes?.length
193209
? undefined
@@ -196,9 +212,10 @@ export function generateAngularServerAppManifest(
196212
const manifestContent = `
197213
export default {
198214
bootstrap: () => import('./main.server.mjs').then(m => m.default),
199-
inlineCriticalCss: ${inlineCriticalCss},
200215
baseHref: '${baseHref}',
201-
locale: ${JSON.stringify(locale)},
216+
${criticalCssPlans.length ? ` criticalCssPlans: ${JSON.stringify(criticalCssPlans)},\n` : ''}${
217+
nonce ? ` nonce: ${JSON.stringify(nonce)},\n` : ''
218+
} locale: ${JSON.stringify(locale)},
202219
routes: ${JSON.stringify(routes, undefined, 2)},
203220
entryPointToBrowserMapping: ${JSON.stringify(entryPointToBrowserMapping, undefined, 2)},
204221
assets: {
@@ -246,3 +263,12 @@ function generateLazyLoadedFilesMappings(
246263

247264
return entryPointToBundles;
248265
}
266+
267+
/**
268+
* Finds the Angular nonce attribute value in an HTML string.
269+
*/
270+
function findNonce(html: string): string | undefined {
271+
const match = /<[a-zA-Z0-9-]+[^>]*?\sngcspnonce=(?:"([^"]*)"|'([^']*)'|(\S+))/i.exec(html);
272+
273+
return match ? (match[1] ?? match[2] ?? match[3]) : undefined;
274+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import type { Metafile } from 'esbuild';
10+
import { BuildOutputFileType, createOutputFile } from '../../tools/esbuild/bundler-files';
11+
import { initializeHash } from '../hash';
12+
import { generateAngularServerAppManifest } from './manifest';
13+
14+
describe('generateAngularServerAppManifest', () => {
15+
beforeAll(async () => {
16+
await initializeHash();
17+
});
18+
19+
const dummyMetafile = { inputs: {}, outputs: {} } as unknown as Metafile;
20+
21+
it('should include criticalCssPlans when inlineCriticalCss is true', async () => {
22+
const additionalHtml = new Map([
23+
[
24+
'index.server.html',
25+
createOutputFile(
26+
'index.server.html',
27+
'<html><body><app-root></app-root></body></html>',
28+
BuildOutputFileType.ServerApplication,
29+
),
30+
],
31+
]);
32+
const outputFiles = [
33+
createOutputFile('styles.css', 'h1 { color: blue; }', BuildOutputFileType.Browser),
34+
];
35+
36+
const { manifestContent } = await generateAngularServerAppManifest(
37+
additionalHtml,
38+
outputFiles,
39+
true,
40+
undefined,
41+
undefined,
42+
'/',
43+
new Set(),
44+
dummyMetafile,
45+
undefined,
46+
);
47+
48+
expect(manifestContent).toContain('criticalCssPlans: [');
49+
expect(manifestContent).toContain('styles.css');
50+
expect(manifestContent).not.toContain('nonce:');
51+
});
52+
53+
it('should not include criticalCssPlans when inlineCriticalCss is false', async () => {
54+
const additionalHtml = new Map([
55+
[
56+
'index.server.html',
57+
createOutputFile(
58+
'index.server.html',
59+
'<html><body><app-root></app-root></body></html>',
60+
BuildOutputFileType.ServerApplication,
61+
),
62+
],
63+
]);
64+
const outputFiles = [
65+
createOutputFile('styles.css', 'h1 { color: blue; }', BuildOutputFileType.Browser),
66+
];
67+
68+
const { manifestContent } = await generateAngularServerAppManifest(
69+
additionalHtml,
70+
outputFiles,
71+
false,
72+
undefined,
73+
undefined,
74+
'/',
75+
new Set(),
76+
dummyMetafile,
77+
undefined,
78+
);
79+
80+
expect(manifestContent).not.toContain('criticalCssPlans:');
81+
});
82+
83+
it('should extract template nonce from index HTML when present', async () => {
84+
const additionalHtml = new Map([
85+
[
86+
'index.server.html',
87+
createOutputFile(
88+
'index.server.html',
89+
'<html><body><app-root ngCspNonce="{% nonce %}"></app-root></body></html>',
90+
BuildOutputFileType.ServerApplication,
91+
),
92+
],
93+
]);
94+
const outputFiles = [
95+
createOutputFile('styles.css', 'h1 { color: blue; }', BuildOutputFileType.Browser),
96+
];
97+
98+
const { manifestContent } = await generateAngularServerAppManifest(
99+
additionalHtml,
100+
outputFiles,
101+
true,
102+
undefined,
103+
undefined,
104+
'/',
105+
new Set(),
106+
dummyMetafile,
107+
undefined,
108+
);
109+
110+
expect(manifestContent).toContain('nonce: "{% nonce %}"');
111+
});
112+
113+
it('should not include css files in serverAssetsChunks or assets', async () => {
114+
const additionalHtml = new Map([
115+
[
116+
'index.server.html',
117+
createOutputFile(
118+
'index.server.html',
119+
'<html><body></body></html>',
120+
BuildOutputFileType.ServerApplication,
121+
),
122+
],
123+
]);
124+
const outputFiles = [
125+
createOutputFile('styles.css', 'h1 { color: blue; }', BuildOutputFileType.Browser),
126+
];
127+
128+
const { manifestContent, serverAssetsChunks } = await generateAngularServerAppManifest(
129+
additionalHtml,
130+
outputFiles,
131+
true,
132+
undefined,
133+
undefined,
134+
'/',
135+
new Set(),
136+
dummyMetafile,
137+
undefined,
138+
);
139+
140+
expect(serverAssetsChunks.some((chunk) => chunk.path.includes('styles'))).toBeFalse();
141+
expect(manifestContent).not.toContain("'styles.css':");
142+
});
143+
});

packages/angular/ssr/BUILD.bazel

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,43 +22,30 @@ ts_project(
2222
"--lib",
2323
"dom.iterable,dom,es2022",
2424
],
25-
data = [
26-
"//packages/angular/ssr/third_party/beasties:beasties_bundled",
27-
],
2825
source_map = True,
2926
tsconfig = "//:build-tsconfig-esm",
3027
deps = [
28+
":node_modules/beasties",
3129
"//:node_modules/@angular/common",
3230
"//:node_modules/@angular/core",
3331
"//:node_modules/@angular/platform-browser",
3432
"//:node_modules/@angular/platform-server",
3533
"//:node_modules/@angular/router",
3634
"//:node_modules/tslib",
37-
"//packages/angular/ssr/third_party/beasties:beasties_dts",
3835
],
3936
)
4037

4138
ng_package(
4239
name = "npm_package",
4340
srcs = [
4441
":package.json",
45-
"//packages/angular/ssr/third_party/beasties:beasties_bundled",
4642
],
4743
externals = [
4844
"@angular/ssr",
4945
"@angular/ssr/node",
50-
"../../third_party/beasties",
5146
],
52-
extra_substitutions = {
53-
# Needed for ssr.d.ts file
54-
"\\./third_party/beasties": "../third_party/beasties",
55-
# Needed for the FESM file.
56-
"\\./(.+)/packages/angular/ssr/third_party/beasties": "../third_party/beasties/index.js",
57-
},
5847
nested_packages = [
5948
"//packages/angular/ssr/schematics:pkg",
60-
# Included directly as the generated types reference the types file in this location.
61-
"//packages/angular/ssr/third_party/beasties:beasties_dts",
6249
],
6350
package = "@angular/ssr",
6451
readme_md = ":README.md",

packages/angular/ssr/node/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,6 @@ ts_project(
2121
"//:node_modules/@angular/platform-server",
2222
"//:node_modules/@types/node",
2323
"//packages/angular/ssr",
24+
"//packages/angular/ssr:node_modules/beasties",
2425
],
2526
)

0 commit comments

Comments
 (0)