bippy 0.6.1-dev.3a24abf → 0.6.1-dev.93556ef

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +26 -1
  3. package/dist/core.cjs +1 -1
  4. package/dist/core.d.cts +39 -42
  5. package/dist/core.d.ts +39 -42
  6. package/dist/core.js +1 -1
  7. package/dist/core2.cjs +1 -1
  8. package/dist/core2.d.cts +11 -3
  9. package/dist/core2.d.ts +11 -3
  10. package/dist/core2.js +1 -1
  11. package/dist/{types.d.cts → errors.d.cts} +187 -68
  12. package/dist/{types.d.ts → errors.d.ts} +187 -68
  13. package/dist/index.cjs +1 -1
  14. package/dist/index.d.cts +11 -3
  15. package/dist/index.d.ts +11 -3
  16. package/dist/index.iife.js +1 -1
  17. package/dist/index.js +1 -1
  18. package/dist/install-hook-only.cjs +1 -1
  19. package/dist/install-hook-only.d.cts +8 -0
  20. package/dist/install-hook-only.d.ts +8 -0
  21. package/dist/install-hook-only.iife.js +1 -1
  22. package/dist/install-hook-only.js +1 -1
  23. package/dist/rdt-hook.cjs +1 -1
  24. package/dist/rdt-hook.js +1 -1
  25. package/dist/source.cjs +13 -14
  26. package/dist/source.d.cts +86 -69
  27. package/dist/source.d.ts +86 -69
  28. package/dist/source.js +13 -14
  29. package/package.json +10 -6
  30. package/src/core.ts +246 -293
  31. package/src/errors.ts +34 -0
  32. package/src/install-hook-only.ts +2 -2
  33. package/src/rdt-hook.ts +152 -110
  34. package/src/react-internals/generated/react-work-tags.ts +318 -0
  35. package/src/react-internals/index.ts +67 -0
  36. package/src/react-internals/semver.ts +80 -0
  37. package/src/{types.ts → react-internals/types.ts} +13 -86
  38. package/src/source/error-stack.ts +11 -0
  39. package/src/source/get-display-name-from-source.ts +44 -40
  40. package/src/source/get-source.ts +42 -26
  41. package/src/source/index.ts +11 -1
  42. package/src/source/inspect-hooks.ts +220 -207
  43. package/src/source/owner-stack.ts +86 -128
  44. package/src/source/parse-debug-stack.ts +4 -4
  45. package/src/source/parse-hook-names.ts +14 -53
  46. package/src/source/parse-stack.ts +14 -31
  47. package/src/source/renderer-dispatchers.ts +30 -0
  48. package/src/source/symbolication.ts +703 -135
  49. package/src/generated/react-work-tags.ts +0 -169
  50. package/src/react-internals.ts +0 -67
  51. package/src/unsubscribe.ts +0 -17
@@ -1,6 +1,8 @@
1
- import { decode, SourceMapMappings, type SourceMapSegment } from "@jridgewell/sourcemap-codec";
1
+ import { decode, type SourceMapMappings, type SourceMapSegment } from "@jridgewell/sourcemap-codec";
2
2
 
3
- import { StackFrame } from "./parse-stack.js";
3
+ import { BippySourceMapError } from "../errors.js";
4
+ import { SCHEME_REGEX } from "./constants.js";
5
+ import type { StackFrame } from "./parse-stack.js";
4
6
 
