@ttsc/playground 0.20.1 → 0.22.0

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.
@@ -15,9 +15,13 @@ import {
15
15
  throwIfAborted,
16
16
  toTypesPackageName,
17
17
  unpackNpmTarball,
18
+ validateNpmByteLimit,
19
+ verifyTarball,
18
20
  } from "./internal/npmRegistry";
19
21
 
20
22
  const DEFAULT_MAX_PACKAGES = 48;
23
+ const DEFAULT_MAX_TARBALL_BYTES = 16 * 1024 * 1024;
24
+ const DEFAULT_MAX_UNPACKED_BYTES = 64 * 1024 * 1024;
21
25
 
22
26
  /**
23
27
  * Resolve, download, and unpack a set of npm packages directly inside the
@@ -72,6 +76,11 @@ export async function installPlaygroundDependencies(
72
76
  });
73
77
  }
74
78
  const maxPackages = options.maxPackages ?? DEFAULT_MAX_PACKAGES;
79
+ const maxTarballBytes = options.maxTarballBytes ?? DEFAULT_MAX_TARBALL_BYTES;
80
+ const maxUnpackedBytes =
81
+ options.maxUnpackedBytes ?? DEFAULT_MAX_UNPACKED_BYTES;
82
+ validateNpmByteLimit(maxTarballBytes, "compressed");
83
+ validateNpmByteLimit(maxUnpackedBytes, "expanded");
75
84
  const queue: IQueueItem[] = [];
76
85
  const queued = new Map<string, IQueueItem>();
77
86
  const done = new Map<string, string>();
@@ -190,12 +199,18 @@ export async function installPlaygroundDependencies(
190
199
  throwIfAborted(options.signal);
191
200
 
192
201
  report("resolve", item, `Resolving ${item.name}`);
193
- const metadata = await fetchNpmMetadata(
194
- fetchImpl,
195
- item.registryName ?? item.name,
196
- item.optional,
197
- options.signal,
198
- );
202
+ let metadata: Awaited<ReturnType<typeof fetchNpmMetadata>>;
203
+ try {
204
+ metadata = await fetchNpmMetadata(
205
+ fetchImpl,
206
+ item.registryName ?? item.name,
207
+ item.optional,
208
+ options.signal,
209
+ );
210
+ } catch (error) {
211
+ throwIfAborted(options.signal);
212
+ throw error;
213
+ }
199
214
  throwIfAborted(options.signal);
200
215
  if (!metadata) {
201
216
  const mounted = installedDependencies.get(item.name);
@@ -250,19 +265,30 @@ export async function installPlaygroundDependencies(
250
265
  const versionMetadata = metadata.versions[version];
251
266
  const tarball = versionMetadata?.dist?.tarball;
252
267
  if (!versionMetadata || !tarball) {
253
- if (item.optional) {
254
- done.set(item.name, "");
255
- report("skip", item, `Skipped optional ${item.name}`);
256
- continue;
257
- }
258
268
  throw new Error(`No tarball found for ${item.name}@${version}.`);
259
269
  }
260
270
 
261
- report("download", item, `Downloading ${item.name}@${version}`, version);
262
- const tgz = await downloadTarball(fetchImpl, tarball, options.signal);
263
- throwIfAborted(options.signal);
264
- report("extract", item, `Extracting ${item.name}@${version}`, version);
265
- const unpacked = await unpackNpmTarball(tgz, options.signal);
271
+ let unpacked: Awaited<ReturnType<typeof unpackNpmTarball>>;
272
+ try {
273
+ report("download", item, `Downloading ${item.name}@${version}`, version);
274
+ const tgz = await downloadTarball(
275
+ fetchImpl,
276
+ tarball,
277
+ options.signal,
278
+ maxTarballBytes,
279
+ );
280
+ throwIfAborted(options.signal);
281
+ await verifyTarball(tgz, versionMetadata.dist ?? {}, options.signal);
282
+ throwIfAborted(options.signal);
283
+ report("extract", item, `Extracting ${item.name}@${version}`, version);
284
+ unpacked = await unpackNpmTarball(tgz, options.signal, maxUnpackedBytes);
285
+ } catch (error) {
286
+ throwIfAborted(options.signal);
287
+ const message = error instanceof Error ? error.message : String(error);
288
+ throw new Error(`Failed to install ${item.name}@${version}: ${message}`, {
289
+ cause: error,
290
+ });
291
+ }
266
292
  throwIfAborted(options.signal);
267
293
  const packageJson = {
268
294
  ...versionMetadata,
@@ -22,6 +22,8 @@ export interface INpmVersionMetadata {
22
22
  peerDependencies?: Record<string, string>;
23
23
  peerDependenciesMeta?: Record<string, { optional?: boolean }>;
24
24
  dist?: {
25
+ integrity?: string;
26
+ shasum?: string;
25
27
  tarball?: string;
26
28
  };
27
29
  }
@@ -57,6 +59,11 @@ export type FetchLike = (
57
59
  init?: RequestInit,
58
60
  ) => Promise<Response>;
59
61
 
62
+ declare const VALIDATED_NPM_BYTE_LIMIT: unique symbol;
63
+ type ValidatedNpmByteLimit = number & {
64
+ readonly [VALIDATED_NPM_BYTE_LIMIT]: true;
65
+ };
66
+
60
67
  const TEXT_FILE_REGEXP =
61
68
  /(^package\.json$|\.([cm]?js|jsx|[cm]?ts|tsx|json)$|\.d\.[cm]?ts$)/i;
62
69
  export const DECLARATION_FILE_REGEXP = /\.d\.[cm]?ts$/i;
@@ -74,22 +81,38 @@ export async function fetchNpmMetadata(
74
81
  optional: boolean,
75
82
  signal: AbortSignal | undefined,
76
83
  ): Promise<INpmMetadata | null> {
77
- const response = await fetchImpl(
78
- `https://registry.npmjs.org/${encodeURIComponent(packageName)}`,
79
- {
80
- headers: {
81
- Accept: "application/vnd.npm.install-v1+json, application/json",
82
- },
83
- signal,
84
- },
84
+ throwIfAborted(signal);
85
+ const response = await fetchWithAbort(
86
+ () =>
87
+ fetchImpl(
88
+ `https://registry.npmjs.org/${encodeURIComponent(packageName)}`,
89
+ {
90
+ headers: {
91
+ Accept: "application/vnd.npm.install-v1+json, application/json",
92
+ },
93
+ signal,
94
+ },
95
+ ),
96
+ signal,
85
97
  );
86
- if (response.status === 404 && optional) return null;
98
+ throwIfResponseAborted(response, signal);
99
+ if (response.status === 404 && optional) {
100
+ cancelResponseBody(response);
101
+ return null;
102
+ }
87
103
  if (!response.ok) {
104
+ cancelResponseBody(response);
88
105
  throw new Error(
89
106
  `npm registry returned ${response.status} while resolving ${packageName}.`,
90
107
  );
91
108
  }
92
- return (await response.json()) as INpmMetadata;
109
+ throwIfAborted(signal);
110
+ const metadata = (await abortable(
111
+ () => response.json() as Promise<INpmMetadata>,
112
+ signal,
113
+ )) as INpmMetadata;
114
+ throwIfAborted(signal);
115
+ return metadata;
93
116
  }
94
117
 
95
118
  export function selectVersion(
@@ -135,20 +158,87 @@ export async function downloadTarball(
135
158
  fetchImpl: FetchLike,
136
159
  tarball: string,
137
160
  signal: AbortSignal | undefined,
161
+ maxBytes = 16 * 1024 * 1024,
138
162
  ): Promise<ArrayBuffer> {
139
- const response = await fetchImpl(tarball, { signal });
163
+ throwIfAborted(signal);
164
+ const byteLimit = validateNpmByteLimit(maxBytes, "compressed");
165
+ const response = await fetchWithAbort(
166
+ () => fetchImpl(tarball, { signal }),
167
+ signal,
168
+ );
169
+ throwIfResponseAborted(response, signal);
140
170
  if (!response.ok) {
171
+ cancelResponseBody(response);
141
172
  throw new Error(`tarball download failed with HTTP ${response.status}.`);
142
173
  }
143
- return response.arrayBuffer();
174
+ const declaredLength = response.headers.get("content-length");
175
+ if (declaredLength !== null) {
176
+ const parsed = Number(declaredLength);
177
+ if (Number.isFinite(parsed) && parsed >= 0 && parsed > byteLimit) {
178
+ cancelResponseBody(response);
179
+ throw new Error(
180
+ `tarball exceeds the ${formatByteLimit(byteLimit)} compressed byte limit.`,
181
+ );
182
+ }
183
+ }
184
+ return collectBoundedStream(
185
+ response.body,
186
+ byteLimit,
187
+ "compressed",
188
+ signal,
189
+ () => response.arrayBuffer(),
190
+ );
191
+ }
192
+
193
+ /** Verify registry authentication metadata against the compressed bytes. */
194
+ export async function verifyTarball(
195
+ tgz: ArrayBuffer,
196
+ dist: { integrity?: string; shasum?: string },
197
+ signal: AbortSignal | undefined,
198
+ ): Promise<void> {
199
+ throwIfAborted(signal);
200
+ if (dist.integrity !== undefined) {
201
+ const candidates = parseIntegrity(dist.integrity);
202
+ const strength = Math.max(...candidates.map(({ rank }) => rank));
203
+ const strongest = candidates.filter(
204
+ (candidate) => candidate.rank === strength,
205
+ );
206
+ const actual = new Uint8Array(
207
+ await abortable(
208
+ () => crypto.subtle.digest(strongest[0]!.webAlgorithm, tgz),
209
+ signal,
210
+ ),
211
+ );
212
+ throwIfAborted(signal);
213
+ if (!strongest.some(({ digest }) => equalBytes(actual, digest))) {
214
+ throw new Error(
215
+ `tarball integrity mismatch (${strongest[0]!.algorithm}).`,
216
+ );
217
+ }
218
+ return;
219
+ }
220
+ if (dist.shasum !== undefined) {
221
+ if (!/^[a-fA-F0-9]{40}$/.test(dist.shasum)) {
222
+ throw new Error("tarball shasum is not a valid SHA-1 digest.");
223
+ }
224
+ const actual = new Uint8Array(
225
+ await abortable(() => crypto.subtle.digest("SHA-1", tgz), signal),
226
+ );
227
+ throwIfAborted(signal);
228
+ if (!equalBytes(actual, decodeHex(dist.shasum))) {
229
+ throw new Error("tarball shasum mismatch (sha1).");
230
+ }
231
+ }
144
232
  }
