Skip to content

Commit 0c18dc0

Browse files
IchordeDionysosdgp1130
authored andcommitted
feat(@angular/build): emit debug ids for stable subresource integrity hashes
Enable generating sub-resource integrity hashes when using error tracking tools relying on Debug IDs linking generated code files with source maps. Closes #33108 fix: persist formatting of source map JSON fix: improve source map comment detection and test fix format fix import error fix imports
1 parent b345bb5 commit 0c18dc0

5 files changed

Lines changed: 331 additions & 0 deletions

File tree

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
} from '../../utils/server-rendering/models';
3131
import { prerenderPages } from '../../utils/server-rendering/prerender';
3232
import { augmentAppWithServiceWorkerEsbuild } from '../../utils/service-worker';
33+
import { injectDebugIds } from './inject-debug-ids';
3334
import { INDEX_HTML_CSR, INDEX_HTML_SERVER, NormalizedApplicationBuildOptions } from './options';
3435
import { OutputMode } from './schema';
3536

@@ -79,6 +80,14 @@ export async function executePostBundleSteps(
7980
partialSSRBuild,
8081
} = options;
8182

83+
// Embed ECMA-426 Debug IDs into JS/source-map pairs before any consumer reads the bytes (in
84+
// particular `generateIndexHtml` below, which computes SRI hashes from the on-disk content).
85+
// Doing this here also covers the i18n path, where this function is invoked once per locale
86+
// with locale-specific output files. Files without a source map sibling are skipped.
87+
if (sourcemapOptions.scripts) {
88+
injectDebugIds(outputFiles);
89+
}
90+
8291
// Index HTML content without CSS inlining to be used for server rendering (AppShell, SSG and SSR).
8392
// NOTE: Critical CSS inlining is deliberately omitted here, as it will be handled during server rendering.
8493
// Additionally, when using prerendering or AppShell, the index HTML file may be regenerated.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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 { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-files';
10+
import {
11+
generateDebugId,
12+
injectDebugIdIntoJs,
13+
injectDebugIdIntoSourceMap,
14+
} from '../../utils/debug-id';
15+
16+
/**
17+
* Embeds an ECMA-426 Debug ID into every browser JavaScript output that has a
18+
* matching source map sibling.
19+
*
20+
* The Debug ID is derived deterministically (UUIDv5) from the source map bytes
21+
* so rebuilds of the same source produce the same ID. The JS file gets a
22+
* `//# debugId=<uuid>` comment placed above any existing
23+
* `//# sourceMappingURL=` line and the source map JSON gets a top-level
24+
* `"debugId"` field. Together they make build artifacts self-identifying as
25+
* proposed by https://github.com/tc39/ecma426/blob/main/proposals/debug-id.md.
26+
*/
27+
export function injectDebugIds(outputFiles: BuildOutputFile[]): void {
28+
const filesByPath = new Map<string, BuildOutputFile>();
29+
for (const file of outputFiles) {
30+
filesByPath.set(file.path, file);
31+
}
32+
33+
const encoder = new TextEncoder();
34+
35+
for (const file of outputFiles) {
36+
if (file.type !== BuildOutputFileType.Browser || !file.path.endsWith('.js')) {
37+
continue;
38+
}
39+
40+
const map = filesByPath.get(`${file.path}.map`);
41+
if (!map) {
42+
continue;
43+
}
44+
45+
const id = generateDebugId(map.contents);
46+
file.contents = encoder.encode(injectDebugIdIntoJs(file.text, id));
47+
map.contents = encoder.encode(injectDebugIdIntoSourceMap(map.text, id));
48+
}
49+
}

packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,83 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
8383
expectNoLog(logs, /subresource-integrity/);
8484
});
8585

