From 358e33146d8758b77194374ab68bb266f9d5045a Mon Sep 17 00:00:00 2001 From: Dennis Kugelmann Date: Thu, 30 Apr 2026 15:29:05 +0200 Subject: [PATCH 1/2] 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 --- .../application/execute-post-bundle.ts | 9 ++ .../builders/application/inject-debug-ids.ts | 49 ++++++++++ .../options/subresource-integrity_spec.ts | 94 ++++++++++++++++++- packages/angular/build/src/utils/debug-id.ts | 91 ++++++++++++++++++ .../angular/build/src/utils/debug-id_spec.ts | 90 ++++++++++++++++++ 5 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 packages/angular/build/src/builders/application/inject-debug-ids.ts create mode 100644 packages/angular/build/src/utils/debug-id.ts create mode 100644 packages/angular/build/src/utils/debug-id_spec.ts diff --git a/packages/angular/build/src/builders/application/execute-post-bundle.ts b/packages/angular/build/src/builders/application/execute-post-bundle.ts index 5171ca254d5d..d7fcb15b8748 100644 --- a/packages/angular/build/src/builders/application/execute-post-bundle.ts +++ b/packages/angular/build/src/builders/application/execute-post-bundle.ts @@ -30,6 +30,7 @@ import { } from '../../utils/server-rendering/models'; import { prerenderPages } from '../../utils/server-rendering/prerender'; import { augmentAppWithServiceWorkerEsbuild } from '../../utils/service-worker'; +import { injectDebugIds } from './inject-debug-ids'; import { INDEX_HTML_CSR, INDEX_HTML_SERVER, NormalizedApplicationBuildOptions } from './options'; import { OutputMode } from './schema'; @@ -79,6 +80,14 @@ export async function executePostBundleSteps( partialSSRBuild, } = options; + // Embed ECMA-426 Debug IDs into JS/source-map pairs before any consumer reads the bytes (in + // particular `generateIndexHtml` below, which computes SRI hashes from the on-disk content). + // Doing this here also covers the i18n path, where this function is invoked once per locale + // with locale-specific output files. Files without a source map sibling are skipped. + if (sourcemapOptions.scripts) { + injectDebugIds(outputFiles); + } + // Index HTML content without CSS inlining to be used for server rendering (AppShell, SSG and SSR). // NOTE: Critical CSS inlining is deliberately omitted here, as it will be handled during server rendering. // Additionally, when using prerendering or AppShell, the index HTML file may be regenerated. diff --git a/packages/angular/build/src/builders/application/inject-debug-ids.ts b/packages/angular/build/src/builders/application/inject-debug-ids.ts new file mode 100644 index 000000000000..8c6cf624c29b --- /dev/null +++ b/packages/angular/build/src/builders/application/inject-debug-ids.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-context'; +import { + generateDebugId, + injectDebugIdIntoJs, + injectDebugIdIntoSourceMap, +} from '../../utils/debug-id'; + +/** + * Embeds an ECMA-426 Debug ID into every browser JavaScript output that has a + * matching source map sibling. + * + * The Debug ID is derived deterministically (UUIDv5) from the source map bytes + * so rebuilds of the same source produce the same ID. The JS file gets a + * `//# debugId=` comment placed above any existing + * `//# sourceMappingURL=` line and the source map JSON gets a top-level + * `"debugId"` field. Together they make build artifacts self-identifying as + * proposed by https://github.com/tc39/ecma426/blob/main/proposals/debug-id.md. + */ +export function injectDebugIds(outputFiles: BuildOutputFile[]): void { + const filesByPath = new Map(); + for (const file of outputFiles) { + filesByPath.set(file.path, file); + } + + const encoder = new TextEncoder(); + + for (const file of outputFiles) { + if (file.type !== BuildOutputFileType.Browser || !file.path.endsWith('.js')) { + continue; + } + + const map = filesByPath.get(`${file.path}.map`); + if (!map) { + continue; + } + + const id = generateDebugId(map.contents); + file.contents = encoder.encode(injectDebugIdIntoJs(file.text, id)); + map.contents = encoder.encode(injectDebugIdIntoSourceMap(map.text, id)); + } +} diff --git a/packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts b/packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts index f3ec9476b21f..a369f5561385 100644 --- a/packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts @@ -6,8 +6,23 @@ * found in the LICENSE file at https://angular.dev/license */ +import { getSystemPath } from '@angular-devkit/core'; +import { createHash } from 'node:crypto'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { buildApplication } from '../../index'; -import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder, expectNoLog } from '../setup'; +import { + APPLICATION_BUILDER_INFO, + BASE_OPTIONS, + describeBuilder, + expectNoLog, + host, +} from '../setup'; + +/** Resolve a path inside the harness workspace synchronously. */ +function workspacePath(...segments: string[]): string { + return join(getSystemPath(host.root()), ...segments); +} describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { describe('Option: "subresourceIntegrity"', () => { @@ -65,5 +80,82 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { .content.toMatch(/integrity="\w+-[A-Za-z0-9/+=]+"/); expectNoLog(logs, /subresource-integrity/); }); + + it(`embeds an ECMA-426 debugId in JS and source map and the integrity matches`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + subresourceIntegrity: true, + sourceMap: { scripts: true }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + + const distDir = workspacePath('dist/browser'); + const allEntries = readdirSync(distDir); + const jsFiles = allEntries.filter( + (f) => f.endsWith('.js') && allEntries.includes(`${f}.map`), + ); + expect(jsFiles.length).toBeGreaterThan(0); + + const debugIdRe = /^\s*\/\/\s*#\s*debugId=([0-9a-f-]+)\s*$/m; + const indexHtml = harness.readFile('dist/browser/index.html'); + const importmapMatch = indexHtml.match(/