1// A production Hermes map has over a million segments. General-purpose decoders hold
2// each as its own array, past a Worker isolate's memory budget; typed arrays fit.
3
4const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
5const BASE64_VALUES = new Int8Array(128).fill(-1);
6for (let index = 0; index < BASE64.length; index += 1) {
7 BASE64_VALUES[BASE64.charCodeAt(index)] = index;
8}
9const COMMA = 44;
10const SEMICOLON = 59;
11/** The relay caches two decoded maps in a 128 MB isolate. */
12const MAX_DECODED_BYTES = 32 * 1024 * 1024;
13
14export interface CompactSourceMap {
15 readonly sources: ReadonlyArray<string>;
16 /** Segment index where each generated line starts, plus one past the last line. */
17 readonly lineStarts: Int32Array;
18 readonly generatedColumns: Int32Array;
19 /** -1 for segments that map to no source. */
20 readonly sourceIndexes: Int32Array;
21 readonly originalLines: Int32Array;
22 readonly originalColumns: Int32Array;
23}
24
25interface OriginalPosition {
26 readonly source: string;
27 /** 1-based. */
28 readonly line: number;
29 /** 0-based, as source maps store it. */
30 readonly column: number;
31}
32
33const isStringArray = (value: unknown): value is ReadonlyArray<string> =>
34 Array.isArray(value) && value.every((item) => typeof item === "string");
35
36/**
37 * Calls `visit` per segment in order; `sourceIndex` is -1 for a segment that maps to no source.
38 * False if a segment is malformed.
39 */
40function walkSegments(
41 mappings: string,
42 visit: (
43 line: number,
44 generatedColumn: number,
45 sourceIndex: number,
46 originalLine: number,
47 originalColumn: number,
48 ) => void,
49): boolean {
50 // Names keep the running offsets right but are not stored: they name an identifier, not the function.
51 const fields = [0, 0, 0, 0, 0];
52 let line = 0;
53 let generatedColumn = 0;
54 let sourceIndex = 0;
55 let originalLine = 0;
56 let originalColumn = 0;
57 let index = 0;
58
59 while (index < mappings.length) {
60 const code = mappings.charCodeAt(index);
61 if (code === SEMICOLON) {
62 line += 1;
63 generatedColumn = 0;
64 index += 1;
65 continue;
66 }
67 if (code === COMMA) {
68 index += 1;
69 continue;
70 }
71
72 let fieldCount = 0;
73 while (index < mappings.length) {
74 const next = mappings.charCodeAt(index);
75 if (next === COMMA || next === SEMICOLON) break;
76 let value = 0;
77 let shift = 0;
78 let digit: number;
79 do {
80 const char = mappings.charCodeAt(index);
81 digit = char < 128 ? BASE64_VALUES[char]! : -1;
82 if (digit === -1) return false;
83 value += (digit & 31) * 2 ** shift;
84 shift += 5;
85 index += 1;
86 } while (digit & 32);
87 const negative = value % 2 === 1;
88 value = Math.floor(value / 2);
89 if (fieldCount < fields.length) fields[fieldCount] = negative ? -value : value;
90 fieldCount += 1;
91 }
92 if (fieldCount !== 1 && fieldCount !== 4 && fieldCount !== 5) return false;
93
94 generatedColumn += fields[0]!;
95 if (fieldCount === 1) {
96 visit(line, generatedColumn, -1, 0, 0);
97 } else {
98 sourceIndex += fields[1]!;
99 originalLine += fields[2]!;
100 originalColumn += fields[3]!;
101 visit(line, generatedColumn, sourceIndex, originalLine, originalColumn);
102 }
103 }
104 return true;
105}
106
107/**
108 * `too_large` past the decode budget; `invalid` for anything but a version 3 map, including
109 * index maps, which React Native never emits.
110 */
111export function decodeSourceMap(raw: unknown): CompactSourceMap | "too_large" | "invalid" {
112 if (typeof raw !== "object" || raw === null) return "invalid";
113 const version = Reflect.get(raw, "version");
114 const mappings = Reflect.get(raw, "mappings");
115 const sources = Reflect.get(raw, "sources");
116 const sourceRoot = Reflect.get(raw, "sourceRoot");
117 if (version !== 3 || typeof mappings !== "string" || !isStringArray(sources)) return "invalid";
118
119 // Hermes maps most bytecode instructions to the same position as the one before; a lookup
120 // lands on the earlier segment instead with the same answer. Dropping them shrinks our iOS
121 // map from 2.1M segments to 1.2M, so the first pass counts what the second keeps.
122 let lineCount = 1;
123 for (let index = 0; index < mappings.length; index += 1) {
124 if (mappings.charCodeAt(index) === SEMICOLON) lineCount += 1;
125 }
126 let kept = 0;
127 let lastLine = -1;
128 let lastSource = -2;
129 let lastOriginalLine = 0;
130 let lastOriginalColumn = 0;
131 const repeats = (line: number, source: number, originalLine: number, originalColumn: number) => {
132 const same =
133 line === lastLine &&
134 source === lastSource &&
135 (source === -1 ||
136 (originalLine === lastOriginalLine && originalColumn === lastOriginalColumn));
137 lastLine = line;
138 lastSource = source;
139 lastOriginalLine = originalLine;
140 lastOriginalColumn = originalColumn;
141 return same;
142 };
143 const counted = walkSegments(mappings, (line, _column, source, originalLine, originalColumn) => {
144 if (!repeats(line, source, originalLine, originalColumn)) kept += 1;
145 });
146 if (!counted) return "invalid";
147
148 const prefixLength = typeof sourceRoot === "string" ? sourceRoot.length + 1 : 0;
149 let sourceChars = 0;
150 for (const source of sources) sourceChars += source.length + prefixLength;
151 // A path takes up to two bytes a character, plus its string header and array slot.
152 const decodedBytes = kept * 16 + (lineCount + 1) * 4 + sourceChars * 2 + sources.length * 32;
153 if (decodedBytes > MAX_DECODED_BYTES) return "too_large";
154
155 const lineStarts = new Int32Array(lineCount + 1);
156 const generatedColumns = new Int32Array(kept);
157 const sourceIndexes = new Int32Array(kept);
158 const originalLines = new Int32Array(kept);
159 const originalColumns = new Int32Array(kept);
160 let segment = 0;
161 let currentLine = 0;
162 lastLine = -1;
163 lastSource = -2;
164 walkSegments(mappings, (line, generatedColumn, source, originalLine, originalColumn) => {
165 if (repeats(line, source, originalLine, originalColumn)) return;
166 while (currentLine < line) {
167 currentLine += 1;
168 lineStarts[currentLine] = segment;
169 }
170 generatedColumns[segment] = generatedColumn;
171 sourceIndexes[segment] = source;
172 originalLines[segment] = originalLine;
173 originalColumns[segment] = originalColumn;
174 segment += 1;
175 });
176 while (currentLine < lineCount) {
177 currentLine += 1;
178 lineStarts[currentLine] = segment;
179 }
180
181 const prefix = typeof sourceRoot === "string" && sourceRoot !== "" ? sourceRoot : undefined;
182 return {
183 sources: prefix ? sources.map((source) => `${prefix.replace(/\/?$/u, "/")}${source}`) : sources,
184 lineStarts,
185 generatedColumns,
186 sourceIndexes,
187 originalLines,
188 originalColumns,
189 };
190}
191
192/** `line` is 1-based and `column` 0-based, as in `@jridgewell/trace-mapping`. */
193function originalPositionFor(
194 map: CompactSourceMap,
195 line: number,
196 column: number,
197): OriginalPosition | undefined {
198 const lineIndex = line - 1;
199 if (lineIndex < 0 || lineIndex >= map.lineStarts.length - 1) return undefined;
200 const lineStart = map.lineStarts[lineIndex]!;
201 // An exact hit takes the first segment at `column`: Hermes maps a function's first
202 // instruction twice (declaration, then first statement), and other libraries take the first.
203 let low = lineStart;
204 let high = map.lineStarts[lineIndex + 1]!;
205 while (low < high) {
206 const middle = (low + high) >>> 1;
207 if (map.generatedColumns[middle]! < column) low = middle + 1;
208 else high = middle;
209 }
210 const exact = low < map.lineStarts[lineIndex + 1]! && map.generatedColumns[low] === column;
211 const found = exact ? low : low - 1;
212 if (found < lineStart) return undefined;
213 const sourceIndex = map.sourceIndexes[found]!;
214 const source = sourceIndex === -1 ? undefined : map.sources[sourceIndex];
215 if (source === undefined) return undefined;
216 return {
217 source,
218 line: map.originalLines[found]! + 1,
219 column: map.originalColumns[found]!,
220 };
221}
222
223/**
224 * `at render (address at index.android.bundle:1:48213)`: Hermes prints the bytecode offset,
225 * the composed map's generated column. On iOS the path is absolute, and an OTA bundle's
226 * contains a space (`Application Support`).
227 */
228const BUNDLE_FRAME = /address at (.+?):(\d+):(\d+)(?=\)?$)/u;
229/** Hermes's own runtime, not in the map. */
230const HERMES_RUNTIME = "InternalBytecode.js";
231
232const baseName = (path: string) => path.slice(path.lastIndexOf("/") + 1);
233
234/** iOS bundle paths carry per-install container UUIDs, which split one crash into many groups. */
235export function stripBundlePaths(stack: string): string {
236 return stack
237 .split("\n")
238 .map((line) => {
239 const match = BUNDLE_FRAME.exec(line);
240 if (!match || !match[1]!.includes("/")) return line;
241 const start = match.index + "address at ".length;
242 return line.slice(0, start) + baseName(match[1]!) + line.slice(start + match[1]!.length);
243 })
244 .join("\n");
245}
246
247interface SymbolicatedStack {
248 readonly stack: string;
249 /** Frames in the app bundle. */
250 readonly bundleFrames: number;
251 /** Bundle frames the map resolved. */
252 readonly mappedFrames: number;
253 /** How much longer the stack got. */
254 readonly growth: number;
255}
256
257/** Once the stack would grow by more than `maxGrowth`, the remaining frames keep their offsets. */
258export function symbolicateStack(
259 stack: string,
260 map: CompactSourceMap,
261 maxGrowth: number,
262): SymbolicatedStack {
263 let bundleFrames = 0;
264 let mappedFrames = 0;
265 let growth = 0;
266 let exhausted = false;
267 const lines = stack.split("\n").map((line) => {
268 const match = BUNDLE_FRAME.exec(line);
269 if (!match || baseName(match[1]!) === HERMES_RUNTIME) return line;
270 bundleFrames += 1;
271 if (exhausted) return line;
272 const position = originalPositionFor(map, Number(match[2]), Number(match[3]));
273 if (!position) return line;
274 const lineAndColumn = `:${position.line}:${position.column + 1}`;
275 const added = position.source.length + lineAndColumn.length - match[0].length;
276 if (growth + added > maxGrowth) {
277 exhausted = true;
278 return line;
279 }
280 growth += added;
281 mappedFrames += 1;
282 // Spliced, not `replace`d: a replacement string would expand `$&` and `$'` in the path.
283 const end = match.index + match[0].length;
284 return line.slice(0, match.index) + position.source + lineAndColumn + line.slice(end);
285 });
286 return { stack: lines.join("\n"), bundleFrames, mappedFrames, growth };
287}