86+
it(`embeds an ECMA-426 debugId in JS and source map and the integrity matches`, async () => {
87+
harness.useTarget('build', {
88+
...BASE_OPTIONS,
89+
subresourceIntegrity: true,
90+
sourceMap: { scripts: true },
91+
});
92+
93+
const { result } = await harness.executeOnce();
94+
expect(result?.success).toBe(true);
95+
96+
const distDir = workspacePath('dist/browser');
97+
const allEntries = readdirSync(distDir);
98+
const jsFiles = allEntries.filter(
99+
(f) => f.endsWith('.js') && allEntries.includes(`${f}.map`),
100+
);
101+
expect(jsFiles.length).toBeGreaterThan(0);
102+
103+
const debugIdRe = /\/\/# debugId=([^\r\n]*)/;
104+
const indexHtml = harness.readFile('dist/browser/index.html');
105+
const importmapMatch = indexHtml.match(/<script type="importmap">([^<]+)<\/script>/);
106+
const importmap = importmapMatch
107+
? (JSON.parse(importmapMatch[1]) as { integrity: Record<string, string> })
108+
: { integrity: {} };
109+
110+
for (const file of jsFiles) {
111+
const js = readFileSync(join(distDir, file), 'utf-8');
112+
const map = JSON.parse(readFileSync(join(distDir, `${file}.map`), 'utf-8'));
113+
114+
const idMatch = js.match(debugIdRe);
115+
expect(idMatch).withContext(`debugId comment in ${file}`).not.toBeNull();
116+
const id = idMatch![1];
117+
118+
// ECMA-426: source map carries the same id under "debugId".
119+
expect(map.debugId).withContext(`debugId field in ${file}.map`).toBe(id);
120+
121+
// Integrity hash recorded in index.html (initial) or in the importmap
122+
// (lazy) must match the bytes written to disk *after* debug-id
123+
// injection, otherwise SRI validation in the browser will fail.
124+
const onDisk = readFileSync(join(distDir, file));
125+
const expectedSri = `sha384-${createHash('sha384').update(onDisk).digest('base64')}`;
126+
const escapedFile = file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
127+
const escapedSri = expectedSri.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
128+
const inHeadIntegrity = new RegExp(
129+
`(?:src|href)="${escapedFile}"[^>]*integrity="${escapedSri}"`,
130+
);
131+
const fromImportmap = importmap.integrity[`/${file}`];
132+
expect(inHeadIntegrity.test(indexHtml) || fromImportmap === expectedSri)
133+
.withContext(`integrity for ${file} must match on-disk bytes`)
134+
.toBeTrue();
135+
}
136+
});
137+
138+
it(`places the debugId comment immediately above sourceMappingURL`, async () => {
139+
harness.useTarget('build', {
140+
...BASE_OPTIONS,
141+
sourceMap: { scripts: true },
142+
});
143+
144+
const { result } = await harness.executeOnce();
145+
expect(result?.success).toBe(true);
146+
147+
const distDir = workspacePath('dist/browser');
148+
const allEntries = readdirSync(distDir);
149+
const jsFiles = allEntries.filter(
150+
(f) => f.endsWith('.js') && allEntries.includes(`${f}.map`),
151+
);
152+
expect(jsFiles.length).toBeGreaterThan(0);
153+
154+
const ordering = /\/\/\s*#\s*debugId=[0-9a-f-]+\s*\n\s*\/\/\s*#\s*sourceMappingURL=/;
155+
for (const file of jsFiles) {
156+
const js = readFileSync(join(distDir, file), 'utf-8');
157+
expect(ordering.test(js))
158+
.withContext(`debugId must precede sourceMappingURL in ${file}`)
159+
.toBeTrue();
160+
}
161+
});
162+
86163
it(`emits an importmap with integrity for lazy chunks when 'true'`, async () => {
87164
await harness.writeFiles(lazyModuleFiles);
88165
await harness.writeFiles(lazyModuleFnImport);
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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 { createHash } from 'node:crypto';
10+
11+
/**
12+
* Fixed RFC 4122 namespace UUID used to derive deterministic UUIDv5 build/Debug IDs.
13+
* Treated as 16 raw bytes when fed into the SHA-1 of `namespace || name`.
14+
*
15+
* The exact value is arbitrary but must remain stable across releases so that
16+
* the same source map content always yields the same Debug ID.
17+
*/
18+
const ANGULAR_BUILD_NAMESPACE = Buffer.from('6f9619ff8b86d011b42d00cf4fc964ff', 'hex');
19+
20+
/**
21+
* Generates a deterministic UUIDv5 (RFC 4122 §4.3) Debug ID from the given name bytes.
22+
*
23+
* Determinism is recommended by the ECMA-426 "Source Map Debug ID" proposal
24+
* (https://github.com/tc39/ecma426/blob/main/proposals/debug-id.md) so that the
25+
* produced artifacts are stable across builds with the same source content.
26+
*
27+
* @param name Bytes that uniquely identify the artifact (typically the source map content).
28+
* @returns A canonical UUIDv5 string (lowercase, hyphenated).
29+
*/
30+
export function generateDebugId(name: string | Uint8Array): string {
31+
const sha = createHash('sha1').update(ANGULAR_BUILD_NAMESPACE).update(name).digest();
32+
33+
// Set version (5) in the high nibble of byte 6.
34+
sha[6] = (sha[6] & 0x0f) | 0x50;
35+
// Set RFC 4122 variant bits (10xx) in byte 8.
36+
sha[8] = (sha[8] & 0x3f) | 0x80;
37+
38+
const h = sha.toString('hex');
39+
40+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
41+
}
42+
43+
/** Pattern matching an existing `//# debugId=<uuid>` comment anywhere in the file. */
44+
const DEBUG_ID_COMMENT = /\n\/\/# debugId=[^\r\n]*\n?/;
45+
46+
/** Pattern matching the `//# sourceMappingURL=` comment, used to position the debug-id line. */
47+
const SOURCE_MAPPING_URL_COMMENT = /\n\/\/# sourceMappingURL=[^\r\n]*\s*$/;
48+
49+
/**
50+
* Inserts (or replaces) a `//# debugId=<id>` comment in the given JavaScript text.
51+
*
52+
* Per ECMA-426, the comment must appear within the last 5 lines and SHOULD be
53+
* placed immediately above any `//# sourceMappingURL=` comment so that existing
54+
* tools that only consult the final line still find the source-map URL.
55+
*/
56+
export function injectDebugIdIntoJs(text: string, id: string): string {
57+
const comment = `//# debugId=${id}`;
58+
59+
// Replace any existing debugId comment to keep the operation idempotent.
60+
if (DEBUG_ID_COMMENT.test(text)) {
61+
return text.replace(DEBUG_ID_COMMENT, `\n${comment}\n`);
62+
}
63+
64+
if (SOURCE_MAPPING_URL_COMMENT.test(text)) {
65+
return text.replace(SOURCE_MAPPING_URL_COMMENT, (match) => `\n${comment}${match}`);
66+
}
67+
68+
// No source map reference; append at the very end on its own line.
69+
return text.endsWith('\n') ? `${text}${comment}\n` : `${text}\n${comment}\n`;
70+
}
71+
72+
/**
73+
* Sets the top-level `debugId` field on a JSON source map.
74+
*
75+
* Per ECMA-426, source maps embed the same Debug ID under a `debugId` key so
76+
* that consumers can pair a generated file with its source map without relying
77+
* on URL/path conventions.
78+
*/
79+
export function injectDebugIdIntoSourceMap(json: string, id: string): string {
80+
let parsed: Record<string, unknown>;
81+
try {
82+
parsed = JSON.parse(json) as Record<string, unknown>;
83+
} catch {
84+
// Source map is malformed; do not corrupt it further.
85+
return json;
86+
}
87+
88+
if (parsed['debugId'] === id) {
89+
return json;
90+
}
91+
92+
parsed['debugId'] = id;
93+
94+
// Preserve existing pretty-print indentation when the source map is formatted.
95+
const indent = json.match(/^[^{]*{\r?\n([ \t]+)/)?.[1];
96+
97+
return JSON.stringify(parsed, null, indent);
98+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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 { generateDebugId, injectDebugIdIntoJs, injectDebugIdIntoSourceMap } from './debug-id';
10+
11+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
12+
13+
describe('debug-id', () => {
14+
describe('generateDebugId', () => {
15+
it('produces a canonical UUIDv5 string', () => {
16+
expect(generateDebugId('hello')).toMatch(UUID);
17+
expect(generateDebugId(new TextEncoder().encode('hello'))).toMatch(UUID);
18+
});
19+
20+
it('is deterministic for identical inputs', () => {
21+
expect(generateDebugId('same content')).toBe(generateDebugId('same content'));
22+
});
23+
24+
it('differs for different inputs', () => {
25+
expect(generateDebugId('one')).not.toBe(generateDebugId('two'));
26+
});
27+
});
28+
29+
describe('injectDebugIdIntoJs', () => {
30+
const id = '11111111-2222-5333-9444-555555555555';
31+
32+
it('inserts the debugId comment immediately above sourceMappingURL', () => {
33+
const text = 'console.log(1);\n//# sourceMappingURL=foo.js.map\n';
34+
const result = injectDebugIdIntoJs(text, id);
35+
expect(result).toBe(
36+
'console.log(1);\n//# debugId=11111111-2222-5333-9444-555555555555\n//# sourceMappingURL=foo.js.map\n',
37+
);
38+
});
39+
40+
it('appends the comment when no sourceMappingURL is present', () => {
41+
const text = 'console.log(1);\n';
42+
const result = injectDebugIdIntoJs(text, id);
43+
expect(result).toBe('console.log(1);\n//# debugId=11111111-2222-5333-9444-555555555555\n');
44+
});
45+
46+
it('appends a leading newline when input does not end with one', () => {
47+
const text = 'console.log(1);';
48+
const result = injectDebugIdIntoJs(text, id);
49+
expect(result).toBe('console.log(1);\n//# debugId=11111111-2222-5333-9444-555555555555\n');
50+
});
51+
52+
it('replaces an existing debugId comment (idempotent)', () => {
53+
const original =
54+
'console.log(1);\n//# debugId=00000000-0000-5000-8000-000000000000\n//# sourceMappingURL=foo.js.map\n';
55+
const result = injectDebugIdIntoJs(original, id);
56+
expect(result).toBe(
57+
'console.log(1);\n//# debugId=11111111-2222-5333-9444-555555555555\n//# sourceMappingURL=foo.js.map\n',
58+
);
59+
// Re-running with the same id is a no-op.
60+
expect(injectDebugIdIntoJs(result, id)).toBe(result);
61+
});
62+
});
63+
64+
describe('injectDebugIdIntoSourceMap', () => {
65+
const id = '11111111-2222-5333-9444-555555555555';
66+
67+
it('adds a top-level debugId field', () => {
68+
const map = JSON.stringify({ version: 3, sources: ['a.ts'], mappings: '' });
69+
const updated = JSON.parse(injectDebugIdIntoSourceMap(map, id));
70+
expect(updated.debugId).toBe(id);
71+
expect(updated.version).toBe(3);
72+
});
73+
74+
it('overwrites an existing debugId field', () => {
75+
const map = JSON.stringify({ version: 3, debugId: 'old', mappings: '' });
76+
const updated = JSON.parse(injectDebugIdIntoSourceMap(map, id));
77+
expect(updated.debugId).toBe(id);
78+
});
79+
80+
it('returns the original input when debugId already matches', () => {
81+
const map =
82+
'{\n "version": 3,\n "debugId": "11111111-2222-5333-9444-555555555555",\n "mappings": ""\n}';
83+
expect(injectDebugIdIntoSourceMap(map, id)).toBe(map);
84+
});
85+
86+
it('preserves indentation when updating a pretty-printed source map', () => {
87+
const map = '{\n\t"version": 3,\n\t"mappings": ""\n}';
88+
expect(injectDebugIdIntoSourceMap(map, id)).toBe(
89+
'{\n\t"version": 3,\n\t"mappings": "",\n\t"debugId": "11111111-2222-5333-9444-555555555555"\n}',
90+
);
91+
});
92+
93+
it('returns the original input when JSON is malformed', () => {
94+
const malformed = '{ this is not json';
95+
expect(injectDebugIdIntoSourceMap(malformed, id)).toBe(malformed);
96+
});
97+
});
98+
});

0 commit comments

Comments
 (0)