Skip to content

Commit 358e331

Browse files
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
1 parent 0841637 commit 358e331

5 files changed

Lines changed: 332 additions & 1 deletion

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-context';
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: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,23 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
import { getSystemPath } from '@angular-devkit/core';
10+
import { createHash } from 'node:crypto';
11+
import { readdirSync, readFileSync } from 'node:fs';
12+
import { join } from 'node:path';
913
import { buildApplication } from '../../index';
10-
import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder, expectNoLog } from '../setup';
14+
import {
15+
APPLICATION_BUILDER_INFO,
16+
BASE_OPTIONS,
17+
describeBuilder,
18+
expectNoLog,
19+
host,
20+
} from '../setup';
21+
22+
/** Resolve a path inside the harness workspace synchronously. */
23+
function workspacePath(...segments: string[]): string {
24+
return join(getSystemPath(host.root()), ...segments);
25+
}
1126

1227
describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
1328
describe('Option: "subresourceIntegrity"', () => {
@@ -65,5 +80,82 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
6580
.content.toMatch(/integrity="\w+-[A-Za-z0-9/+=]+"/);
6681
expectNoLog(logs, /subresource-integrity/);
6782
});
83+
84+
it(`embeds an ECMA-426 debugId in JS and source map and the integrity matches`, async () => {
85+
harness.useTarget('build', {
86+
...BASE_OPTIONS,
87+
subresourceIntegrity: true,
88+
sourceMap: { scripts: true },
89+
});
90+
91+
const { result } = await harness.executeOnce();
92+
expect(result?.success).toBe(true);
93+
94+
const distDir = workspacePath('dist/browser');
95+
const allEntries = readdirSync(distDir);
96+
const jsFiles = allEntries.filter(
97+
(f) => f.endsWith('.js') && allEntries.includes(`${f}.map`),
98+
);
99+
expect(jsFiles.length).toBeGreaterThan(0);
100+
101+
const debugIdRe = /^\s*\/\/\s*#\s*debugId=([0-9a-f-]+)\s*$/m;
102+
const indexHtml = harness.readFile('dist/browser/index.html');
103+
const importmapMatch = indexHtml.match(/<script type="importmap">([^<]+)<\/script>/);
104+
const importmap = importmapMatch
105+
? (JSON.parse(importmapMatch[1]) as { integrity: Record<string, string> })
106+
: { integrity: {} };
107+
108+
for (const file of jsFiles) {
109+
const js = readFileSync(join(distDir, file), 'utf-8');
110+
const map = JSON.parse(readFileSync(join(distDir, `${file}.map`), 'utf-8'));
111+
112+
const idMatch = js.match(debugIdRe);
113+
expect(idMatch).withContext(`debugId comment in ${file}`).not.toBeNull();
114+
const id = idMatch![1];
115+
116+
// ECMA-426: source map carries the same id under "debugId".
117+
expect(map.debugId).withContext(`debugId field in ${file}.map`).toBe(id);
118+
119+
// Integrity hash recorded in index.html (initial) or in the importmap
120+
// (lazy) must match the bytes written to disk *after* debug-id
121+
// injection, otherwise SRI validation in the browser will fail.
122+
const onDisk = readFileSync(join(distDir, file));
123+
const expectedSri = `sha384-${createHash('sha384').update(onDisk).digest('base64')}`;
124+
const escapedFile = file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
125+
const escapedSri = expectedSri.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
126+
const inHeadIntegrity = new RegExp(
127+
`(?:src|href)="${escapedFile}"[^>]*integrity="${escapedSri}"`,
128+
);
129+
const fromImportmap = importmap.integrity[`/${file}`];
130+
expect(inHeadIntegrity.test(indexHtml) || fromImportmap === expectedSri)
131+
.withContext(`integrity for ${file} must match on-disk bytes`)
132+
.toBeTrue();
133+
}
134+
});
135+
136+
it(`places the debugId comment immediately above sourceMappingURL`, async () => {
137+
harness.useTarget('build', {
138+
...BASE_OPTIONS,
139+
sourceMap: { scripts: true },
140+
});
141+
142+
const { result } = await harness.executeOnce();
143+
expect(result?.success).toBe(true);
144+
145+
const distDir = workspacePath('dist/browser');
146+
const allEntries = readdirSync(distDir);
147+
const jsFiles = allEntries.filter(
148+
(f) => f.endsWith('.js') && allEntries.includes(`${f}.map`),
149+
);
150+
expect(jsFiles.length).toBeGreaterThan(0);
151+
152+
const ordering = /\/\/\s*#\s*debugId=[0-9a-f-]+\s*\n\s*\/\/\s*#\s*sourceMappingURL=/;
153+
for (const file of jsFiles) {
154+
const js = readFileSync(join(distDir, file), 'utf-8');
155+
expect(ordering.test(js))
156+
.withContext(`debugId must precede sourceMappingURL in ${file}`)
157+
.toBeTrue();
158+
}
159+
});
68160
});
69161
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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 = /^[ \t]*\/\/[ \t]*#[ \t]*debugId=[^\n]*\n?/m;
45+
46+
/** Pattern matching the `//# sourceMappingURL=` comment, used to position the debug-id line. */
47+
const SOURCE_MAPPING_URL_COMMENT = /^[ \t]*\/\/[ \t]*#[ \t]*sourceMappingURL=[^\n]*$/m;
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, `${comment}\n`);
62+
}
63+
64+
if (SOURCE_MAPPING_URL_COMMENT.test(text)) {
65+
return text.replace(SOURCE_MAPPING_URL_COMMENT, (match) => `${comment}\n${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+
parsed['debugId'] = id;
89+
90+
return JSON.stringify(parsed);
91+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
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 {
10+
generateDebugId,
11+
injectDebugIdIntoJs,
12+
injectDebugIdIntoSourceMap,
13+
} from './debug-id';
14+
15+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
16+
17+
describe('debug-id', () => {
18+
describe('generateDebugId', () => {
19+
it('produces a canonical UUIDv5 string', () => {
20+
expect(generateDebugId('hello')).toMatch(UUID);
21+
expect(generateDebugId(new TextEncoder().encode('hello'))).toMatch(
22+
UUID,
23+
);
24+
});
25+
26+
it('is deterministic for identical inputs', () => {
27+
expect(generateDebugId('same content')).toBe(generateDebugId('same content'));
28+
});
29+
30+
it('differs for different inputs', () => {
31+
expect(generateDebugId('one')).not.toBe(generateDebugId('two'));
32+
});
33+
});
34+
35+
describe('injectDebugIdIntoJs', () => {
36+
const id = '11111111-2222-5333-9444-555555555555';
37+
38+
it('inserts the debugId comment immediately above sourceMappingURL', () => {
39+
const text = 'console.log(1);\n//# sourceMappingURL=foo.js.map\n';
40+
const result = injectDebugIdIntoJs(text, id);
41+
expect(result).toBe(
42+
'console.log(1);\n//# debugId=11111111-2222-5333-9444-555555555555\n//# sourceMappingURL=foo.js.map\n',
43+
);
44+
});
45+
46+
it('appends the comment when no sourceMappingURL is present', () => {
47+
const text = 'console.log(1);\n';
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('appends a leading newline when input does not end with one', () => {
53+
const text = 'console.log(1);';
54+
const result = injectDebugIdIntoJs(text, id);
55+
expect(result).toBe('console.log(1);\n//# debugId=11111111-2222-5333-9444-555555555555\n');
56+
});
57+
58+
it('replaces an existing debugId comment (idempotent)', () => {
59+
const original = 'console.log(1);\n//# debugId=00000000-0000-5000-8000-000000000000\n//# sourceMappingURL=foo.js.map\n';
60+
const result = injectDebugIdIntoJs(original, id);
61+
expect(result).toBe(
62+
'console.log(1);\n//# debugId=11111111-2222-5333-9444-555555555555\n//# sourceMappingURL=foo.js.map\n',
63+
);
64+
// Re-running with the same id is a no-op.
65+
expect(injectDebugIdIntoJs(result, id)).toBe(result);
66+
});
67+
});
68+
69+
describe('injectDebugIdIntoSourceMap', () => {
70+
const id = '11111111-2222-5333-9444-555555555555';
71+
72+
it('adds a top-level debugId field', () => {
73+
const map = JSON.stringify({ version: 3, sources: ['a.ts'], mappings: '' });
74+
const updated = JSON.parse(injectDebugIdIntoSourceMap(map, id));
75+
expect(updated.debugId).toBe(id);
76+
expect(updated.version).toBe(3);
77+
});
78+
79+
it('overwrites an existing debugId field', () => {
80+
const map = JSON.stringify({ version: 3, debugId: 'old', mappings: '' });
81+
const updated = JSON.parse(injectDebugIdIntoSourceMap(map, id));
82+
expect(updated.debugId).toBe(id);
83+
});
84+
85+
it('returns the original input when JSON is malformed', () => {
86+
const malformed = '{ this is not json';
87+
expect(injectDebugIdIntoSourceMap(malformed, id)).toBe(malformed);
88+
});
89+
});
90+
});

0 commit comments

Comments
 (0)