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({