5
7
  export interface DecodedSourceMapSection {
6
8
  map: {
@@ -10,7 +12,7 @@ export interface DecodedSourceMapSection {
10
12
  names?: string[];
11
13
  sourceRoot?: string;
12
14
  sources: string[];
13
- sourcesContent?: string[];
15
+ sourcesContent?: Array<string | null>;
14
16
  version: 3;
15
17
  };
16
18
  offset: {
@@ -19,20 +21,30 @@ export interface DecodedSourceMapSection {
19
21
  };
20
22
  }
21
23
 
22
- // https://tc39.es/ecma426/#sec-index-source-map
23
24
  export interface IndexSourceMap {
24
25
  file?: string;
25
26
  sections: Array<{
26
- map: StandardSourceMap;
27
+ map?: StandardSourceMap;
27
28
  offset: {
28
29
  column: number;
29
30
  line: number;
30
31
  };
32
+ url?: string;
31
33
  }>;
32
34
  version: 3;
33
35
  }
34
36
 
35
- export type RawSourceMap = IndexSourceMap | StandardSourceMap;
37
+ export interface SourceFetch {
38
+ (url: string, init?: RequestInit): Promise<Response>;
39
+ }
40
+
41
+ export interface SourceMapRequestOptions {
42
+ allowUnsafeServerFetch?: boolean;
43
+ maxBundleSizeBytes?: number;
44
+ maxSourceMapSizeBytes?: number;
45
+ signal?: AbortSignal;
46
+ timeoutMs?: number;
47
+ }
36
48
 
37
49
  export interface SourceMap {
38
50
  file?: string;
@@ -42,11 +54,10 @@ export interface SourceMap {
42
54
  sections?: DecodedSourceMapSection[];
43
55
  sourceRoot?: string;
44
56
  sources: string[];
45
- sourcesContent?: string[];
57
+ sourcesContent?: Array<string | null>;
46
58
  version: 3;
47
59
  }
48
60
 
49
- // https://developer.chrome.com/blog/sourcemaps#the_anatomy_of_a_source_map
50
61
  export interface StandardSourceMap {
51
62
  file?: string;
52
63
  ignoreList?: number[];
@@ -54,27 +65,60 @@ export interface StandardSourceMap {
54
65
  names?: string[];
55
66
  sourceRoot?: string;
56
67
  sources: string[];
57
- sourcesContent?: string[];
68
+ sourcesContent?: Array<string | null>;
58
69
  version: 3;
59
70
  x_google_ignoreList?: number[];
60
71
  }
61
72
 
62
- // has a scheme, e.g. http://, https://, file://, data:, etc.
63
- // https://datatracker.ietf.org/doc/html/rfc3986#section-3.1
64
- const SCHEME_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
65
- // inline sourcemap, e.g. data:application/json;base64,...
66
- const INLINE_SOURCEMAP_REGEX = /^data:application\/json[^,]+base64,/;
67
- // sourcemap url, e.g. //@ sourceMappingURL=... or /* @ sourceMappingURL=... */ at the end of the file
73
+ const INLINE_SOURCEMAP_REGEX = /^data:application\/json(?:;[^,]*)?,/i;
68
74
  const SOURCEMAP_REGEX =
69
75
  /(?:\/\/[@#][ \t]+sourceMappingURL=([^\s'"]+?)[ \t]*$)|(?:\/\*[@#][ \t]+sourceMappingURL=([^*]+?)[ \t]*(?:\*\/)[ \t]*$)/;
70
76
 
71
77
  export const sourceMapCache = new Map<string, null | SourceMap>();
78
+ const sourceMapCachesByFetch = new WeakMap<SourceFetch, Map<string, null | SourceMap>>();
72
79
  interface SourceMapResult {
73
80
  sourceMap: null | SourceMap;
74
81
  isTransientFailure: boolean;
75
82
  }
76
83
 
77
- const _pendingSourceMapRequests = new Map<string, Promise<SourceMapResult>>();
84
+ class TransientSourceMapError extends Error {}
85
+
86
+ const pendingSourceMapRequests = new Map<string, Promise<SourceMapResult>>();
87
+ const pendingSourceMapRequestsByFetch = new WeakMap<
88
+ SourceFetch,
89
+ Map<string, Promise<SourceMapResult>>
90
+ >();
91
+ const defaultMaxBundleSizeBytes = 25 * 1024 * 1024;
92
+ const defaultMaxSourceMapSizeBytes = 100 * 1024 * 1024;
93
+ const extensionProtocols = new Set([
94
+ "chrome-extension:",
95
+ "moz-extension:",
96
+ "safari-web-extension:",
97
+ ]);
98
+ const defaultFetchableProtocols = new Set(["http:", "https:", ...extensionProtocols]);
99
+
100
+ const getSourceFromSegment = (
101
+ segment: SourceMapSegment,
102
+ sources: string[],
103
+ ignoredSourceIndices?: Set<number>,
104
+ names?: string[],
105
+ ): StackFrame | null => {
106
+ const [, sourceIndex, sourceLine, sourceColumn, nameIndex] = segment;
107
+ if (sourceIndex === undefined || sourceLine === undefined || sourceColumn === undefined) {
108
+ return null;
109
+ }
110
+
111
+ const fileName = sources[sourceIndex];
112
+ if (!fileName) return null;
113
+
114
+ return {
115
+ columnNumber: sourceColumn,
116
+ fileName,
117
+ functionName: nameIndex === undefined ? undefined : names?.[nameIndex] || undefined,
118
+ isIgnoreListed: ignoredSourceIndices?.has(sourceIndex) ?? false,
119
+ lineNumber: sourceLine + 1,
120
+ };
121
+ };
78
122
 
79
123
  const getSourceFromMappings = (
80
124
  mappings: SourceMapMappings,
@@ -82,6 +126,7 @@ const getSourceFromMappings = (
82
126
  lineIndexInMappings: number,
83
127
  column: number,
84
128
  ignoredSourceIndices?: Set<number>,
129
+ names?: string[],
85
130
  ): StackFrame | null => {
86
131
  if (lineIndexInMappings < 0 || lineIndexInMappings >= mappings.length) {
87
132
  return null;
@@ -92,8 +137,6 @@ const getSourceFromMappings = (
92
137
  return null;
93
138
  }
94
139
 
95
- // Segments within a line are sorted by generated column, so binary search for
96
- // the last segment at or before the column.
97
140
  let closestLineSegment: null | SourceMapSegment = null;
98
141
  let lowIndex = 0;
99
142
  let highIndex = lineMapping.length - 1;
@@ -111,24 +154,7 @@ const getSourceFromMappings = (
111
154
  return null;
112
155
  }
113
156
 
114
- const [, sourceIndex, sourceLine, sourceColumn] = closestLineSegment;
115
-
116
- if (sourceIndex === undefined || sourceLine === undefined || sourceColumn === undefined) {
117
- return null;
118
- }
119
-
120
- const fileName = sources[sourceIndex];
121
-
122
- if (!fileName) {
123
- return null;
124
- }
125
-
126
- return {
127
- columnNumber: sourceColumn,
128
- fileName,
129
- lineNumber: sourceLine + 1,
130
- isIgnoreListed: ignoredSourceIndices?.has(sourceIndex) ?? false,
131
- };
157
+ return getSourceFromSegment(closestLineSegment, sources, ignoredSourceIndices, names);
132
158
  };
133
159
 
134
160
  export const getSourceFromSourceMap = (
@@ -137,7 +163,6 @@ export const getSourceFromSourceMap = (
137
163
  column: number,
138
164
  ): StackFrame | null => {
139
165
  if (sourceMap.sections) {
140
- // Section offsets are 0-based while stack trace lines are 1-based.
141
166
  const lineIndex = line - 1;
142
167
  let targetSection: DecodedSourceMapSection | null = null;
143
168
 
@@ -166,6 +191,7 @@ export const getSourceFromSourceMap = (
166
191
  relativeLine,
167
192
  relativeColumn,
168
193
  targetSection.map.ignoredSourceIndices,
194
+ targetSection.map.names,
169
195
  );
170
196
  }
171
197
 
@@ -175,17 +201,148 @@ export const getSourceFromSourceMap = (
175
201
  line - 1,
176
202
  column,
177
203
  sourceMap.ignoredSourceIndices,
204
+ sourceMap.names,
178
205
  );
179
206
  };
180
207
 
181
- const getSourceMapUrl = (url: string, content: string): null | string => {
182
- // Walk lines backwards without content.split("\n"), which would allocate a
183
- // string per line of the entire bundle.
208
+ const getSourceFromMappingsByFunctionName = (
209
+ mappings: SourceMapMappings,
210
+ sources: string[],
211
+ names: string[] | undefined,
212
+ functionName: string,
213
+ ignoredSourceIndices?: Set<number>,
214
+ ): StackFrame | null => {
215
+ if (!names) return null;
216
+ const functionNameIndex = names.indexOf(functionName);
217
+ if (functionNameIndex === -1) return null;
218
+
219
+ let ignoredSource: StackFrame | null = null;
220
+ for (const lineMapping of mappings) {
221
+ for (const segment of lineMapping) {
222
+ if (segment[4] !== functionNameIndex) continue;
223
+ const source = getSourceFromSegment(segment, sources, ignoredSourceIndices, names);
224
+ if (!source) continue;
225
+ if (!source.isIgnoreListed) return source;
226
+ ignoredSource ??= source;
227
+ }
228
+ }
229
+ return ignoredSource;
230
+ };
231
+
232
+ export const getSourceFromSourceMapByFunctionName = (
233
+ sourceMap: SourceMap,
234
+ functionName: string,
235
+ ): StackFrame | null => {
236
+ if (sourceMap.sections) {
237
+ let ignoredSource: StackFrame | null = null;
238
+ for (const section of sourceMap.sections) {
239
+ const source = getSourceFromMappingsByFunctionName(
240
+ section.map.mappings,
241
+ section.map.sources,
242
+ section.map.names,
243
+ functionName,
244
+ section.map.ignoredSourceIndices,
245
+ );
246
+ if (!source) continue;
247
+ if (!source.isIgnoreListed) return source;
248
+ ignoredSource ??= source;
249
+ }
250
+ return ignoredSource;
251
+ }
252
+
253
+ return getSourceFromMappingsByFunctionName(
254
+ sourceMap.mappings,
255
+ sourceMap.sources,
256
+ sourceMap.names,
257
+ functionName,
258
+ sourceMap.ignoredSourceIndices,
259
+ );
260
+ };
261
+
262
+ const findSourceContentByFileName = (
263
+ sources: string[],
264
+ sourcesContent: Array<string | null> | undefined,
265
+ fileName: string,
266
+ ): string | null => {
267
+ if (!sourcesContent) return null;
268
+ const sourceIndex = sources.indexOf(fileName);
269
+ return sourceIndex === -1 ? null : (sourcesContent[sourceIndex] ?? null);
270
+ };
271
+
272
+ export const getSourceContentFromSourceMap = (
273
+ sourceMap: SourceMap,
274
+ originalFileName: string,
275
+ ): string | null => {
276
+ const sourceContent = findSourceContentByFileName(
277
+ sourceMap.sources,
278
+ sourceMap.sourcesContent,
279
+ originalFileName,
280
+ );
281
+ if (sourceContent !== null) return sourceContent;
282
+
283
+ if (!sourceMap.sections) return null;
284
+ for (const section of sourceMap.sections) {
285
+ const sectionSourceContent = findSourceContentByFileName(
286
+ section.map.sources,
287
+ section.map.sourcesContent,
288
+ originalFileName,
289
+ );
290
+ if (sectionSourceContent !== null) return sectionSourceContent;
291
+ }
292
+ return null;
293
+ };
294
+
295
+ const resolveUrl = (reference: string, baseUrl: string): string | null => {
296
+ if (INLINE_SOURCEMAP_REGEX.test(reference)) return reference;
297
+ try {
298
+ return new URL(reference, baseUrl).toString();
299
+ } catch {
300
+ try {
301
+ const resolvedUrl = new URL(reference, new URL(baseUrl, "https://bippy.invalid/"));
302
+ return `${resolvedUrl.pathname}${resolvedUrl.search}${resolvedUrl.hash}`;
303
+ } catch {
304
+ return null;
305
+ }
306
+ }
307
+ };
308
+
309
+ const getMetroSourceMapUrl = (bundleUrl: string): string | null => {
310
+ try {
311
+ const parsedBundleUrl = new URL(bundleUrl);
312
+ const bundleExtensionIndex = parsedBundleUrl.pathname.lastIndexOf(".bundle");
313
+ if (bundleExtensionIndex === -1) return null;
314
+ const embeddedSearch = parsedBundleUrl.pathname.slice(bundleExtensionIndex + ".bundle".length);
315
+ if (embeddedSearch && !embeddedSearch.startsWith("//&")) return null;
316
+ parsedBundleUrl.pathname = `${parsedBundleUrl.pathname.slice(0, bundleExtensionIndex)}.map`;
317
+ if (embeddedSearch) {
318
+ const embeddedSearchParameters = new URLSearchParams(embeddedSearch.slice("//&".length));
319
+ for (const [parameterName, parameterValue] of embeddedSearchParameters) {
320
+ parsedBundleUrl.searchParams.set(parameterName, parameterValue);
321
+ }
322
+ }
323
+ parsedBundleUrl.hash = "";
324
+ return parsedBundleUrl.toString();
325
+ } catch {
326
+ return null;
327
+ }
328
+ };
329
+
330
+ const getSourceMapUrl = (
331
+ bundleUrl: string,
332
+ bundleContent: string,
333
+ bundleResponse: Response,
334
+ ): null | string => {
335
+ const sourceMapHeader =
336
+ bundleResponse.headers.get("sourcemap") ?? bundleResponse.headers.get("x-sourcemap");
337
+ if (sourceMapHeader) {
338
+ return resolveUrl(sourceMapHeader.trim(), bundleUrl);
339
+ }
340
+
184
341
  let sourceMapUrl: string | undefined;
185
- let searchEnd = content.length;
342
+ let searchEnd = bundleContent.length;
186
343
  while (searchEnd > 0 && !sourceMapUrl) {
187
- const lineStart = content.lastIndexOf("\n", searchEnd - 1) + 1;
188
- const regexMatch = content.slice(lineStart, searchEnd).match(SOURCEMAP_REGEX);
344
+ const lineStart = bundleContent.lastIndexOf("\n", searchEnd - 1) + 1;
345
+ const regexMatch = bundleContent.slice(lineStart, searchEnd).match(SOURCEMAP_REGEX);
189
346
  if (regexMatch) {
190
347
  sourceMapUrl = regexMatch[1] || regexMatch[2];
191
348
  }
@@ -193,63 +350,176 @@ const getSourceMapUrl = (url: string, content: string): null | string => {
193
350
  }
194
351
 
195
352
  if (!sourceMapUrl) {
196
- return null;
353
+ return getMetroSourceMapUrl(bundleUrl);
197
354
  }
198
355
 
199
- const hasScheme = SCHEME_REGEX.test(sourceMapUrl);
200
- if (!(INLINE_SOURCEMAP_REGEX.test(sourceMapUrl) || hasScheme || sourceMapUrl.startsWith("/"))) {
201
- const urlSegments = url.split("/");
202
- urlSegments[urlSegments.length - 1] = sourceMapUrl;
203
- sourceMapUrl = urlSegments.join("/");
356
+ return resolveUrl(sourceMapUrl, bundleUrl);
357
+ };
358
+
359
+ const isStandardSourceMap = (value: unknown): value is StandardSourceMap => {
360
+ if (typeof value !== "object" || value === null) return false;
361
+ const version = Reflect.get(value, "version");
362
+ const mappings = Reflect.get(value, "mappings");
363
+ const file = Reflect.get(value, "file");
364
+ const names = Reflect.get(value, "names");
365
+ const sourceRoot = Reflect.get(value, "sourceRoot");
366
+ const sources = Reflect.get(value, "sources");
367
+ const sourcesContent = Reflect.get(value, "sourcesContent");
368
+ const ignoreList = Reflect.get(value, "ignoreList");
369
+ const googleIgnoreList = Reflect.get(value, "x_google_ignoreList");
370
+ if (
371
+ version !== 3 ||
372
+ typeof mappings !== "string" ||
373
+ (file !== undefined && typeof file !== "string") ||
374
+ (names !== undefined &&
375
+ (!Array.isArray(names) || names.some((entry) => typeof entry !== "string"))) ||
376
+ (sourceRoot !== undefined && typeof sourceRoot !== "string") ||
377
+ (sourcesContent !== undefined &&
378
+ (!Array.isArray(sourcesContent) ||
379
+ sourcesContent.some((entry) => typeof entry !== "string" && entry !== null))) ||
380
+ (ignoreList !== undefined &&
381
+ (!Array.isArray(ignoreList) ||
382
+ ignoreList.some((entry) => !Number.isInteger(entry) || entry < 0))) ||
383
+ (googleIgnoreList !== undefined &&
384
+ (!Array.isArray(googleIgnoreList) ||
385
+ googleIgnoreList.some((entry) => !Number.isInteger(entry) || entry < 0)))
386
+ ) {
387
+ return false;
204
388
  }
389
+ if (!Array.isArray(sources) || sources.some((entry) => typeof entry !== "string")) return false;
390
+ if (sourcesContent && sourcesContent.length !== sources.length) return false;
391
+ const sourceCount = sources.length;
392
+ return [...(ignoreList ?? []), ...(googleIgnoreList ?? [])].every(
393
+ (sourceIndex) => sourceIndex < sourceCount,
394
+ );
395
+ };
205
396
 
206
- return sourceMapUrl;
397
+ const isIndexSourceMap = (value: unknown): value is IndexSourceMap => {
398
+ if (typeof value !== "object" || value === null) return false;
399
+ const version = Reflect.get(value, "version");
400
+ const sections = Reflect.get(value, "sections");
401
+ if (version !== 3 || !Array.isArray(sections)) {
402
+ return false;
403
+ }
404
+ return sections.every((section: unknown) => {
405
+ if (typeof section !== "object" || section === null) return false;
406
+ const map = Reflect.get(section, "map");
407
+ const url = Reflect.get(section, "url");
408
+ const offset = Reflect.get(section, "offset");
409
+ if (typeof offset !== "object" || offset === null) return false;
410
+ const offsetColumn = Reflect.get(offset, "column");
411
+ const offsetLine = Reflect.get(offset, "line");
412
+ const hasMap = isStandardSourceMap(map);
413
+ const hasUrl = typeof url === "string" && url.length > 0;
414
+ return (
415
+ hasMap !== hasUrl &&
416
+ typeof offsetColumn === "number" &&
417
+ Number.isInteger(offsetColumn) &&
418
+ offsetColumn >= 0 &&
419
+ typeof offsetLine === "number" &&
420
+ Number.isInteger(offsetLine) &&
421
+ offsetLine >= 0
422
+ );
423
+ });
207
424
  };
208
425
 
209
426
  const getIgnoredSourceIndices = (rawSourceMap: StandardSourceMap): Set<number> | undefined => {
210
427
  const ignoreList = rawSourceMap.ignoreList ?? rawSourceMap.x_google_ignoreList;
211
- return Array.isArray(ignoreList) && ignoreList.length > 0 ? new Set(ignoreList) : undefined;
428
+ return ignoreList?.length ? new Set(ignoreList) : undefined;
212
429
  };
213
430
 
214
- const resolveSourceRoot = (sourceRoot: string | undefined, source: string): string => {
431
+ const resolveSourceRoot = (
432
+ sourceRoot: string | undefined,
433
+ source: string,
434
+ sourceMapUrl: string,
435
+ ): string => {
215
436
  if (!sourceRoot || SCHEME_REGEX.test(source) || source.startsWith("/")) return source;
216
437
  const normalizedSourceRoot = sourceRoot.endsWith("/") ? sourceRoot : `${sourceRoot}/`;
217
438
  const normalizedSource = source.replace(/^\.\//, "");
218
- if (SCHEME_REGEX.test(normalizedSourceRoot)) {
219
- try {
220
- return new URL(normalizedSource, normalizedSourceRoot).toString();
221
- } catch {}
222
- }
223
- return `${normalizedSourceRoot}${normalizedSource}`;
224
- };
225
-
226
- const resolveSourceMapSources = (rawSourceMap: StandardSourceMap): string[] =>
227
- rawSourceMap.sources.map((source) => resolveSourceRoot(rawSourceMap.sourceRoot, source));
228
-
229
- const decodeStandardSourceMap = (rawSourceMap: StandardSourceMap): SourceMap => ({
230
- file: rawSourceMap.file,
231
- ignoredSourceIndices: getIgnoredSourceIndices(rawSourceMap),
232
- mappings: decode(rawSourceMap.mappings),
233
- names: rawSourceMap.names,
234
- sourceRoot: rawSourceMap.sourceRoot,
235
- sources: resolveSourceMapSources(rawSourceMap),
236
- sourcesContent: rawSourceMap.sourcesContent,
237
- version: 3,
238
- });
239
-
240
- const decodeIndexSourceMap = (rawSourceMap: IndexSourceMap): SourceMap => {
241
- const decodedSections: DecodedSourceMapSection[] = rawSourceMap.sections.map(
242
- ({ map, offset }) => ({
243
- map: {
244
- ...map,
245
- ignoredSourceIndices: getIgnoredSourceIndices(map),
246
- mappings: decode(map.mappings),
247
- sources: resolveSourceMapSources(map),
248
- },
249
- offset,
250
- }),
439
+ try {
440
+ const baseUrl = SCHEME_REGEX.test(normalizedSourceRoot)
441
+ ? normalizedSourceRoot
442
+ : new URL(normalizedSourceRoot, sourceMapUrl).toString();
443
+ return new URL(normalizedSource, baseUrl).toString();
444
+ } catch {
445
+ const pathSegments = `${normalizedSourceRoot}${normalizedSource}`.split("/");
446
+ const normalizedPathSegments: string[] = [];
447
+ for (const pathSegment of pathSegments) {
448
+ if (pathSegment === "..") {
449
+ normalizedPathSegments.pop();
450
+ } else if (pathSegment !== ".") {
451
+ normalizedPathSegments.push(pathSegment);
452
+ }
453
+ }
454
+ return normalizedPathSegments.join("/");
455
+ }
456
+ };
457
+
458
+ const resolveSourceMapSources = (rawSourceMap: StandardSourceMap, sourceMapUrl: string): string[] =>
459
+ rawSourceMap.sources.map((source) =>
460
+ resolveSourceRoot(rawSourceMap.sourceRoot, source, sourceMapUrl),
251
461
  );
252
462
 
463
+ const decodeStandardSourceMap = (
464
+ rawSourceMap: StandardSourceMap,
465
+ sourceMapUrl: string,
466
+ ): SourceMap | null => {
467
+ try {
468
+ return {
469
+ file: rawSourceMap.file,
470
+ ignoredSourceIndices: getIgnoredSourceIndices(rawSourceMap),
471
+ mappings: decode(rawSourceMap.mappings),
472
+ names: rawSourceMap.names,
473
+ sourceRoot: rawSourceMap.sourceRoot,
474
+ sources: resolveSourceMapSources(rawSourceMap, sourceMapUrl),
475
+ sourcesContent: rawSourceMap.sourcesContent,
476
+ version: 3,
477
+ };
478
+ } catch {
479
+ return null;
480
+ }
481
+ };
482
+
483
+ interface LoadStandardSourceMap {
484
+ (url: string): Promise<StandardSourceMap | null>;
485
+ }
486
+
487
+ const decodeIndexSourceMap = async (
488
+ rawSourceMap: IndexSourceMap,
489
+ sourceMapUrl: string,
490
+ loadStandardSourceMap: LoadStandardSourceMap,
491
+ ): Promise<SourceMap | null> => {
492
+ const decodedSections: DecodedSourceMapSection[] = [];
493
+ let previousOffsetLine = -1;
494
+ let previousOffsetColumn = -1;
495
+ for (const section of rawSourceMap.sections) {
496
+ if (
497
+ section.offset.line < previousOffsetLine ||
498
+ (section.offset.line === previousOffsetLine && section.offset.column <= previousOffsetColumn)
499
+ ) {
500
+ return null;
501
+ }
502
+ previousOffsetLine = section.offset.line;
503
+ previousOffsetColumn = section.offset.column;
504
+ const sectionUrl = section.url ? resolveUrl(section.url, sourceMapUrl) : null;
505
+ const map = section.map ?? (sectionUrl ? await loadStandardSourceMap(sectionUrl) : null);
506
+ if (!map) return null;
507
+ const sectionSourceMapUrl = sectionUrl ?? sourceMapUrl;
508
+ try {
509
+ decodedSections.push({
510
+ map: {
511
+ ...map,
512
+ ignoredSourceIndices: getIgnoredSourceIndices(map),
513
+ mappings: decode(map.mappings),
514
+ sources: resolveSourceMapSources(map, sectionSourceMapUrl),
515
+ },
516
+ offset: section.offset,
517
+ });
518
+ } catch {
519
+ return null;
520
+ }
521
+ }
522
+
253
523
  const allSources = new Set<string>();
254
524
  for (const section of decodedSections) {
255
525
  for (const source of section.map.sources) {
@@ -269,7 +539,7 @@ const decodeIndexSourceMap = (rawSourceMap: IndexSourceMap): SourceMap => {
269
539
  };
270
540
  };
271
541
 
272
- const isFetchableUrl = (url: string): boolean => {
542
+ const isFetchableUrl = (url: string, shouldAllowCustomProtocol = false): boolean => {
273
543
  if (!url) {
274
544
  return false;
275
545
  }
@@ -288,92 +558,389 @@ const isFetchableUrl = (url: string): boolean => {
288
558
 
289
559
  const scheme = schemeMatch[0].toLowerCase();
290
560
 
291
- return scheme === "http:" || scheme === "https:";
561
+ return shouldAllowCustomProtocol || defaultFetchableProtocols.has(scheme);
292
562
  };
293
563
 
294
- // Resolves a bundle's source map, or null when the bundle definitively has
295
- // none. A thrown fetch (network error or aborted request) is left to propagate
296
- // so getSourceMap can treat it as transient and avoid caching it: a non-ok
297
- // response, a missing sourceMappingURL, or an undecodable map are definitive and
298
- // return null, but a dropped connection is not and must stay retryable.
299
- export const getSourceMapImpl = async (
300
- bundleUrl: string,
301
- fetchFn?: (url: string) => Promise<Response>,
302
- ): Promise<null | SourceMap> => {
303
- if (!isFetchableUrl(bundleUrl)) {
564
+ const isServerRuntime = (): boolean =>
565
+ typeof window === "undefined" &&
566
+ typeof process !== "undefined" &&
567
+ typeof process.versions?.node === "string";
568
+
569
+ const isAbsoluteHttpUrl = (url: string): boolean => {
570
+ try {
571
+ const parsedUrl = new URL(url);
572
+ return (
573
+ (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:") &&
574
+ !parsedUrl.username &&
575
+ !parsedUrl.password
576
+ );
577
+ } catch {
578
+ return false;
579
+ }
580
+ };
581
+
582
+ const getUrlProtocol = (url: string): string | null => {
583
+ try {
584
+ return new URL(url).protocol;
585
+ } catch {
304
586
  return null;
305
587
  }
588
+ };
306
589
 
307
- const sourceFetch =
308
- fetchFn ??
309
- (typeof globalThis.fetch === "function" ? globalThis.fetch.bind(globalThis) : undefined);
310
- if (!sourceFetch) return null;
590
+ const isExtensionUrl = (url: string): boolean => {
591
+ const protocol = getUrlProtocol(url);
592
+ return protocol !== null && extensionProtocols.has(protocol);
593
+ };
311
594
 
312
- const bundleResponse = await sourceFetch(bundleUrl);
313
- if (!bundleResponse.ok) {
314
- return null;
595
+ const isSameOrigin = (firstUrl: string, secondUrl: string): boolean => {
596
+ try {
597
+ const firstParsedUrl = new URL(firstUrl);
598
+ const secondParsedUrl = new URL(secondUrl);
599
+ return (
600
+ firstParsedUrl.protocol === secondParsedUrl.protocol &&
601
+ firstParsedUrl.host === secondParsedUrl.host
602
+ );
603
+ } catch {
604
+ return false;
315
605
  }
316
- const bundleContent = await bundleResponse.text();
317
- if (!bundleContent) {
318
- return null;
606
+ };
607
+
608
+ const isAllowedSourceMapUrl = (
609
+ bundleUrl: string,
610
+ sourceMapUrl: string,
611
+ shouldRejectCrossOrigin: boolean,
612
+ shouldAllowCustomProtocol: boolean,
613
+ ): boolean => {
614
+ if (INLINE_SOURCEMAP_REGEX.test(sourceMapUrl)) return true;
615
+ if (!isFetchableUrl(sourceMapUrl, shouldAllowCustomProtocol)) return false;
616
+ const bundleProtocol = getUrlProtocol(bundleUrl);
617
+ const sourceMapProtocol = getUrlProtocol(sourceMapUrl);
618
+ const hasCustomProtocol =
619
+ (bundleProtocol !== null && !defaultFetchableProtocols.has(bundleProtocol)) ||
620
+ (sourceMapProtocol !== null && !defaultFetchableProtocols.has(sourceMapProtocol));
621
+ const shouldRequireSameOrigin =
622
+ shouldRejectCrossOrigin ||
623
+ hasCustomProtocol ||
624
+ isExtensionUrl(bundleUrl) ||
625
+ isExtensionUrl(sourceMapUrl);
626
+ return !shouldRequireSameOrigin || isSameOrigin(bundleUrl, sourceMapUrl);
627
+ };
628
+
629
+ const isTransientHttpStatus = (status: number): boolean =>
630
+ status === 408 || status === 425 || status === 429 || status >= 500;
631
+
632
+ const assertResponseIsCacheable = (response: Response): boolean => {
633
+ if (response.ok) return true;
634
+ if (isTransientHttpStatus(response.status)) {
635
+ throw new TransientSourceMapError();
319
636
  }
637
+ return false;
638
+ };
320
639
 
321
- const sourceMapUrl = getSourceMapUrl(bundleUrl, bundleContent);
640
+ const isResponseUrlAllowed = (
641
+ requestedUrl: string,
642
+ response: Response,
643
+ shouldRejectCrossOrigin: boolean,
644
+ ): boolean =>
645
+ !shouldRejectCrossOrigin || response.url.length === 0 || isSameOrigin(requestedUrl, response.url);
322
646
 
323
- if (!sourceMapUrl) return null;
324
- // inline data: maps (vite dev, babel inline sourcemaps) are decoded by
325
- // fetch itself, so they bypass the network-url check
326
- if (!isFetchableUrl(sourceMapUrl) && !INLINE_SOURCEMAP_REGEX.test(sourceMapUrl)) {
327
- return null;
647
+ interface RequestSignal {
648
+ cleanup: () => void;
649
+ signal?: AbortSignal;
650
+ }
651
+
652
+ const createRequestSignal = (options: SourceMapRequestOptions): RequestSignal => {
653
+ if (options.timeoutMs === undefined || options.timeoutMs < 0) {
654
+ return { cleanup: () => {}, signal: options.signal };
655
+ }
656
+ const abortController = new AbortController();
657
+ const abortFromSource = (): void => abortController.abort(options.signal?.reason);
658
+ if (options.signal?.aborted) {
659
+ abortFromSource();
660
+ } else {
661
+ options.signal?.addEventListener("abort", abortFromSource, { once: true });
662
+ }
663
+ const timeoutHandle = setTimeout(
664
+ () => abortController.abort(new BippySourceMapError("Source map request timed out")),
665
+ options.timeoutMs,
666
+ );
667
+ return {
668
+ cleanup: () => {
669
+ clearTimeout(timeoutHandle);
670
+ options.signal?.removeEventListener("abort", abortFromSource);
671
+ },
672
+ signal: abortController.signal,
673
+ };
674
+ };
675
+
676
+ const readResponseText = async (
677
+ response: Response,
678
+ maxSizeBytes: number,
679
+ ): Promise<string | null> => {
680
+ const contentLength = Number(response.headers.get("content-length"));
681
+ if (Number.isFinite(contentLength) && contentLength > maxSizeBytes) return null;
682
+ if (!response.body) {
683
+ const responseText = await response.text();
684
+ return new TextEncoder().encode(responseText).byteLength > maxSizeBytes ? null : responseText;
328
685
  }
329
686
 
330
- const sourceMapResponse = await sourceFetch(sourceMapUrl);
331
- if (!sourceMapResponse.ok) {
687
+ const reader = response.body.getReader();
688
+ const decoder = new TextDecoder();
689
+ const decodedChunks: string[] = [];
690
+ let totalSizeBytes = 0;
691
+
692
+ try {
693
+ while (true) {
694
+ const { done, value } = await reader.read();
695
+ if (done) break;
696
+ totalSizeBytes += value.byteLength;
697
+ if (totalSizeBytes > maxSizeBytes) {
698
+ await reader.cancel();
699
+ return null;
700
+ }
701
+ decodedChunks.push(decoder.decode(value, { stream: true }));
702
+ }
703
+ decodedChunks.push(decoder.decode());
704
+ return decodedChunks.join("");
705
+ } finally {
706
+ reader.releaseLock();
707
+ }
708
+ };
709
+
710
+ const decodeBase64 = (encodedContent: string): string | null => {
711
+ try {
712
+ if (typeof globalThis.atob === "function") {
713
+ return new TextDecoder().decode(
714
+ Uint8Array.from(globalThis.atob(encodedContent), (character) => character.charCodeAt(0)),
715
+ );
716
+ }
717
+ } catch {
332
718
  return null;
333
719
  }
334
720
 
721
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
722
+ const normalizedContent = encodedContent.replace(/\s/g, "").replace(/=+$/, "");
723
+ const bytes: number[] = [];
724
+ let bitBuffer = 0;
725
+ let bitCount = 0;
726
+ for (const character of normalizedContent) {
727
+ const value = alphabet.indexOf(character);
728
+ if (value === -1) return null;
729
+ bitBuffer = bitBuffer * 64 + value;
730
+ bitCount += 6;
731
+ if (bitCount >= 8) {
732
+ bitCount -= 8;
733
+ bytes.push(Math.floor(bitBuffer / 2 ** bitCount) & 255);
734
+ bitBuffer %= 2 ** bitCount;
735
+ }
736
+ }
737
+ return new TextDecoder().decode(Uint8Array.from(bytes));
738
+ };
739
+
740
+ const readInlineSourceMap = (sourceMapUrl: string, maxSizeBytes: number): unknown => {
741
+ const commaIndex = sourceMapUrl.indexOf(",");
742
+ if (commaIndex === -1) return null;
743
+ const metadata = sourceMapUrl.slice(0, commaIndex);
744
+ const encodedContent = sourceMapUrl.slice(commaIndex + 1);
745
+ if (encodedContent.length > maxSizeBytes * 2) return null;
335
746
  try {
336
- const rawSourceMap = (await sourceMapResponse.json()) as RawSourceMap;
747
+ const content = metadata.toLowerCase().includes(";base64")
748
+ ? decodeBase64(encodedContent)
749
+ : decodeURIComponent(encodedContent);
750
+ if (content === null) return null;
751
+ if (new TextEncoder().encode(content).byteLength > maxSizeBytes) return null;
752
+ return JSON.parse(content);
753
+ } catch {
754
+ return null;
755
+ }
756
+ };
337
757
 
338
- return "sections" in rawSourceMap
339
- ? decodeIndexSourceMap(rawSourceMap)
340
- : decodeStandardSourceMap(rawSourceMap);
758
+ const readSourceMapDocument = async (
759
+ sourceMapUrl: string,
760
+ sourceFetch: SourceFetch,
761
+ signal: AbortSignal | undefined,
762
+ maxSizeBytes: number,
763
+ shouldRejectRedirects: boolean,
764
+ ): Promise<unknown> => {
765
+ if (INLINE_SOURCEMAP_REGEX.test(sourceMapUrl)) {
766
+ return readInlineSourceMap(sourceMapUrl, maxSizeBytes);
767
+ }
768
+ const sourceMapResponse = await sourceFetch(sourceMapUrl, {
769
+ redirect: shouldRejectRedirects ? "error" : "follow",
770
+ signal,
771
+ });
772
+ if (!assertResponseIsCacheable(sourceMapResponse)) return null;
773
+ if (!isResponseUrlAllowed(sourceMapUrl, sourceMapResponse, shouldRejectRedirects)) return null;
774
+ const sourceMapContent = await readResponseText(sourceMapResponse, maxSizeBytes);
775
+ if (sourceMapContent === null) return null;
776
+ try {
777
+ return JSON.parse(sourceMapContent);
341
778
  } catch {
342
779
  return null;
343
780
  }
344
781
  };
345
782
 
783
+ const getSourceMapUncachedInternal = async (
784
+ bundleUrl: string,
785
+ fetchFn?: SourceFetch,
786
+ options: SourceMapRequestOptions = {},
787
+ ): Promise<null | SourceMap> => {
788
+ const shouldAllowCustomProtocol = fetchFn !== undefined;
789
+ if (!isFetchableUrl(bundleUrl, shouldAllowCustomProtocol)) {
790
+ return null;
791
+ }
792
+
793
+ const isServer = isServerRuntime();
794
+ const shouldRejectRedirects = isServer || isExtensionUrl(bundleUrl);
795
+ if (isServer && !fetchFn) {
796
+ if (!options.allowUnsafeServerFetch || !isAbsoluteHttpUrl(bundleUrl)) return null;
797
+ }
798
+
799
+ const sourceFetch =
800
+ fetchFn ??
801
+ (typeof globalThis.fetch === "function" ? globalThis.fetch.bind(globalThis) : undefined);
802
+ if (!sourceFetch) return null;
803
+ const maxBundleSizeBytes = options.maxBundleSizeBytes ?? defaultMaxBundleSizeBytes;
804
+ const maxSourceMapSizeBytes = options.maxSourceMapSizeBytes ?? defaultMaxSourceMapSizeBytes;
805
+ if (maxBundleSizeBytes < 0 || maxSourceMapSizeBytes < 0) return null;
806
+ const requestSignal = createRequestSignal(options);
807
+ try {
808
+ const bundleResponse = await sourceFetch(bundleUrl, {
809
+ redirect: shouldRejectRedirects ? "error" : "follow",
810
+ signal: requestSignal.signal,
811
+ });
812
+ if (!assertResponseIsCacheable(bundleResponse)) return null;
813
+ if (!isResponseUrlAllowed(bundleUrl, bundleResponse, shouldRejectRedirects)) return null;
814
+ const bundleContent = await readResponseText(bundleResponse, maxBundleSizeBytes);
815
+ if (bundleContent === null) return null;
816
+ const sourceMapUrl = getSourceMapUrl(bundleUrl, bundleContent, bundleResponse);
817
+ if (!sourceMapUrl) return null;
818
+ if (
819
+ !isAllowedSourceMapUrl(
820
+ bundleUrl,
821
+ sourceMapUrl,
822
+ shouldRejectRedirects,
823
+ shouldAllowCustomProtocol,
824
+ )
825
+ ) {
826
+ return null;
827
+ }
828
+
829
+ const rawSourceMap = await readSourceMapDocument(
830
+ sourceMapUrl,
831
+ sourceFetch,
832
+ requestSignal.signal,
833
+ maxSourceMapSizeBytes,
834
+ shouldRejectRedirects,
835
+ );
836
+ if (isStandardSourceMap(rawSourceMap)) {
837
+ return decodeStandardSourceMap(rawSourceMap, sourceMapUrl);
838
+ }
839
+ if (!isIndexSourceMap(rawSourceMap)) return null;
840
+ return decodeIndexSourceMap(rawSourceMap, sourceMapUrl, async (sectionUrl) => {
841
+ if (
842
+ !isAllowedSourceMapUrl(
843
+ bundleUrl,
844
+ sectionUrl,
845
+ shouldRejectRedirects,
846
+ shouldAllowCustomProtocol,
847
+ )
848
+ ) {
849
+ return null;
850
+ }
851
+ const sectionSourceMap = await readSourceMapDocument(
852
+ sectionUrl,
853
+ sourceFetch,
854
+ requestSignal.signal,
855
+ maxSourceMapSizeBytes,
856
+ shouldRejectRedirects,
857
+ );
858
+ return isStandardSourceMap(sectionSourceMap) ? sectionSourceMap : null;
859
+ });
860
+ } finally {
861
+ requestSignal.cleanup();
862
+ }
863
+ };
864
+
865
+ export const getSourceMapUncached = async (
866
+ bundleUrl: string,
867
+ fetchFn?: SourceFetch,
868
+ options: SourceMapRequestOptions = {},
869
+ ): Promise<null | SourceMap> => {
870
+ try {
871
+ return await getSourceMapUncachedInternal(bundleUrl, fetchFn, options);
872
+ } catch (error) {
873
+ if (error instanceof TransientSourceMapError) return null;
874
+ throw error;
875
+ }
876
+ };
877
+
878
+ const getPerFetchMap = <Value>(
879
+ mapsByFetch: WeakMap<SourceFetch, Map<string, Value>>,
880
+ globalMap: Map<string, Value>,
881
+ fetchFn: SourceFetch | undefined,
882
+ ): Map<string, Value> => {
883
+ if (!fetchFn) return globalMap;
884
+ let map = mapsByFetch.get(fetchFn);
885
+ if (!map) {
886
+ map = new Map();
887
+ mapsByFetch.set(fetchFn, map);
888
+ }
889
+ return map;
890
+ };
891
+
892
+ const getSourceMapCache = (fetchFn: SourceFetch | undefined): Map<string, null | SourceMap> =>
893
+ getPerFetchMap(sourceMapCachesByFetch, sourceMapCache, fetchFn);
894
+
895
+ const getPendingSourceMapRequests = (
896
+ fetchFn: SourceFetch | undefined,
897
+ ): Map<string, Promise<SourceMapResult>> =>
898
+ getPerFetchMap(pendingSourceMapRequestsByFetch, pendingSourceMapRequests, fetchFn);
899
+
900
+ const getSourceMapCacheKey = (file: string, options: SourceMapRequestOptions): string =>
901
+ options.allowUnsafeServerFetch === undefined &&
902
+ options.maxBundleSizeBytes === undefined &&
903
+ options.maxSourceMapSizeBytes === undefined &&
904
+ options.timeoutMs === undefined
905
+ ? file
906
+ : `${file}\0${options.allowUnsafeServerFetch ?? ""}\0${options.maxBundleSizeBytes ?? ""}\0${options.maxSourceMapSizeBytes ?? ""}\0${options.timeoutMs ?? ""}`;
907
+
346
908
  export const getSourceMap = async (
347
909
  file: string,
348
910
  useCache = true,
349
- fetchFn?: (url: string) => Promise<Response>,
911
+ fetchFn?: SourceFetch,
912
+ options: SourceMapRequestOptions = {},
350
913
  ): Promise<null | SourceMap> => {
351
- if (useCache && sourceMapCache.has(file)) {
352
- return sourceMapCache.get(file) ?? null;
914
+ const shouldUseCache = useCache && options.signal === undefined;
915
+ const cache = getSourceMapCache(fetchFn);
916
+ const pendingRequests = getPendingSourceMapRequests(fetchFn);
917
+ const cacheKey = getSourceMapCacheKey(file, options);
918
+ if (shouldUseCache && cache.has(cacheKey)) {
919
+ return cache.get(cacheKey) ?? null;
353
920
  }
354
921
 
355
- const pendingRequest = useCache ? _pendingSourceMapRequests.get(file) : undefined;
922
+ const pendingRequest = shouldUseCache ? pendingRequests.get(cacheKey) : undefined;
356
923
  if (pendingRequest) {
357
924
  return (await pendingRequest).sourceMap;
358
925
  }
359
926
 
360
- // A transient fetch failure (aborted request or network error) rejects; a
361
- // definitive "no map" resolves to null. Only definitive results are cached:
362
- // caching a transient null would pin the bundle to a degraded result for the
363
- // rest of the page's lifetime, even after the network recovers.
364
- const fetchPromise: Promise<SourceMapResult> = getSourceMapImpl(file, fetchFn).then(
927
+ const fetchPromise: Promise<SourceMapResult> = getSourceMapUncachedInternal(
928
+ file,
929
+ fetchFn,
930
+ options,
931
+ ).then(
365
932
  (sourceMap) => ({ sourceMap, isTransientFailure: false }),
366
933
  () => ({ sourceMap: null, isTransientFailure: true }),
367
934
  );
368
- if (useCache) {
369
- _pendingSourceMapRequests.set(file, fetchPromise);
935
+ if (shouldUseCache) {
936
+ pendingRequests.set(cacheKey, fetchPromise);
370
937
  }
371
938
 
372
939
  const { sourceMap, isTransientFailure } = await fetchPromise;
373
- if (useCache) {
374
- _pendingSourceMapRequests.delete(file);
940
+ if (shouldUseCache) {
941
+ pendingRequests.delete(cacheKey);
375
942
  if (!isTransientFailure) {
376
- sourceMapCache.set(file, sourceMap);
943
+ cache.set(cacheKey, sourceMap);
377
944
  }
378
945
  }
379
946
 
@@ -383,9 +950,9 @@ export const getSourceMap = async (
383
950
  export const symbolicateStack = async (
384
951
  stack: StackFrame[],
385
952
  cache = true,
386
- fetchFn?: (url: string) => Promise<Response>,
953
+ fetchFn?: SourceFetch,
387
954
  ): Promise<StackFrame[]> => {
388
- return await Promise.all(
955
+ return Promise.all(
389
956
  stack.map(async (stackFrame) => {
390
957
  if (!stackFrame.fileName) return stackFrame;
391
958
  const sourceMap = await getSourceMap(stackFrame.fileName, cache, fetchFn);
@@ -409,6 +976,7 @@ export const symbolicateStack = async (
409
976
  ? stackFrame.source.replace(stackFrame.fileName, symbolicatedSource.fileName)
410
977
  : stackFrame.source,
411
978
  fileName: symbolicatedSource.fileName,
979
+ functionName: symbolicatedSource.functionName ?? stackFrame.functionName,
412
980
  lineNumber: symbolicatedSource.lineNumber,
413
981
  columnNumber: symbolicatedSource.columnNumber,
414
982
  isIgnoreListed: symbolicatedSource.isIgnoreListed,