bippy 0.6.1-dev.3a24abf → 0.6.1-dev.d7876ea

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.
@@ -1,5 +1,6 @@
1
1
  import { decode, SourceMapMappings, type SourceMapSegment } from "@jridgewell/sourcemap-codec";
2
2
 
3
+ import { BippySourceMapError } from "../errors.js";
3
4
  import { StackFrame } from "./parse-stack.js";
4
5
 
5
6
  export interface DecodedSourceMapSection {
@@ -10,7 +11,7 @@ export interface DecodedSourceMapSection {
10
11
  names?: string[];
11
12
  sourceRoot?: string;
12
13
  sources: string[];
13
- sourcesContent?: string[];
14
+ sourcesContent?: Array<string | null>;
14
15
  version: 3;
15
16
  };
16
17
  offset: {
@@ -19,20 +20,29 @@ export interface DecodedSourceMapSection {
19
20
  };
20
21
  }
21
22
 
22
- // https://tc39.es/ecma426/#sec-index-source-map
23
23
  export interface IndexSourceMap {
24
24
  file?: string;
25
25
  sections: Array<{
26
- map: StandardSourceMap;
26
+ map?: StandardSourceMap;
27
27
  offset: {
28
28
  column: number;
29
29
  line: number;
30
30
  };
31
+ url?: string;
31
32
  }>;
32
33
  version: 3;
33
34
  }
34
35
 
35
- export type RawSourceMap = IndexSourceMap | StandardSourceMap;
36
+ export interface SourceFetch {
37
+ (url: string, init?: RequestInit): Promise<Response>;
38
+ }
39
+
40
+ export interface SourceMapRequestOptions {
41
+ maxBundleSizeBytes?: number;
42
+ maxSourceMapSizeBytes?: number;
43
+ signal?: AbortSignal;
44
+ timeoutMs?: number;
45
+ }
36
46
 
37
47
  export interface SourceMap {
38
48
  file?: string;
@@ -42,11 +52,10 @@ export interface SourceMap {
42
52
  sections?: DecodedSourceMapSection[];
43
53
  sourceRoot?: string;
44
54
  sources: string[];
45
- sourcesContent?: string[];
55
+ sourcesContent?: Array<string | null>;
46
56
  version: 3;
47
57
  }
48
58
 
49
- // https://developer.chrome.com/blog/sourcemaps#the_anatomy_of_a_source_map
50
59
  export interface StandardSourceMap {
51
60
  file?: string;
52
61
  ignoreList?: number[];
@@ -54,27 +63,30 @@ export interface StandardSourceMap {
54
63
  names?: string[];
55
64
  sourceRoot?: string;
56
65
  sources: string[];
57
- sourcesContent?: string[];
66
+ sourcesContent?: Array<string | null>;
58
67
  version: 3;
59
68
  x_google_ignoreList?: number[];
60
69
  }
61
70
 
62
- // has a scheme, e.g. http://, https://, file://, data:, etc.
63
- // https://datatracker.ietf.org/doc/html/rfc3986#section-3.1
64
71
  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
72
+ const INLINE_SOURCEMAP_REGEX = /^data:application\/json(?:;[^,]*)?,/i;
68
73
  const SOURCEMAP_REGEX =
69
74
  /(?:\/\/[@#][ \t]+sourceMappingURL=([^\s'"]+?)[ \t]*$)|(?:\/\*[@#][ \t]+sourceMappingURL=([^*]+?)[ \t]*(?:\*\/)[ \t]*$)/;
70
75
 
71
76
  export const sourceMapCache = new Map<string, null | SourceMap>();
77
+ const sourceMapCachesByFetch = new WeakMap<SourceFetch, Map<string, null | SourceMap>>();
72
78
  interface SourceMapResult {
73
79
  sourceMap: null | SourceMap;
74
80
  isTransientFailure: boolean;
75
81
  }
76
82
 
77
83
  const _pendingSourceMapRequests = new Map<string, Promise<SourceMapResult>>();
84
+ const pendingSourceMapRequestsByFetch = new WeakMap<
85
+ SourceFetch,
86
+ Map<string, Promise<SourceMapResult>>
87
+ >();
88
+ const defaultMaxBundleSizeBytes = 25 * 1024 * 1024;
89
+ const defaultMaxSourceMapSizeBytes = 100 * 1024 * 1024;
78
90
 
79
91
  const getSourceFromMappings = (
80
92
  mappings: SourceMapMappings,
@@ -92,8 +104,6 @@ const getSourceFromMappings = (
92
104
  return null;
93
105
  }
94
106
 
95
- // Segments within a line are sorted by generated column, so binary search for
96
- // the last segment at or before the column.
97
107
  let closestLineSegment: null | SourceMapSegment = null;
98
108
  let lowIndex = 0;
99
109
  let highIndex = lineMapping.length - 1;
@@ -137,7 +147,6 @@ export const getSourceFromSourceMap = (
137
147
  column: number,
138
148
  ): StackFrame | null => {
139
149
  if (sourceMap.sections) {
140
- // Section offsets are 0-based while stack trace lines are 1-based.
141
150
  const lineIndex = line - 1;
142
151
  let targetSection: DecodedSourceMapSection | null = null;
143
152
 
@@ -178,9 +187,21 @@ export const getSourceFromSourceMap = (
178
187
  );
179
188
  };
180
189
 
190
+ const resolveUrl = (reference: string, baseUrl: string): string | null => {
191
+ if (INLINE_SOURCEMAP_REGEX.test(reference)) return reference;
192
+ try {
193
+ return new URL(reference, baseUrl).toString();
194
+ } catch {
195
+ try {
196
+ const resolvedUrl = new URL(reference, new URL(baseUrl, "https://bippy.invalid/"));
197
+ return `${resolvedUrl.pathname}${resolvedUrl.search}${resolvedUrl.hash}`;
198
+ } catch {
199
+ return null;
200
+ }
201
+ }
202
+ };
203
+
181
204
  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.
184
205
  let sourceMapUrl: string | undefined;
185
206
  let searchEnd = content.length;
186
207
  while (searchEnd > 0 && !sourceMapUrl) {
@@ -196,14 +217,57 @@ const getSourceMapUrl = (url: string, content: string): null | string => {
196
217
  return null;
197
218
  }
198
219
 
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("/");
204
- }
220
+ return resolveUrl(sourceMapUrl, url);
221
+ };
222
+
223
+ const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
224
+ typeof value === "object" && value !== null;
225
+
226
+ const isStringArray = (value: unknown): value is string[] =>
227
+ Array.isArray(value) && value.every((entry) => typeof entry === "string");
228
+
229
+ const isSourcesContent = (value: unknown): value is Array<string | null> =>
230
+ Array.isArray(value) && value.every((entry) => typeof entry === "string" || entry === null);
231
+
232
+ const isOptionalNumberArray = (value: unknown): value is number[] | undefined =>
233
+ value === undefined ||
234
+ (Array.isArray(value) && value.every((entry) => Number.isInteger(entry) && entry >= 0));
235
+
236
+ const isStandardSourceMap = (value: unknown): value is StandardSourceMap => {
237
+ if (!isObjectRecord(value)) return false;
238
+ const hasValidShape =
239
+ value.version === 3 &&
240
+ typeof value.mappings === "string" &&
241
+ isStringArray(value.sources) &&
242
+ (value.file === undefined || typeof value.file === "string") &&
243
+ (value.names === undefined || isStringArray(value.names)) &&
244
+ (value.sourceRoot === undefined || typeof value.sourceRoot === "string") &&
245
+ (value.sourcesContent === undefined || isSourcesContent(value.sourcesContent)) &&
246
+ isOptionalNumberArray(value.ignoreList) &&
247
+ isOptionalNumberArray(value.x_google_ignoreList);
248
+ if (!hasValidShape) return false;
249
+ if (value.sourcesContent && value.sourcesContent.length !== value.sources.length) return false;
250
+ return [...(value.ignoreList ?? []), ...(value.x_google_ignoreList ?? [])].every(
251
+ (sourceIndex) => sourceIndex < value.sources.length,
252
+ );
253
+ };
205
254
 
206
- return sourceMapUrl;
255
+ const isIndexSourceMap = (value: unknown): value is IndexSourceMap => {
256
+ if (!isObjectRecord(value) || value.version !== 3 || !Array.isArray(value.sections)) {
257
+ return false;
258
+ }
259
+ return value.sections.every((section) => {
260
+ if (!isObjectRecord(section) || !isObjectRecord(section.offset)) return false;
261
+ const hasMap = isStandardSourceMap(section.map);
262
+ const hasUrl = typeof section.url === "string" && section.url.length > 0;
263
+ return (
264
+ hasMap !== hasUrl &&
265
+ Number.isInteger(section.offset.column) &&
266
+ section.offset.column >= 0 &&
267
+ Number.isInteger(section.offset.line) &&
268
+ section.offset.line >= 0
269
+ );
270
+ });
207
271
  };
208
272
 
209
273
  const getIgnoredSourceIndices = (rawSourceMap: StandardSourceMap): Set<number> | undefined => {
@@ -211,45 +275,98 @@ const getIgnoredSourceIndices = (rawSourceMap: StandardSourceMap): Set<number> |
211
275
  return Array.isArray(ignoreList) && ignoreList.length > 0 ? new Set(ignoreList) : undefined;
212
276
  };
213
277
 
214
- const resolveSourceRoot = (sourceRoot: string | undefined, source: string): string => {
278
+ const resolveSourceRoot = (
279
+ sourceRoot: string | undefined,
280
+ source: string,
281
+ sourceMapUrl: string,
282
+ ): string => {
215
283
  if (!sourceRoot || SCHEME_REGEX.test(source) || source.startsWith("/")) return source;
216
284
  const normalizedSourceRoot = sourceRoot.endsWith("/") ? sourceRoot : `${sourceRoot}/`;
217
285
  const normalizedSource = source.replace(/^\.\//, "");
218
- if (SCHEME_REGEX.test(normalizedSourceRoot)) {
219
- try {
220
- return new URL(normalizedSource, normalizedSourceRoot).toString();
221
- } catch {}
286
+ try {
287
+ const baseUrl = SCHEME_REGEX.test(normalizedSourceRoot)
288
+ ? normalizedSourceRoot
289
+ : new URL(normalizedSourceRoot, sourceMapUrl).toString();
290
+ return new URL(normalizedSource, baseUrl).toString();
291
+ } catch {
292
+ const pathSegments = `${normalizedSourceRoot}${normalizedSource}`.split("/");
293
+ const normalizedPathSegments: string[] = [];
294
+ for (const pathSegment of pathSegments) {
295
+ if (pathSegment === "..") {
296
+ normalizedPathSegments.pop();
297
+ } else if (pathSegment !== ".") {
298
+ normalizedPathSegments.push(pathSegment);
299
+ }
300
+ }
301
+ return normalizedPathSegments.join("/");
222
302
  }
223
- return `${normalizedSourceRoot}${normalizedSource}`;
224
303
  };
225
304
 
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
- }),
305
+ const resolveSourceMapSources = (rawSourceMap: StandardSourceMap, sourceMapUrl: string): string[] =>
306
+ rawSourceMap.sources.map((source) =>
307
+ resolveSourceRoot(rawSourceMap.sourceRoot, source, sourceMapUrl),
251
308
  );
252
309
 
310
+ const decodeStandardSourceMap = (
311
+ rawSourceMap: StandardSourceMap,
312
+ sourceMapUrl: string,
313
+ ): SourceMap | null => {
314
+ try {
315
+ return {
316
+ file: rawSourceMap.file,
317
+ ignoredSourceIndices: getIgnoredSourceIndices(rawSourceMap),
318
+ mappings: decode(rawSourceMap.mappings),
319
+ names: rawSourceMap.names,
320
+ sourceRoot: rawSourceMap.sourceRoot,
321
+ sources: resolveSourceMapSources(rawSourceMap, sourceMapUrl),
322
+ sourcesContent: rawSourceMap.sourcesContent,
323
+ version: 3,
324
+ };
325
+ } catch {
326
+ return null;
327
+ }
328
+ };
329
+
330
+ interface LoadStandardSourceMap {
331
+ (url: string): Promise<StandardSourceMap | null>;
332
+ }
333
+
334
+ const decodeIndexSourceMap = async (
335
+ rawSourceMap: IndexSourceMap,
336
+ sourceMapUrl: string,
337
+ loadStandardSourceMap: LoadStandardSourceMap,
338
+ ): Promise<SourceMap | null> => {
339
+ const decodedSections: DecodedSourceMapSection[] = [];
340
+ let previousOffsetLine = -1;
341
+ let previousOffsetColumn = -1;
342
+ for (const section of rawSourceMap.sections) {
343
+ if (
344
+ section.offset.line < previousOffsetLine ||
345
+ (section.offset.line === previousOffsetLine && section.offset.column <= previousOffsetColumn)
346
+ ) {
347
+ return null;
348
+ }
349
+ previousOffsetLine = section.offset.line;
350
+ previousOffsetColumn = section.offset.column;
351
+ const sectionUrl = section.url ? resolveUrl(section.url, sourceMapUrl) : null;
352
+ const map = section.map ?? (sectionUrl ? await loadStandardSourceMap(sectionUrl) : null);
353
+ if (!map) return null;
354
+ const sectionSourceMapUrl = sectionUrl ?? sourceMapUrl;
355
+ try {
356
+ decodedSections.push({
357
+ map: {
358
+ ...map,
359
+ ignoredSourceIndices: getIgnoredSourceIndices(map),
360
+ mappings: decode(map.mappings),
361
+ sources: resolveSourceMapSources(map, sectionSourceMapUrl),
362
+ },
363
+ offset: section.offset,
364
+ });
365
+ } catch {
366
+ return null;
367
+ }
368
+ }
369
+
253
370
  const allSources = new Set<string>();
254
371
  for (const section of decodedSections) {
255
372
  for (const source of section.map.sources) {
@@ -291,89 +408,227 @@ const isFetchableUrl = (url: string): boolean => {
291
408
  return scheme === "http:" || scheme === "https:";
292
409
  };
293
410
 
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)) {
304
- return null;
411
+ interface RequestSignal {
412
+ cleanup: () => void;
413
+ signal?: AbortSignal;
414
+ }
415
+
416
+ const createRequestSignal = (options: SourceMapRequestOptions): RequestSignal => {
417
+ if (!options.signal && options.timeoutMs === undefined) {
418
+ return { cleanup: () => {} };
419
+ }
420
+ const abortController = new AbortController();
421
+ const abortFromSource = (): void => abortController.abort(options.signal?.reason);
422
+ if (options.signal?.aborted) {
423
+ abortFromSource();
424
+ } else {
425
+ options.signal?.addEventListener("abort", abortFromSource, { once: true });
305
426
  }
427
+ const timeoutHandle =
428
+ options.timeoutMs !== undefined && options.timeoutMs >= 0
429
+ ? setTimeout(
430
+ () => abortController.abort(new BippySourceMapError("Source map request timed out")),
431
+ options.timeoutMs,
432
+ )
433
+ : undefined;
434
+ return {
435
+ cleanup: () => {
436
+ if (timeoutHandle !== undefined) clearTimeout(timeoutHandle);
437
+ options.signal?.removeEventListener("abort", abortFromSource);
438
+ },
439
+ signal: abortController.signal,
440
+ };
441
+ };
306
442
 
307
- const sourceFetch =
308
- fetchFn ??
309
- (typeof globalThis.fetch === "function" ? globalThis.fetch.bind(globalThis) : undefined);
310
- if (!sourceFetch) return null;
443
+ const readResponseText = async (
444
+ response: Response,
445
+ maxSizeBytes: number,
446
+ ): Promise<string | null> => {
447
+ const contentLength = Number(response.headers.get("content-length"));
448
+ if (Number.isFinite(contentLength) && contentLength > maxSizeBytes) return null;
449
+ const content = await response.text();
450
+ return new TextEncoder().encode(content).byteLength <= maxSizeBytes ? content : null;
451
+ };
311
452
 
312
- const bundleResponse = await sourceFetch(bundleUrl);
313
- if (!bundleResponse.ok) {
453
+ const decodeBase64 = (encodedContent: string): string | null => {
454
+ try {
455
+ if (typeof globalThis.atob === "function") {
456
+ return new TextDecoder().decode(
457
+ Uint8Array.from(globalThis.atob(encodedContent), (character) => character.charCodeAt(0)),
458
+ );
459
+ }
460
+ } catch {
314
461
  return null;
315
462
  }
316
- const bundleContent = await bundleResponse.text();
317
- if (!bundleContent) {
318
- return null;
463
+
464
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
465
+ const normalizedContent = encodedContent.replace(/\s/g, "").replace(/=+$/, "");
466
+ const bytes: number[] = [];
467
+ let bitBuffer = 0;
468
+ let bitCount = 0;
469
+ for (const character of normalizedContent) {
470
+ const value = alphabet.indexOf(character);
471
+ if (value === -1) return null;
472
+ bitBuffer = bitBuffer * 64 + value;
473
+ bitCount += 6;
474
+ if (bitCount >= 8) {
475
+ bitCount -= 8;
476
+ bytes.push(Math.floor(bitBuffer / 2 ** bitCount) & 255);
477
+ bitBuffer %= 2 ** bitCount;
478
+ }
319
479
  }
480
+ return new TextDecoder().decode(Uint8Array.from(bytes));
481
+ };
320
482
 
321
- const sourceMapUrl = getSourceMapUrl(bundleUrl, bundleContent);
483
+ const readInlineSourceMap = (sourceMapUrl: string, maxSizeBytes: number): unknown => {
484
+ const commaIndex = sourceMapUrl.indexOf(",");
485
+ if (commaIndex === -1) return null;
486
+ const metadata = sourceMapUrl.slice(0, commaIndex);
487
+ const encodedContent = sourceMapUrl.slice(commaIndex + 1);
488
+ if (encodedContent.length > maxSizeBytes * 2) return null;
489
+ try {
490
+ const content = metadata.toLowerCase().includes(";base64")
491
+ ? decodeBase64(encodedContent)
492
+ : decodeURIComponent(encodedContent);
493
+ if (content === null) return null;
494
+ if (new TextEncoder().encode(content).byteLength > maxSizeBytes) return null;
495
+ return JSON.parse(content);
496
+ } catch {
497
+ return null;
498
+ }
499
+ };
322
500
 
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)) {
501
+ const readSourceMapDocument = async (
502
+ sourceMapUrl: string,
503
+ sourceFetch: SourceFetch,
504
+ signal: AbortSignal | undefined,
505
+ maxSizeBytes: number,
506
+ ): Promise<unknown> => {
507
+ if (INLINE_SOURCEMAP_REGEX.test(sourceMapUrl)) {
508
+ return readInlineSourceMap(sourceMapUrl, maxSizeBytes);
509
+ }
510
+ const sourceMapResponse = await sourceFetch(sourceMapUrl, { signal });
511
+ if (!sourceMapResponse.ok) return null;
512
+ const sourceMapContent = await readResponseText(sourceMapResponse, maxSizeBytes);
513
+ if (sourceMapContent === null) return null;
514
+ try {
515
+ return JSON.parse(sourceMapContent);
516
+ } catch {
327
517
  return null;
328
518
  }
519
+ };
329
520
 
330
- const sourceMapResponse = await sourceFetch(sourceMapUrl);
331
- if (!sourceMapResponse.ok) {
521
+ export const getSourceMapUncached = async (
522
+ bundleUrl: string,
523
+ fetchFn?: SourceFetch,
524
+ options: SourceMapRequestOptions = {},
525
+ ): Promise<null | SourceMap> => {
526
+ if (!isFetchableUrl(bundleUrl)) {
332
527
  return null;
333
528
  }
334
529
 
530
+ const sourceFetch =
531
+ fetchFn ??
532
+ (typeof globalThis.fetch === "function" ? globalThis.fetch.bind(globalThis) : undefined);
533
+ if (!sourceFetch) return null;
534
+ const maxBundleSizeBytes = options.maxBundleSizeBytes ?? defaultMaxBundleSizeBytes;
535
+ const maxSourceMapSizeBytes = options.maxSourceMapSizeBytes ?? defaultMaxSourceMapSizeBytes;
536
+ if (maxBundleSizeBytes < 0 || maxSourceMapSizeBytes < 0) return null;
537
+ const requestSignal = createRequestSignal(options);
335
538
  try {
336
- const rawSourceMap = (await sourceMapResponse.json()) as RawSourceMap;
539
+ const bundleResponse = await sourceFetch(bundleUrl, { signal: requestSignal.signal });
540
+ if (!bundleResponse.ok) return null;
541
+ const bundleContent = await readResponseText(bundleResponse, maxBundleSizeBytes);
542
+ if (!bundleContent) return null;
543
+ const sourceMapUrl = getSourceMapUrl(bundleUrl, bundleContent);
544
+ if (!sourceMapUrl) return null;
545
+ if (!isFetchableUrl(sourceMapUrl) && !INLINE_SOURCEMAP_REGEX.test(sourceMapUrl)) return null;
546
+
547
+ const rawSourceMap = await readSourceMapDocument(
548
+ sourceMapUrl,
549
+ sourceFetch,
550
+ requestSignal.signal,
551
+ maxSourceMapSizeBytes,
552
+ );
553
+ if (isStandardSourceMap(rawSourceMap)) {
554
+ return decodeStandardSourceMap(rawSourceMap, sourceMapUrl);
555
+ }
556
+ if (!isIndexSourceMap(rawSourceMap)) return null;
557
+ return decodeIndexSourceMap(rawSourceMap, sourceMapUrl, async (sectionUrl) => {
558
+ const sectionSourceMap = await readSourceMapDocument(
559
+ sectionUrl,
560
+ sourceFetch,
561
+ requestSignal.signal,
562
+ maxSourceMapSizeBytes,
563
+ );
564
+ return isStandardSourceMap(sectionSourceMap) ? sectionSourceMap : null;
565
+ });
566
+ } finally {
567
+ requestSignal.cleanup();
568
+ }
569
+ };
337
570
 
338
- return "sections" in rawSourceMap
339
- ? decodeIndexSourceMap(rawSourceMap)
340
- : decodeStandardSourceMap(rawSourceMap);
341
- } catch {
342
- return null;
571
+ const getSourceMapCache = (fetchFn: SourceFetch | undefined): Map<string, null | SourceMap> => {
572
+ if (!fetchFn) return sourceMapCache;
573
+ let cache = sourceMapCachesByFetch.get(fetchFn);
574
+ if (!cache) {
575
+ cache = new Map();
576
+ sourceMapCachesByFetch.set(fetchFn, cache);
343
577
  }
578
+ return cache;
344
579
  };
345
580
 
581
+ const getPendingSourceMapRequests = (
582
+ fetchFn: SourceFetch | undefined,
583
+ ): Map<string, Promise<SourceMapResult>> => {
584
+ if (!fetchFn) return _pendingSourceMapRequests;
585
+ let pendingRequests = pendingSourceMapRequestsByFetch.get(fetchFn);
586
+ if (!pendingRequests) {
587
+ pendingRequests = new Map();
588
+ pendingSourceMapRequestsByFetch.set(fetchFn, pendingRequests);
589
+ }
590
+ return pendingRequests;
591
+ };
592
+
593
+ const getSourceMapCacheKey = (file: string, options: SourceMapRequestOptions): string =>
594
+ options.maxBundleSizeBytes === undefined &&
595
+ options.maxSourceMapSizeBytes === undefined &&
596
+ options.timeoutMs === undefined
597
+ ? file
598
+ : `${file}\0${options.maxBundleSizeBytes ?? ""}\0${options.maxSourceMapSizeBytes ?? ""}\0${options.timeoutMs ?? ""}`;
599
+
346
600
  export const getSourceMap = async (
347
601
  file: string,
348
602
  useCache = true,
349
- fetchFn?: (url: string) => Promise<Response>,
603
+ fetchFn?: SourceFetch,
604
+ options: SourceMapRequestOptions = {},
350
605
  ): Promise<null | SourceMap> => {
351
- if (useCache && sourceMapCache.has(file)) {
352
- return sourceMapCache.get(file) ?? null;
606
+ const shouldUseCache = useCache && options.signal === undefined;
607
+ const cache = getSourceMapCache(fetchFn);
608
+ const pendingRequests = getPendingSourceMapRequests(fetchFn);
609
+ const cacheKey = getSourceMapCacheKey(file, options);
610
+ if (shouldUseCache && cache.has(cacheKey)) {
611
+ return cache.get(cacheKey) ?? null;
353
612
  }
354
613
 
355
- const pendingRequest = useCache ? _pendingSourceMapRequests.get(file) : undefined;
614
+ const pendingRequest = shouldUseCache ? pendingRequests.get(cacheKey) : undefined;
356
615
  if (pendingRequest) {
357
616
  return (await pendingRequest).sourceMap;
358
617
  }
359
618
 
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(
619
+ const fetchPromise: Promise<SourceMapResult> = getSourceMapUncached(file, fetchFn, options).then(
365
620
  (sourceMap) => ({ sourceMap, isTransientFailure: false }),
366
621
  () => ({ sourceMap: null, isTransientFailure: true }),
367
622
  );
368
- if (useCache) {
369
- _pendingSourceMapRequests.set(file, fetchPromise);
623
+ if (shouldUseCache) {
624
+ pendingRequests.set(cacheKey, fetchPromise);
370
625
  }
371
626
 
372
627
  const { sourceMap, isTransientFailure } = await fetchPromise;
373
- if (useCache) {
374
- _pendingSourceMapRequests.delete(file);
628
+ if (shouldUseCache) {
629
+ pendingRequests.delete(cacheKey);
375
630
  if (!isTransientFailure) {
376
- sourceMapCache.set(file, sourceMap);
631
+ cache.set(cacheKey, sourceMap);
377
632
  }
378
633
  }
379
634
 
@@ -385,7 +640,7 @@ export const symbolicateStack = async (
385
640
  cache = true,
386
641
  fetchFn?: (url: string) => Promise<Response>,
387
642
  ): Promise<StackFrame[]> => {
388
- return await Promise.all(
643
+ return Promise.all(
389
644
  stack.map(async (stackFrame) => {
390
645
  if (!stackFrame.fileName) return stackFrame;
391
646
  const sourceMap = await getSourceMap(stackFrame.fileName, cache, fetchFn);
package/src/types.ts CHANGED
@@ -89,7 +89,12 @@ export interface ContextDependency<T> extends Omit<
89
89
  observedBits?: number;
90
90
  }
91
91
 
92
+ export interface DebugThenableState {
93
+ thenables?: unknown[];
94
+ }
95
+
92
96
  export interface Dependencies extends Omit<ReactReconciler.Dependencies, "firstContext"> {
97
+ _debugThenableState?: DebugThenableState | unknown[];
93
98
  firstContext: ContextDependency<unknown> | null;
94
99
  }
95
100
 
@@ -115,6 +120,11 @@ export interface ReactDebugInfo {
115
120
  name?: string;
116
121
  }
117
122
 
123
+ export interface ReactMemoCache {
124
+ data: unknown[][];
125
+ index: number;
126
+ }
127
+
118
128
  export interface FiberDebugSource extends Source {
119
129
  columnNumber?: number;
120
130
  }
@@ -123,7 +133,7 @@ export interface FiberUpdateQueue {
123
133
  [key: string]: unknown;
124
134
  dispatch?: unknown;
125
135
  lastEffect: Effect | null;
126
- memoCache?: unknown;
136
+ memoCache?: ReactMemoCache;
127
137
  }
128
138
 
129
139
  // HACK: @types/react-reconciler does not yet include React 19 debug fields or recursive server owners.
@@ -160,7 +170,7 @@ export interface Fiber<T = unknown> extends Omit<
160
170
  sibling: Fiber | null;
161
171
  stateNode: T;
162
172
  tag: WorkTag;
163
- updateQueue: FiberUpdateQueue;
173
+ updateQueue: FiberUpdateQueue | null;
164
174
  }
165
175
 
166
176
  export interface HostFiber<T = unknown> extends Fiber<T> {