From d72a80a750b979854b7fdea4be75574f541023e0 Mon Sep 17 00:00:00 2001 From: Michael Cebrian Date: Wed, 18 Mar 2026 07:06:43 -0400 Subject: [PATCH] fix(@angular/build): inline external sourcemaps for workspace library files When no Babel plugins are required, the JavaScript transformer returns library files as-is, preserving the comment but never reading the referenced map file from disk. esbuild does not follow external sourcemap links in input files, so the chain from bundled output back to the original TypeScript source is never formed. Read the external map file and return an inline base64 sourcemap instead. esbuild processes inline sourcemaps from input files correctly, allowing it to compose the full sourcemap chain through to the original TypeScript source. --- .../esbuild/javascript-transformer-worker.ts | 41 +++++++++++++++- .../tools/esbuild/javascript-transformer.ts | 49 +++++++++++++++++-- 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts b/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts index 7d86009b773f..be23bef06877 100644 --- a/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts +++ b/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts @@ -95,7 +95,46 @@ async function transformWithBabel( // If no additional transformations are needed, return the data directly if (plugins.length === 0) { // Strip sourcemaps if they should not be used - return useInputSourcemap ? data : data.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, ''); + if (!useInputSourcemap) { + return data.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, ''); + } + + // Inline any external sourceMappingURL so esbuild can chain through to the original source. + // When no Babel plugins run, external map references are preserved in the returned data but + // esbuild does not follow them. Converting to an inline base64 map allows esbuild to compose + // the full sourcemap chain from bundle output back to the original TypeScript source. + const externalMapMatch = /^\/\/# sourceMappingURL=(?!data:)([^\r\n]+)/m.exec(data); + if (externalMapMatch) { + const mapRef = externalMapMatch[1]; + const fileDir = path.dirname(filename); + const mapPath = path.resolve(fileDir, mapRef); + // Reject path traversal — the resolved map file must remain within the source + // file's directory tree and must be a .map file. This prevents a crafted + // sourceMappingURL from reading arbitrary files from disk. + const fileDirPrefix = fileDir.endsWith(path.sep) ? fileDir : fileDir + path.sep; + if (!mapPath.startsWith(fileDirPrefix) || !mapPath.endsWith('.map')) { + return data; + } + try { + const mapContent = await fs.promises.readFile(mapPath, 'utf-8'); + const inlineMap = Buffer.from(mapContent).toString('base64'); + // Strip ALL sourceMappingURL comments before appending the composed inline map. + // When allowJs + inlineSourceMap are enabled, the TypeScript compiler preserves + // the original external reference AND appends its own data: inline sourcemap. + // esbuild uses the last comment, so leaving both would cause it to follow the + // TS-generated map (which only traces back to the compiled JS, not TypeScript). + const stripped = data.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, ''); + return stripped.trimEnd() + '\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,' + inlineMap + '\n'; + } catch (error) { + // Map file not readable; return data with the original external reference + // eslint-disable-next-line no-console + console.warn( + `Unable to inline sourcemap for '${filename}': ${error instanceof Error ? error.message : error}`, + ); + } + } + + return data; } const result = await transformAsync(data, { diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts b/packages/angular/build/src/tools/esbuild/javascript-transformer.ts index b728a0f599e2..11236507ef5f 100644 --- a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts +++ b/packages/angular/build/src/tools/esbuild/javascript-transformer.ts @@ -8,6 +8,7 @@ import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; +import path from 'node:path'; import { IMPORT_EXEC_ARGV } from '../../utils/server-rendering/esm-in-memory-loader/utils'; import { WorkerPool, WorkerPoolOptions } from '../../utils/worker-pool'; import { Cache } from './cache'; @@ -165,10 +166,50 @@ export class JavaScriptTransformer { this.#commonOptions.sourcemap && (!!this.#commonOptions.thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename)); - return Buffer.from( - keepSourcemap ? data : data.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, ''), - 'utf-8', - ); + if (!keepSourcemap) { + return Buffer.from(data.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, ''), 'utf-8'); + } + + // Inline any external sourceMappingURL so esbuild can chain through to the original source. + // When no Babel plugins run, external map references are preserved in the returned data but + // esbuild does not follow them. Converting to an inline base64 map allows esbuild to compose + // the full sourcemap chain from bundle output back to the original TypeScript source. + const externalMapMatch = /^\/\/# sourceMappingURL=(?!data:)([^\r\n]+)/m.exec(data); + if (externalMapMatch) { + const mapRef = externalMapMatch[1]; + const fileDir = path.dirname(filename); + const mapPath = path.resolve(fileDir, mapRef); + // Reject path traversal — the resolved map file must remain within the source + // file's directory tree and must be a .map file. This prevents a crafted + // sourceMappingURL from reading arbitrary files from disk. + const fileDirPrefix = fileDir.endsWith(path.sep) ? fileDir : fileDir + path.sep; + if (mapPath.startsWith(fileDirPrefix) && mapPath.endsWith('.map')) { + try { + const mapContent = await readFile(mapPath, 'utf-8'); + const inlineMap = Buffer.from(mapContent).toString('base64'); + // Strip ALL sourceMappingURL comments before appending the composed inline map. + // When allowJs + inlineSourceMap are enabled, the TypeScript compiler preserves + // the original external reference AND appends its own data: inline sourcemap. + // esbuild uses the last comment, so leaving both would cause it to follow the + // TS-generated map (which only traces back to the compiled JS, not TypeScript). + const stripped = data.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, ''); + const result = + stripped.trimEnd() + + '\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,' + + inlineMap + + '\n'; + return Buffer.from(result, 'utf-8'); + } catch (error) { + // Map file not readable; return data with the original external reference + // eslint-disable-next-line no-console + console.warn( + `Unable to inline sourcemap for '${filename}': ${error instanceof Error ? error.message : error}`, + ); + } + } + } + + return Buffer.from(data, 'utf-8'); } return this.#ensureWorkerPool().run({