Skip to content

Commit d72a80a

Browse files
committed
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.
1 parent 98450e1 commit d72a80a

File tree

2 files changed

+85
-5
lines changed

2 files changed

+85
-5
lines changed

packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,46 @@ async function transformWithBabel(
9595
// If no additional transformations are needed, return the data directly
9696
if (plugins.length === 0) {
9797
// Strip sourcemaps if they should not be used
98-
return useInputSourcemap ? data : data.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, '');
98+
if (!useInputSourcemap) {
99+
return data.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, '');
100+
}
101+
102+
// Inline any external sourceMappingURL so esbuild can chain through to the original source.
103+
// When no Babel plugins run, external map references are preserved in the returned data but
104+
// esbuild does not follow them. Converting to an inline base64 map allows esbuild to compose
105+
// the full sourcemap chain from bundle output back to the original TypeScript source.
106+
const externalMapMatch = /^\/\/# sourceMappingURL=(?!data:)([^\r\n]+)/m.exec(data);
107+
if (externalMapMatch) {
108+
const mapRef = externalMapMatch[1];
109+
const fileDir = path.dirname(filename);
110+
const mapPath = path.resolve(fileDir, mapRef);
111+
// Reject path traversal — the resolved map file must remain within the source
112+
// file's directory tree and must be a .map file. This prevents a crafted
113+
// sourceMappingURL from reading arbitrary files from disk.
114+
const fileDirPrefix = fileDir.endsWith(path.sep) ? fileDir : fileDir + path.sep;
115+
if (!mapPath.startsWith(fileDirPrefix) || !mapPath.endsWith('.map')) {
116+
return data;
117+
}
118+
try {
119+
const mapContent = await fs.promises.readFile(mapPath, 'utf-8');
120+
const inlineMap = Buffer.from(mapContent).toString('base64');
121+
// Strip ALL sourceMappingURL comments before appending the composed inline map.
122+
// When allowJs + inlineSourceMap are enabled, the TypeScript compiler preserves
123+
// the original external reference AND appends its own data: inline sourcemap.
124+
// esbuild uses the last comment, so leaving both would cause it to follow the
125+
// TS-generated map (which only traces back to the compiled JS, not TypeScript).
126+
const stripped = data.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, '');
127+
return stripped.trimEnd() + '\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,' + inlineMap + '\n';
128+
} catch (error) {
129+
// Map file not readable; return data with the original external reference
130+
// eslint-disable-next-line no-console
131+
console.warn(
132+
`Unable to inline sourcemap for '${filename}': ${error instanceof Error ? error.message : error}`,
133+
);
134+
}
135+
}
136+
137+
return data;
99138
}
100139

101140
const result = await transformAsync(data, {

packages/angular/build/src/tools/esbuild/javascript-transformer.ts

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import { createHash } from 'node:crypto';
1010
import { readFile } from 'node:fs/promises';
11+
import path from 'node:path';
1112
import { IMPORT_EXEC_ARGV } from '../../utils/server-rendering/esm-in-memory-loader/utils';
1213
import { WorkerPool, WorkerPoolOptions } from '../../utils/worker-pool';
1314
import { Cache } from './cache';
@@ -165,10 +166,50 @@ export class JavaScriptTransformer {
165166
this.#commonOptions.sourcemap &&
166167
(!!this.#commonOptions.thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename));
167168

168-
return Buffer.from(
169-
keepSourcemap ? data : data.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, ''),
170-
'utf-8',
171-
);
169+
if (!keepSourcemap) {
170+
return Buffer.from(data.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, ''), 'utf-8');
171+
}
172+
173+
// Inline any external sourceMappingURL so esbuild can chain through to the original source.
174+
// When no Babel plugins run, external map references are preserved in the returned data but
175+
// esbuild does not follow them. Converting to an inline base64 map allows esbuild to compose
176+
// the full sourcemap chain from bundle output back to the original TypeScript source.
177+
const externalMapMatch = /^\/\/# sourceMappingURL=(?!data:)([^\r\n]+)/m.exec(data);
178+
if (externalMapMatch) {
179+
const mapRef = externalMapMatch[1];
180+
const fileDir = path.dirname(filename);
181+
const mapPath = path.resolve(fileDir, mapRef);
182+
// Reject path traversal — the resolved map file must remain within the source
183+
// file's directory tree and must be a .map file. This prevents a crafted
184+
// sourceMappingURL from reading arbitrary files from disk.
185+
const fileDirPrefix = fileDir.endsWith(path.sep) ? fileDir : fileDir + path.sep;
186+
if (mapPath.startsWith(fileDirPrefix) && mapPath.endsWith('.map')) {
187+
try {
188+
const mapContent = await readFile(mapPath, 'utf-8');
189+
const inlineMap = Buffer.from(mapContent).toString('base64');
190+
// Strip ALL sourceMappingURL comments before appending the composed inline map.
191+
// When allowJs + inlineSourceMap are enabled, the TypeScript compiler preserves
192+
// the original external reference AND appends its own data: inline sourcemap.
193+
// esbuild uses the last comment, so leaving both would cause it to follow the
194+
// TS-generated map (which only traces back to the compiled JS, not TypeScript).
195+
const stripped = data.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, '');
196+
const result =
197+
stripped.trimEnd() +
198+
'\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,' +
199+
inlineMap +
200+
'\n';
201+
return Buffer.from(result, 'utf-8');
202+
} catch (error) {
203+
// Map file not readable; return data with the original external reference
204+
// eslint-disable-next-line no-console
205+
console.warn(
206+
`Unable to inline sourcemap for '${filename}': ${error instanceof Error ? error.message : error}`,
207+
);
208+
}
209+
}
210+
}
211+
212+
return Buffer.from(data, 'utf-8');
172213
}
173214

174215
return this.#ensureWorkerPool().run({

0 commit comments

Comments
 (0)