Skip to content

Commit 089d394

Browse files
drakeo338alan-agius4
authored andcommitted
fix(@angular/build): add integrity metadata for all initial chunks beyond the module preload limit
With subresource integrity enabled, the import map integrity entries skipped every file in the initial files set. Only the first 10 initial script chunks receive a `modulepreload` hint (and none do when `preloadInitial` is disabled or `externalPackages` is set), so the remaining initial chunks ended up without any integrity metadata. Skip only entry points, which are referenced by their own tag, and files that actually received a hint. All other initial chunks are now included in the import map integrity entries. Closes #34395
1 parent 9732721 commit 089d394

2 files changed

Lines changed: 122 additions & 1 deletion

File tree

‎packages/angular/build/src/builders/application/index-html-generator.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,15 @@ export async function generateIndexHtml(
9191
let chunksIntegrity: ReadonlyMap<string, string> | undefined;
9292
if (subresourceIntegrity) {
9393
const integrity = new Map<string, string>();
94+
// Entry points and module preload hints carry their own integrity attribute. Any other
95+
// initial chunk (e.g. beyond the module preload limit) is only covered by the import map.
96+
const hinted = new Set(hints.map(({ url }) => url));
9497
for (const file of browserOutputFiles) {
95-
if (!file.path.endsWith('.js') || initialFiles.has(file.path)) {
98+
if (
99+
!file.path.endsWith('.js') ||
100+
initialFiles.get(file.path)?.entrypoint ||
101+
hinted.has(file.path)
102+
) {
96103
continue;
97104
}
98105
const hash = createHash('sha384').update(file.contents).digest('base64');
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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 { mkdtemp, rm, writeFile } from 'node:fs/promises';
10+
import { tmpdir } from 'node:os';
11+
import { join } from 'node:path';
12+
import {
13+
BuildOutputFile,
14+
BuildOutputFileType,
15+
InitialFileRecord,
16+
} from '../../tools/esbuild/bundler-files';
17+
import { generateIndexHtml } from './index-html-generator';
18+
import { NormalizedApplicationBuildOptions } from './options';
19+
20+
describe('generateIndexHtml', () => {
21+
let tempDir: string;
22+
23+
beforeEach(async () => {
24+
tempDir = await mkdtemp(join(tmpdir(), 'index-html-generator-spec-'));
25+
await writeFile(
26+
join(tempDir, 'index.html'),
27+
'<html><head><base href="/"></head><body><app-root></app-root></body></html>',
28+
);
29+
});
30+
31+
afterEach(async () => {
32+
await rm(tempDir, { recursive: true, force: true });
33+
});
34+
35+
function createOutputFile(path: string): BuildOutputFile {
36+
const text = `console.log(${JSON.stringify(path)});`;
37+
38+
return {
39+
path,
40+
text,
41+
contents: Buffer.from(text),
42+
type: BuildOutputFileType.Browser,
43+
} as unknown as BuildOutputFile;
44+
}
45+
46+
function createOptions(subresourceIntegrity: boolean): NormalizedApplicationBuildOptions {
47+
return {
48+
indexHtmlOptions: {
49+
input: join(tempDir, 'index.html'),
50+
insertionOrder: [['main.js', true]],
51+
preloadInitial: true,
52+
},
53+
subresourceIntegrity,
54+
baseHref: '/',
55+
security: {},
56+
} as unknown as NormalizedApplicationBuildOptions;
57+
}
58+
59+
it('adds integrity metadata for every initial chunk when there are more than the module preload limit', async () => {
60+
const chunkCount = 15;
61+
const chunks = Array.from({ length: chunkCount }, (_, i) => `chunk-${i}.js`);
62+
const initialFiles = new Map<string, InitialFileRecord>([
63+
['main.js', { entrypoint: true, name: 'main', type: 'script', serverFile: false, depth: 0 }],
64+
...chunks.map(
65+
(chunk, i) =>
66+
[chunk, { entrypoint: false, type: 'script', serverFile: false, depth: i + 1 }] as [
67+
string,
68+
InitialFileRecord,
69+
],
70+
),
71+
]);
72+
const outputFiles = ['main.js', ...chunks].map(createOutputFile);
73+
74+
const { csrContent, errors } = await generateIndexHtml(
75+
initialFiles,
76+
outputFiles,
77+
createOptions(true),
78+
);
79+
expect(errors).toEqual([]);
80+
81+
const importMapMatch = /<script type="importmap">(.*?)<\/script>/.exec(csrContent);
82+
const importMapIntegrity: Record<string, string> = importMapMatch
83+
? JSON.parse(importMapMatch[1]).integrity
84+
: {};
85+
86+
for (const chunk of chunks) {
87+
const hasPreloadIntegrity = new RegExp(
88+
`<link rel="modulepreload" href="${chunk}" integrity="sha384-[^"]+"`,
89+
).test(csrContent);
90+
91+
expect(hasPreloadIntegrity || `./${chunk}` in importMapIntegrity)
92+
.withContext(`Expected integrity metadata for ${chunk}`)
93+
.toBeTrue();
94+
}
95+
});
96+
97+
it('adds integrity metadata for initial chunks when module preloads are disabled', async () => {
98+
const initialFiles = new Map<string, InitialFileRecord>([
99+
['main.js', { entrypoint: true, name: 'main', type: 'script', serverFile: false, depth: 0 }],
100+
['chunk-a.js', { entrypoint: false, type: 'script', serverFile: false, depth: 1 }],
101+
]);
102+
const outputFiles = ['main.js', 'chunk-a.js'].map(createOutputFile);
103+
const options = createOptions(true);
104+
options.indexHtmlOptions!.preloadInitial = false;
105+
106+
const { csrContent, errors } = await generateIndexHtml(initialFiles, outputFiles, options);
107+
expect(errors).toEqual([]);
108+
109+
const importMapMatch = /<script type="importmap">(.*?)<\/script>/.exec(csrContent);
110+
const importMapIntegrity: Record<string, string> = JSON.parse(importMapMatch![1]).integrity;
111+
112+
expect(Object.keys(importMapIntegrity)).toEqual(['./chunk-a.js']);
113+
});
114+
});

0 commit comments

Comments
 (0)