145
233
 
146
234
  export async function unpackNpmTarball(
147
235
  tgz: ArrayBuffer,
148
236
  signal: AbortSignal | undefined,
237
+ maxBytes = 64 * 1024 * 1024,
149
238
  ): Promise<IUnpackedPackage> {
150
239
  throwIfAborted(signal);
151
- const tar = await gunzip(tgz);
240
+ const byteLimit = validateNpmByteLimit(maxBytes, "expanded");
241
+ const tar = await gunzip(tgz, byteLimit, signal);
152
242
  throwIfAborted(signal);
153
243
  const decoder = new TextDecoder();
154
244
  const files: Record<string, string> = {};
@@ -156,24 +246,47 @@ export async function unpackNpmTarball(
156
246
  let offset = 0;
157
247
  let longPath: string | null = null;
158
248
  let paxPath: string | null = null;
249
+ let terminated = false;
250
+ let archiveRoot: string | null = null;
251
+
252
+ const confine = (rawPath: string): string => {
253
+ const confined = confineTarPath(rawPath, archiveRoot);
254
+ archiveRoot = confined.root;
255
+ return confined.relative;
256
+ };
159
257
 
160
- while (offset + 512 <= tar.length) {
258
+ while (offset < tar.length) {
161
259
  throwIfAborted(signal);
260
+ if (offset + 512 > tar.length) {
261
+ throw new Error("Truncated tar header.");
262
+ }
162
263
  const header = tar.subarray(offset, offset + 512);
163
264
  offset += 512;
164
- if (header.every((value) => value === 0)) break;
265
+ if (header.every((value) => value === 0)) {
266
+ terminated = true;
267
+ break;
268
+ }
165
269
 
166
270
  const type = String.fromCharCode(header[156] ?? 0);
167
271
  const size = parseOctal(header.subarray(124, 136));
272
+ if (size > tar.length - offset) {
273
+ throw new Error("Tar entry body extends beyond the archive.");
274
+ }
168
275
  const body = tar.subarray(offset, offset + size);
169
- offset += Math.ceil(size / 512) * 512;
276
+ const paddedSize = Math.ceil(size / 512) * 512;
277
+ if (!Number.isSafeInteger(paddedSize) || paddedSize > tar.length - offset) {
278
+ throw new Error("Tar entry padding extends beyond the archive.");
279
+ }
280
+ offset += paddedSize;
170
281
 
171
282
  if (type === "L") {
172
283
  longPath = trimNull(decoder.decode(body));
284
+ confine(longPath);
173
285
  continue;
174
286
  }
175
287
  if (type === "x") {
176
288
  paxPath = parsePaxPath(body);
289
+ if (paxPath !== null) confine(paxPath);
177
290
  continue;
178
291
  }
179
292
  if (type !== "0" && type !== "\0") {
@@ -182,12 +295,11 @@ export async function unpackNpmTarball(
182
295
  continue;
183
296
  }
184
297
 
185
- const rawPath =
186
- paxPath ?? longPath ?? readTarString(header.subarray(0, 100), decoder);
298
+ const rawPath = paxPath ?? longPath ?? readTarHeaderPath(header, decoder);
187
299
  longPath = null;
188
300
  paxPath = null;
189
- const rel = stripTarRoot(rawPath);
190
- if (!rel || !TEXT_FILE_REGEXP.test(rel)) continue;
301
+ const rel = confine(rawPath);
302
+ if (!TEXT_FILE_REGEXP.test(rel)) continue;
191
303
 
192
304
  const text = decoder.decode(body);
193
305
  files[rel] = text;
@@ -199,11 +311,16 @@ export async function unpackNpmTarball(
199
311
  }
200
312
  }
201
313
  }
314
+ if (!terminated) throw new Error("Tar archive has no end marker.");
202
315
 
203
316
  return { files, packageJson };
204
317
  }
205
318
 
206
- async function gunzip(input: ArrayBuffer): Promise<Uint8Array> {
319
+ async function gunzip(
320
+ input: ArrayBuffer,
321
+ maxBytes: ValidatedNpmByteLimit,
322
+ signal: AbortSignal | undefined,
323
+ ): Promise<Uint8Array> {
207
324
  if (!("DecompressionStream" in globalThis)) {
208
325
  throw new Error(
209
326
  "This browser cannot unpack npm tgz files because DecompressionStream is unavailable.",
@@ -212,7 +329,11 @@ async function gunzip(input: ArrayBuffer): Promise<Uint8Array> {
212
329
  const stream = new Blob([input])
213
330
  .stream()
214
331
  .pipeThrough(new DecompressionStream("gzip"));
215
- return new Uint8Array(await new Response(stream).arrayBuffer());
332
+ return new Uint8Array(
333
+ await collectBoundedStream(stream, maxBytes, "expanded", signal, async () =>
334
+ new Response(stream).arrayBuffer(),
335
+ ),
336
+ );
216
337
  }
217
338
 
218
339
  export interface IMountedFiles {
@@ -307,12 +428,18 @@ function readTarString(bytes: Uint8Array, decoder: TextDecoder): string {
307
428
 
308
429
  function trimNull(text: string): string {
309
430
  const index = text.indexOf("\0");
310
- return (index < 0 ? text : text.slice(0, index)).trim();
431
+ return index < 0 ? text : text.slice(0, index);
311
432
  }
312
433
 
313
434
  function parseOctal(bytes: Uint8Array): number {
314
435
  const text = trimNull(new TextDecoder().decode(bytes)).trim();
315
- return text ? Number.parseInt(text, 8) : 0;
436
+ if (text.length === 0) return 0;
437
+ if (!/^[0-7]+$/.test(text)) throw new Error("Invalid tar entry size.");
438
+ const value = Number.parseInt(text, 8);
439
+ if (!Number.isSafeInteger(value) || value < 0) {
440
+ throw new Error("Tar entry size is outside the safe integer range.");
441
+ }
442
+ return value;
316
443
  }
317
444
 
318
445
  function parsePaxPath(bytes: Uint8Array): string | null {
@@ -343,11 +470,284 @@ function parsePaxPath(bytes: Uint8Array): string | null {
343
470
  return path;
344
471
  }
345
472
 
346
- function stripTarRoot(path: string): string {
347
- const normalized = path.replace(/\\/g, "/").replace(/^\/+/, "");
348
- if (!normalized) return "";
349
- if (normalized.startsWith("package/"))
350
- return normalized.slice("package/".length);
351
- const slash = normalized.indexOf("/");
352
- return slash < 0 ? normalized : normalized.slice(slash + 1);
473
+ function readTarHeaderPath(header: Uint8Array, decoder: TextDecoder): string {
474
+ const name = readTarString(header.subarray(0, 100), decoder);
475
+ const prefix = readTarString(header.subarray(345, 500), decoder);
476
+ return prefix ? `${prefix}/${name}` : name;
477
+ }
478
+
479
+ /**
480
+ * Require an npm archive path below one safe, consistent top-level root.
481
+ *
482
+ * Npm normally emits `package/`, while current DefinitelyTyped tarballs use
483
+ * roots such as `node/` and `react/`. The root spelling does not enter the
484
+ * mounted key; consistency and safe remaining segments provide confinement.
485
+ */
486
+ function confineTarPath(
487
+ rawPath: string,
488
+ archiveRoot: string | null,
489
+ ): { relative: string; root: string } {
490
+ if (
491
+ rawPath.length === 0 ||
492
+ rawPath.includes("\\") ||
493
+ rawPath.startsWith("/") ||
494
+ /^[a-zA-Z]:/.test(rawPath)
495
+ ) {
496
+ throw new Error(`Invalid npm tar entry path ${JSON.stringify(rawPath)}.`);
497
+ }
498
+ const segments = rawPath.split("/");
499
+ if (
500
+ segments.length < 2 ||
501
+ segments.some(
502
+ (segment, index) =>
503
+ segment.length === 0 ||
504
+ segment === "." ||
505
+ segment === ".." ||
506
+ (index > 0 && /^[a-zA-Z]:/.test(segment)),
507
+ )
508
+ ) {
509
+ throw new Error(
510
+ `npm tar entry is outside a confined package root: ${JSON.stringify(rawPath)}.`,
511
+ );
512
+ }
513
+ const root = segments[0]!;
514
+ if (archiveRoot !== null && root !== archiveRoot) {
515
+ throw new Error(
516
+ `npm tar archive mixes package roots ${JSON.stringify(archiveRoot)} and ${JSON.stringify(root)}.`,
517
+ );
518
+ }
519
+ return { relative: segments.slice(1).join("/"), root };
520
+ }
521
+
522
+ interface IIntegrityCandidate {
523
+ algorithm: string;
524
+ digest: Uint8Array;
525
+ rank: number;
526
+ webAlgorithm: AlgorithmIdentifier;
527
+ }
528
+
529
+ function parseIntegrity(integrity: string): IIntegrityCandidate[] {
530
+ const tokens = integrity.trim().split(/\s+/).filter(Boolean);
531
+ if (tokens.length === 0) throw new Error("tarball integrity is empty.");
532
+ const candidates: IIntegrityCandidate[] = [];
533
+ for (const token of tokens) {
534
+ const match = /^([A-Za-z0-9]+)-([A-Za-z0-9+/]+={0,2})(?:\?[!-~]+)?$/i.exec(
535
+ token,
536
+ );
537
+ if (!match) {
538
+ throw new Error("tarball integrity contains malformed metadata.");
539
+ }
540
+ const algorithm = match[1]!.toLowerCase();
541
+ if (!["sha1", "sha256", "sha384", "sha512"].includes(algorithm)) {
542
+ decodeBase64(match[2]!);
543
+ continue;
544
+ }
545
+ const expectedLength =
546
+ algorithm === "sha512"
547
+ ? 64
548
+ : algorithm === "sha384"
549
+ ? 48
550
+ : algorithm === "sha256"
551
+ ? 32
552
+ : 20;
553
+ const digest = decodeBase64(match[2]!);
554
+ if (digest.length !== expectedLength) {
555
+ throw new Error("tarball integrity contains a malformed digest.");
556
+ }
557
+ candidates.push({
558
+ algorithm,
559
+ digest,
560
+ rank: expectedLength,
561
+ webAlgorithm: algorithm.replace("sha", "SHA-") as AlgorithmIdentifier,
562
+ });
563
+ }
564
+ if (candidates.length === 0) {
565
+ throw new Error("tarball integrity has no supported digest algorithm.");
566
+ }
567
+ return candidates;
568
+ }
569
+
570
+ function decodeBase64(value: string): Uint8Array {
571
+ try {
572
+ const decoded = atob(value);
573
+ return Uint8Array.from(decoded, (character) => character.charCodeAt(0));
574
+ } catch {
575
+ throw new Error("tarball integrity contains malformed base64.");
576
+ }
577
+ }
578
+
579
+ function decodeHex(value: string): Uint8Array {
580
+ return Uint8Array.from(value.match(/../g) ?? [], (byte) =>
581
+ Number.parseInt(byte, 16),
582
+ );
583
+ }
584
+
585
+ function equalBytes(left: Uint8Array, right: Uint8Array): boolean {
586
+ if (left.length !== right.length) return false;
587
+ let difference = 0;
588
+ for (let index = 0; index < left.length; ++index) {
589
+ difference |= left[index]! ^ right[index]!;
590
+ }
591
+ return difference === 0;
592
+ }
593
+
594
+ async function collectBoundedStream(
595
+ stream: ReadableStream<Uint8Array> | null,
596
+ maxBytes: ValidatedNpmByteLimit,
597
+ kind: "compressed" | "expanded",
598
+ signal: AbortSignal | undefined,
599
+ fallback: () => Promise<ArrayBuffer>,
600
+ ): Promise<ArrayBuffer> {
601
+ if (stream === null) {
602
+ throwIfAborted(signal);
603
+ const bytes = await abortable(fallback, signal);
604
+ throwIfAborted(signal);
605
+ if (bytes.byteLength > maxBytes) {
606
+ throw new Error(
607
+ `tarball exceeds the ${formatByteLimit(maxBytes)} ${kind} byte limit.`,
608
+ );
609
+ }
610
+ return bytes;
611
+ }
612
+ const reader = stream.getReader();
613
+ const chunks: Uint8Array[] = [];
614
+ let length = 0;
615
+ let abort: (() => void) | undefined;
616
+ const aborted =
617
+ signal === undefined
618
+ ? undefined
619
+ : new Promise<never>((_resolve, reject) => {
620
+ abort = () => {
621
+ let error: unknown;
622
+ try {
623
+ throwIfAborted(signal);
624
+ return;
625
+ } catch (caught) {
626
+ error = caught;
627
+ }
628
+ void reader.cancel(error).catch(() => undefined);
629
+ reject(error);
630
+ };
631
+ signal.addEventListener("abort", abort, { once: true });
632
+ });
633
+ try {
634
+ for (;;) {
635
+ throwIfAborted(signal);
636
+ const read = reader.read();
637
+ const next = aborted ? await Promise.race([read, aborted]) : await read;
638
+ throwIfAborted(signal);
639
+ if (next.done) break;
640
+ if (next.value.byteLength > maxBytes - length) {
641
+ void reader.cancel().catch(() => undefined);
642
+ throw new Error(
643
+ `tarball exceeds the ${formatByteLimit(maxBytes)} ${kind} byte limit.`,
644
+ );
645
+ }
646
+ chunks.push(next.value);
647
+ length += next.value.byteLength;
648
+ }
649
+ } catch (error) {
650
+ void reader.cancel(error).catch(() => undefined);
651
+ throw error;
652
+ } finally {
653
+ if (abort !== undefined) signal?.removeEventListener("abort", abort);
654
+ }
655
+ const output = new Uint8Array(length);
656
+ let offset = 0;
657
+ for (const chunk of chunks) {
658
+ output.set(chunk, offset);
659
+ offset += chunk.byteLength;
660
+ }
661
+ return output.buffer;
662
+ }
663
+
664
+ function formatByteLimit(bytes: number): string {
665
+ return `${bytes.toLocaleString("en-US")}-byte`;
666
+ }
667
+
668
+ /** Validate one public npm archive byte budget before starting related work. */
669
+ export function validateNpmByteLimit(
670
+ maxBytes: number,
671
+ kind: "compressed" | "expanded",
672
+ ): ValidatedNpmByteLimit {
673
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
674
+ throw new Error(`${kind} byte limit must be a positive safe integer.`);
675
+ }
676
+ return maxBytes as ValidatedNpmByteLimit;
677
+ }
678
+
679
+ /**
680
+ * Reject promptly on abort even when an underlying browser task is not
681
+ * cancellable.
682
+ */
683
+ function abortable<T>(
684
+ start: () => Promise<T>,
685
+ signal: AbortSignal | undefined,
686
+ disposeLateValue?: (value: T) => void,
687
+ ): Promise<T> {
688
+ throwIfAborted(signal);
689
+ let task: Promise<T>;
690
+ try {
691
+ task = start();
692
+ } catch (error) {
693
+ throwIfAborted(signal);
694
+ throw error;
695
+ }
696
+ if (signal === undefined) return task;
697
+ return new Promise<T>((resolve, reject) => {
698
+ let aborted = false;
699
+ const abort = () => {
700
+ if (aborted) return;
701
+ aborted = true;
702
+ signal.removeEventListener("abort", abort);
703
+ try {
704
+ throwIfAborted(signal);
705
+ } catch (error) {
706
+ reject(error);
707
+ }
708
+ };
709
+ signal.addEventListener("abort", abort, { once: true });
710
+ void task
711
+ .then((value) => {
712
+ if (signal.aborted) {
713
+ try {
714
+ disposeLateValue?.(value);
715
+ } catch {
716
+ // Disposal is best-effort and must not replace the abort reason.
717
+ }
718
+ abort();
719
+ return;
720
+ }
721
+ resolve(value);
722
+ }, reject)
723
+ .finally(() => {
724
+ signal.removeEventListener("abort", abort);
725
+ });
726
+ if (signal.aborted) abort();
727
+ });
728
+ }
729
+
730
+ /** Cancel an unused response body without obscuring the deciding outcome. */
731
+ function cancelResponseBody(response: Response): void {
732
+ void response.body?.cancel().catch(() => undefined);
733
+ }
734
+
735
+ /** Preserve the abort reason across the response-to-caller handoff. */
736
+ function throwIfResponseAborted(
737
+ response: Response,
738
+ signal: AbortSignal | undefined,
739
+ ): void {
740
+ if (!signal?.aborted) return;
741
+ cancelResponseBody(response);
742
+ throwIfAborted(signal);
743
+ }
744
+
745
+ /** Fetch one response and cancel any body that loses the abort race. */
746
+ async function fetchWithAbort(
747
+ start: () => Promise<Response>,
748
+ signal: AbortSignal | undefined,
749
+ ): Promise<Response> {
750
+ const response = await abortable(start, signal, cancelResponseBody);
751
+ throwIfResponseAborted(response, signal);
752
+ return response;
353
753
  }