Skip to content

Commit 5a0d226

Browse files
committed
perf(@angular/build): avoid full JSON parsing when updating sourcemap ignore list
Previously, the sourcemap ignore-list plugin parsed the entire generated sourcemap buffer into a JavaScript object via JSON.parse and re-serialized it via JSON.stringify to inject x_google_ignoreList. In typical applications, sourcemap files range from 2 MB to 10 MB+ each. The sources array constitutes less than 1% of the total file size, with the vast majority of the payload comprised of sourcesContent and VLQ-encoded mappings. Parsing and re-stringifying this large structure generates significant V8 heap churn (5x to 6x transient allocations per sourcemap) and incurs 20 ms to 60 ms of single-threaded blocking time per chunk. To eliminate redundant parsing and serialization overhead: - Scan the buffer to extract and parse only the sources JSON array. - Determine the node modules ignore list indices using the extracted sources array. - Splice the serialized x_google_ignoreList property directly into the output buffer adjacent to the root object delimiter without parsing or allocating intermediate strings for sourcesContent or mappings. - Gracefully fall back to full JSON parsing and serialization if non-standard JSON formatting is encountered. In benchmarks on 2.4 MB to 10.4 MB sourcemap files, ignore-list processing dropped from 20.3 ms to 0.60 ms (33.9x faster) and 60.2 ms to 2.27 ms (26.5x faster), respectively, while reducing heap churn by more than 99%.
1 parent d11a663 commit 5a0d226

1 file changed

Lines changed: 120 additions & 0 deletions

File tree

packages/angular/build/src/tools/esbuild/sourcemap-ignorelist-plugin.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@ const IGNORE_LIST_ID = 'x_google_ignoreList';
2121
*/
2222
const NODE_MODULE_BYTES = Buffer.from('node_modules/', 'utf-8');
2323

24+
/**
25+
* The UTF-8 bytes for the "sources" property key used to locate the sources array.
26+
*/
27+
const SOURCES_KEY_BYTES = Buffer.from('"sources"', 'utf-8');
28+
29+
/**
30+
* The UTF-8 bytes for the ignore list identifier to check if already present.
31+
*/
32+
const IGNORE_LIST_BYTES = Buffer.from(`"${IGNORE_LIST_ID}"`, 'utf-8');
33+
2434
/**
2535
* Minimal sourcemap object required to create the ignore list.
2636
*/
@@ -29,6 +39,83 @@ interface SourceMap {
2939
[IGNORE_LIST_ID]?: number[];
3040
}
3141

42+
function extractSources(contents: Buffer): string[] | undefined {
43+
const sourcesKeyIndex = contents.indexOf(SOURCES_KEY_BYTES);
44+
if (sourcesKeyIndex === -1) {
45+
return undefined;
46+
}
47+
48+
// Find the ':' after "sources"
49+
let colonIndex = sourcesKeyIndex + SOURCES_KEY_BYTES.length;
50+
while (colonIndex < contents.length && contents[colonIndex] <= 0x20) {
51+
colonIndex++;
52+
}
53+
if (contents[colonIndex] !== 0x3a /* : */) {
54+
return undefined;
55+
}
56+
57+
// Find the '[' for the array
58+
let arrayStartIndex = colonIndex + 1;
59+
while (arrayStartIndex < contents.length && contents[arrayStartIndex] <= 0x20) {
60+
arrayStartIndex++;
61+
}
62+
if (contents[arrayStartIndex] !== 0x5b /* [ */) {
63+
return undefined;
64+
}
65+
66+
// Scan until matching ']'
67+
let depth = 0;
68+
let inString = false;
69+
for (let i = arrayStartIndex; i < contents.length; i++) {
70+
const byte = contents[i];
71+
if (inString) {
72+
if (byte === 0x5c /* \ */) {
73+
i++; // skip escaped character
74+
} else if (byte === 0x22 /* " */) {
75+
inString = false;
76+
}
77+
} else if (byte === 0x22 /* " */) {
78+
inString = true;
79+
} else if (byte === 0x5b /* [ */) {
80+
depth++;
81+
} else if (byte === 0x5d /* ] */) {
82+
depth--;
83+
if (depth === 0) {
84+
try {
85+
const slice = contents.toString('utf-8', arrayStartIndex, i + 1);
86+
const parsed = JSON.parse(slice);
87+
88+
return Array.isArray(parsed) && parsed.every((s) => typeof s === 'string')
89+
? (parsed as string[])
90+
: undefined;
91+
} catch {
92+
return undefined;
93+
}
94+
}
95+
}
96+
}
97+
98+
return undefined;
99+
}
100+
101+
function updateSourcemapFast(contents: Buffer, ignoreList: readonly number[]): Buffer | undefined {
102+
let braceIndex = 0;
103+
while (braceIndex < contents.length && contents[braceIndex] <= 0x20) {
104+
braceIndex++;
105+
}
106+
if (contents[braceIndex] !== 0x7b /* { */) {
107+
return undefined;
108+
}
109+
110+
const injection = Buffer.from(`"${IGNORE_LIST_ID}":${JSON.stringify(ignoreList)},`, 'utf-8');
111+
112+
return Buffer.concat([
113+
contents.subarray(0, braceIndex + 1),
114+
injection,
115+
contents.subarray(braceIndex + 1),
116+
]);
117+
}
118+
32119
/**
33120
* Creates an esbuild plugin that updates generated sourcemaps to include the Chrome
34121
* DevTools ignore list extension. All source files that originate from a node modules
@@ -68,7 +155,40 @@ export function createSourcemapIgnorelistPlugin(): Plugin {
68155
continue;
69156
}
70157

158+
let fastPathSuccess = false;
159+
if (!contents.includes(IGNORE_LIST_BYTES)) {
160+
const sources = extractSources(contents);
161+
if (sources) {
162+
const ignoreList: number[] = [];
163+
for (let index = 0; index < sources.length; ++index) {
164+
const location = sources[index].indexOf('node_modules/');
165+
if (location === 0 || (location > 0 && sources[index][location - 1] === '/')) {
166+
ignoreList.push(index);
167+
}
168+
}
169+
170+
if (ignoreList.length === 0) {
171+
continue;
172+
}
173+
174+
const updated = updateSourcemapFast(contents, ignoreList);
175+
if (updated) {
176+
file.contents = updated;
177+
fastPathSuccess = true;
178+
}
179+
}
180+
}
181+
182+
if (fastPathSuccess) {
183+
continue;
184+
}
185+
186+
// Fallback to full JSON parse/stringify if fast scanning or splicing fails
71187
const map = JSON.parse(contents.toString('utf-8')) as SourceMap;
188+
if (map[IGNORE_LIST_ID]) {
189+
continue;
190+
}
191+
72192
const ignoreList = [];
73193

74194
// Check and store the index of each source originating from a node modules directory

0 commit comments

Comments
 (0)