Source Maps: the ECMA-426 Format, Hidden Source Maps in Production, Error Tracking and Security
A source map is a JSON file that maps every position in minified code back to the source, standardized as ECMA-426 since December 2024; a hidden source map only drops the sourceMappingURL comment, so anyone can download the file if it is on the server. In Bookstore we decode the mappings field's Base64 VLQ, see ASP.NET Core serving the .map file, keep maps out of the deploy and map a production error back to a TypeScript line with a debug ID.
Version note: The examples in this post were run with Angular 22.2 (Angular CLI and @angular/build 22.2.7) with TypeScript 6.0, Vite 8.3 (Rolldown 1.2) with TypeScript 7.0, .NET 10 (ASP.NET Core 10) and Node.js 24; browser behavior was observed with Playwright 1.65's Chromium.
Guess First
We turned on source maps for production in both front ends of Bookstore, the online bookstore used as the shared example across this site's series, but as "hidden" ones. In the Angular client bookstore-web:
"production": {
"outputHashing": "all",
"sourceMap": {
"scripts": true,
"styles": false,
"hidden": true
}
}
The same in the Vite widget embedded in Bookstore.Web:
build: {
manifest: true,
outDir: "../server/Bookstore.Web/wwwroot/dist",
// .map files without a sourceMappingURL comment; the JS and the map share a debugId
sourcemap: "hidden",
rolldownOptions: { output: { sourcemapDebugIds: true } },
},
After npm run build:server there are main-DH5dtSTh.js and main-DH5dtSTh.js.map under wwwroot/dist/assets; the end of the JavaScript file no longer has a sourceMappingURL comment. We put the application live. What happens if a visitor types /dist/assets/main-DH5dtSTh.js.map into the browser?
This is the seventh post of the Build Tools series. In the previous post we used source maps for bundle analysis; now we look at the source map itself: the structure of the file, how the mappings field is encoded, source map strategies in production, matching with error tracking services and the risk of source maps leaking source code. We covered using source maps for debugging in DevTools in the Browser DevTools series post "JavaScript Debugging with the Sources Panel: Breakpoint Types, Source Maps and the Ignore List"; here we stay on the build and production side.
The Structure of a Source Map
A source map is a JSON file that holds, for every position in a minified JavaScript file, the matching position in the source code. The first version of the format was written for Google's Closure Inspector tool, and version 3, which is in use today, lived for years without a formal standard, on the shared agreement of tools. Ecma International published it as a standard for the first time in December 2024 (ECMA-426, "Source map format"). Today the draft of the standard and new proposals are developed in a TC39 working group.
The map Angular produced for the orders-page lazy chunk (sourcesContent shortened):
{
"version": 3,
"file": "chunk-wi8gYIFU.js",
"names": [
"ɵɵdomElementStart",
"ɵɵtext",
"...",
"formatLongDate",
"..."
],
"sources": [
"../src/app/orders/orders-page.ts"
],
"sourcesContent": [
"..."
],
"mappings": "A,+GAcMA,IAAAA,GAAAA,EAAAA,IAAAA,EAAAA,GAAAA,...",
"debugId": "101e1479-3a3f-50a0-963d-dc448ae853e3"
}
The fields:
versionis always 3;fileis the file the map belongs to.sourcesholds the paths of the source files;sourcesContentholds the full content of those files, comments included.namesholds the original names of identifiers renamed during minification: above,formatLongDatebecameuin the output.mappingsis the actual mapping; we unpack it below.ignoreListholds the indexes of entries insourcesthat DevTools and stack traces hide by default. In Angular'smainmap, 87 of the 107 sources come fromnode_modules, and the list contains exactly those 87 files.debugIdis not in the standard itself; it comes from the Debug ID proposal at Stage 2 in TC39, and it will help us with error tracking below.
The mappings Field and Base64 VLQ
mappings separates the lines of the generated file with ; and the segments on each line with ,. Each segment carries 1, 4 or 5 numbers: the column in the generated file, the index into sources, the source line, the source column and, if present, the index into names. Two tricks keep the file small. First, the numbers are not absolute but written as differences from the previous segment; code that sits close together in the source turns into small numbers. Second, each number is encoded with Base64 VLQ: each Base64 character carries 6 bits, 5 of them data and one a "more follows" flag, and the lowest bit of the first character is the sign. To see the idea we wrote a short script that does the decoding by hand:
const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
function decodeVlq(segment) {
const values = [];
let value = 0;
let shift = 0;
for (const char of segment) {
const digit = BASE64.indexOf(char);
value += (digit & 31) << shift;
if (digit & 32) {
shift += 5;
continue;
}
values.push(value & 1 ? -(value >> 1) : value >> 1);
value = 0;
shift = 0;
}
return values;
}
node scripts/decode-mappings.mjs dist/bookstore-web/source-maps/chunk-wi8gYIFU.js.map 500
The part of the output that falls into the placed() method:
CAC3C [1,0,1,-43] -> js 1:421 src/app/orders/orders-page.ts:26:5
OAAOC [7,0,0,7,1] -> js 1:428 src/app/orders/orders-page.ts:26:12 name=formatLongDate
EAAeD [2,0,0,15,-1] -> js 1:430 src/app/orders/orders-page.ts:26:27 name=order
EAAM [2,0,0,6] -> js 1:432 src/app/orders/orders-page.ts:26:33
In the minified file this part is placed(t){return u(t.placedAt)}. The return at column 421 is line 26, column 5 in the source; the u at column 428 is formatLongDate at line 26, column 12. Note that the numbers in brackets are differences: [7,0,0,7,1] means "7 columns right of the previous segment, same file, same line, 7 columns right, the next name". With this encoding, the mappings field of Angular's 224 kB main file takes 316 kB.
Three Strategies in Production
There are three options for source maps in production:
- Do not produce them: Angular's production default. An error from a user looks like
at u (main-2CA6OFEQ.js:4:194805)and is very hard to read. - Public:
ng build --source-map. The JavaScript ends with a//# sourceMappingURL=main-LFF7S2KX.js.mapcomment; DevTools downloads the map on its own. - Hidden: the map is produced but the JavaScript has no
sourceMappingURLcomment. The maps go to the error tracking service, not to users.
We looked at what a public map exposes. We served the output of ng build --source-map with a static server and requested the map:
public build: GET /main-LFF7S2KX.js.map -> 200 1977155 bytes
A 1.98 MB map for a 224 kB JavaScript file. 1.51 MB of it is sourcesContent: the TypeScript source of Angular, RxJS and our own code, with comments. We searched the downloaded file for the comment we wrote in collections.ts in the previous post and found it: Object.groupBy is Baseline only since March 2024 (Safari 17.4), .... In Bookstore that is harmless; but if code comments contain names of internal systems, an old API address or notes like "we turned this check off for now", a public map hands them to everyone. A source map is not a vulnerability in itself; the JavaScript sent to the browser is readable anyway. But sourcesContent carries all of the unminified, commented source code.
In Angular, sourcesContent: false keeps the content out of the map; in the same build the main map dropped from 1,977,155 bytes to 415,618 bytes and the comment was no longer in the map. The price is that the error tracking service cannot show the code around the failing line. If you never hand the maps to users, leaving sourcesContent on is more useful. vendor: true follows the packages' own source maps in node_modules as well, taking the map one step closer to the original source; its default is false.
Hidden Source Maps and Deploy
The answer to the guess: ASP.NET Core serves the map. We started Bookstore.Web with the vite-manifest profile and sent two requests:
GET /dist/assets/main-DH5dtSTh.js -> 200 text/javascript
GET /dist/assets/main-DH5dtSTh.js.map -> 200 text/plain 25554
"Hidden" only removes the comment from the JavaScript; as long as the file sits in wwwroot, the static files middleware hands it to anyone. The file name is no secret either: appending .map to the JavaScript name is enough. A hidden source map only means something once the maps are taken out of the folder that gets deployed. In the widget we added this to the build:server script:
// Runs after "vite build --mode server": wwwroot is served as static files, and ASP.NET Core
// serves .map files too, so the hidden source maps move out of it before publish
import { mkdirSync, readdirSync, readFileSync, renameSync } from 'node:fs';
import { join } from 'node:path';
const assetsDir = '../server/Bookstore.Web/wwwroot/dist/assets';
const mapsDir = 'source-maps';
mkdirSync(mapsDir, { recursive: true });
for (const file of readdirSync(assetsDir).filter((name) => name.endsWith('.map'))) {
const { debugId } = JSON.parse(readFileSync(join(assetsDir, file), 'utf8'));
renameSync(join(assetsDir, file), join(mapsDir, file));
console.log(`${file.padEnd(28)} ${debugId}`);
}
"build:server": "vite build --mode server && node scripts/collect-source-maps.mjs"
On the Angular side, scripts/collect-source-maps.mjs does the same job, moving the maps from dist/bookstore-web/browser to dist/bookstore-web/source-maps, and npm run build:prod runs both steps together:
chunk-Blx69gxB.js.map cfe2585f-0e0e-5b1f-a535-135cee5d3910
chunk-ClSMPZ-e.js.map a5f9776f-ff26-5bc2-a12b-16efa767e9f0
chunk-NfCRZQFd.js.map 5d854da3-1af5-54b0-ad95-c07174333e98
chunk-wi8gYIFU.js.map 101e1479-3a3f-50a0-963d-dc448ae853e3
main-2CA6OFEQ.js.map 04ab4fa9-7d79-5999-a5c7-759004c81f7e
This time, on the same server serving the deployed browser folder, GET /main-2CA6OFEQ.js.map returned 404. In CI the source-maps folder is uploaded to the error tracking service and never enters the deployed artifact.
Error Tracking: Matching with a Debug ID
Finding where a production error sits in the source code is the core of what error tracking services do. We did it ourselves, without a service, using the same steps. First we replaced Angular's ErrorHandler with a small class that stands in for an error tracking SDK:
import { ErrorHandler, Injectable } from '@angular/core';
// A stand-in for an error tracking SDK: one JSON line per error, with the minified stack
@Injectable()
export class BookstoreErrorHandler implements ErrorHandler {
handleError(error: unknown): void {
const report = {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
url: location.href,
};
console.error(`[bookstore-error] ${JSON.stringify(report)}`);
}
}
{ provide: ErrorHandler, useClass: BookstoreErrorHandler },
Then we imitated one of the most common errors in real life: bad data coming from the API. We temporarily added an order whose date is 'pending' to the orders list and built with npm run build:prod; that is why the line numbers below are one higher than in the snapshot's file. The report we got when we opened the "My orders" page with Playwright (port written as 4200, stack shortened):
{
"message": "Invalid time value",
"stack": "RangeError: Invalid time value\n at u (http://localhost:4200/main-Y5HWTT5K.js:4:194805)\n at r.placed (http://localhost:4200/chunk-DcrQce8c.js:1:478)\n at g (http://localhost:4200/chunk-DcrQce8c.js:1:231)\n at Vl (http://localhost:4200/main-Y5HWTT5K.js:4:48781)\n ...",
"url": "http://localhost:4200/orders"
}
Getting from this stack to the source code takes three steps, and scripts/symbolicate.mjs does them in order. For each line of the stack it finds the JavaScript file, reads the debug ID from the //# debugId= comment at the end of the file, and picks the map in the source-maps folder that carries the same debugId. Translating the position needs no extra package; the findOrigin method of the SourceMap class in Node.js's node:module module does it:
const mapsByDebugId = new Map();
for (const file of readdirSync(mapsDir).filter((name) => name.endsWith('.js.map'))) {
const payload = JSON.parse(readFileSync(join(mapsDir, file), 'utf8'));
mapsByDebugId.set(payload.debugId, new SourceMap(payload));
}
function debugIdOf(jsFile) {
const match = readFileSync(join(browserDir, jsFile), 'utf8').match(/\/\/# debugId=([0-9a-f-]+)/);
return match?.[1];
}
node scripts/symbolicate.mjs report.json
Invalid time value
at formatLongDate (src/app/shared/dates.ts:23:19)
at r.placed (src/app/orders/orders-page.ts:27:12)
at templateFn (src/app/orders/orders-page.ts:15:10)
at executeTemplate (node_modules/@angular/core/fesm2022/_debug_node-chunk.mjs:5529:5)
at refreshView (node_modules/@angular/core/fesm2022/_debug_node-chunk.mjs:6224:7)
Line 23, column 19 of dates.ts is format in the longDate.format(new Date(iso)) call: the line that tries to format an invalid Date. Below it is placed(), which calls this function, and below that {{ placed(order) }} in the template.
Why is a debug ID needed? Matching the map by file name also works, but it is fragile: CDNs can change the address, and the same file name can carry different content in different releases. The debug ID ties a JavaScript file to its map with an identity independent of location. Since Angular 22.1, every build with script source maps on writes this identity itself; in the @angular/build source the ID is derived from a hash (SHA-1) of the map's content in UUID format, so the same code gets the same ID on every build. In Vite, Rolldown's sourcemapDebugIds option does the same. Services like Sentry use the same identity; Sentry's docs describe writing the ID with sentry-cli sourcemaps inject and uploading the maps with sentry-cli sourcemaps upload if you do not use its bundler plugin. Since Angular and Rolldown already write the ID, the first step does nothing in these projects.
Function Names and the Scopes Proposal
Look at the function names in the output. In our first attempt the script took each line's name from its own position and printed u instead of formatLongDate for the dates.ts:23 line. The reason is what the names field means: the name in a segment is the name of the identifier at that position, that is, the name of the function called on that line. To find the name of the function a frame runs in, you have to look at the call site in the frame below; our script does that, and error tracking services work with similar heuristics. This does not always work: on the second line we could not find the name and the browser's r.placed remained. What the user really means is their own placed method.
The Scopes proposal, at Stage 3 in TC39, closes this gap. It adds the scopes of the source code (functions, blocks and the variables inside them) and their counterparts in the generated code to the map. With that, a debugger will be able to show the original name of a minified and inlined function, frames that are missing from the stack because they were inlined, and the values of renamed variables. At the time of writing the proposal's document refers to a prototype, not a browser implementation; it is not a setting you can turn on in build tools today. The Range Mappings (Stage 2) and Debug ID (Stage 2) proposals in the same repository are also candidates for upcoming versions of the standard.
Production Notes
Keep the maps separate from the deployed artifact, and check it in CI. A one-line check that stops the build if a .map file is left in the dist folder keeps the "hidden" setting from giving false confidence.
Store the maps together with the release. An error report can arrive three weeks later; if that build's map is gone, knowing the debug ID is of no use. If you upload to an error tracking service, check its retention period too.
Keep sourceMap.styles off in production in most cases. In Angular, maps for global and component styles are of no use in error reports but make the artifact larger; our config has styles: false.
Turning on source maps changes the output. In Angular the debug ID is derived from the map's content, so when we turned sourcesContent off the hashes of the JavaScript files changed too: the debugId comment at the end of each file was different. Changing the source map setting means users download every file again.
Common Pitfalls
First, reading hidden: true as "the maps are secret". Only the comment in the JavaScript goes away; if the map file is on the server, its address can be guessed, and most static file servers, ASP.NET Core included, will serve it.
Second, forgetting what sourcesContent carries. It carries all the source code, comments included; a public map means distributing a readable copy of the code.
Third, matching a map with the wrong build. Matching by file name points to the wrong line when the same name carries different content. Use the debug ID, or at least keep the maps in separate folders per release.
Fourth, trusting the function names in error tracking blindly. The names are inferred from the names field with heuristics; the line and column are reliable, the name sometimes is not.
Fifth, treating source maps as a security measure or a vulnerability. Minified code is readable too; anything that must stay secret (an API key, a business rule) should not be in the JavaScript sent to the browser. We said the same about VITE_ variables in the Vite post of this series.
A Small Experiment
★☆☆ Run npm run build:prod in snapshot/07/web and check that no .map file is left in dist/bookstore-web/browser, and that the debugId of every map in the source-maps folder matches the last line of its JavaScript file.
★★☆ Extend decode-mappings.mjs: for a given source file and line, list all generated positions mapped to that line. Find where the groupBy call in catalog-page.ts lands in the minified code.
★★★ Add a feature to symbolicate.mjs that uses sourcesContent: for each frame, print the source line and the lines just above and below it. Then build with sourcesContent: false and resolve the same report; make sure the script carries on with a clear message in that case.
In the next post we move to the last post of the series: the 2025–2026 release map with Rolldown, Oxc, Rspack, Turbopack and Lightning CSS, and how to repeat the measurements from this series in your own project.