kinetex 1.4.0 → 1.5.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.
- package/README.md +170 -46
- package/dist/browser/kinetex.esm.js +29 -29
- package/dist/browser/kinetex.js +1143 -791
- package/dist/browser/kinetex.min.js +29 -29
- package/dist/cjs/aws-sigv4.js +7 -14
- package/dist/cjs/cache.js +9 -0
- package/dist/cjs/circuit-breaker.js +8 -1
- package/dist/cjs/client.js +350 -95
- package/dist/cjs/cookie-parser.js +28 -1
- package/dist/cjs/core.js +227 -5
- package/dist/cjs/dedup.js +93 -10
- package/dist/cjs/digest.js +36 -15
- package/dist/cjs/graphql.js +2 -11
- package/dist/cjs/headers.js +24 -1
- package/dist/cjs/interceptors.js +6 -17
- package/dist/cjs/lifecycle.js +3 -19
- package/dist/cjs/logging.js +12 -29
- package/dist/cjs/progress.js +1 -3
- package/dist/cjs/proxy.js +19 -2
- package/dist/cjs/response.js +202 -75
- package/dist/cjs/socks5.js +5 -0
- package/dist/cjs/utils.js +115 -27
- package/dist/cjs/ws.js +43 -2
- package/dist/esm/aws-sigv4.js +7 -14
- package/dist/esm/aws-sigv4.js.map +1 -1
- package/dist/esm/cache.js +9 -0
- package/dist/esm/cache.js.map +1 -1
- package/dist/esm/circuit-breaker.js +8 -1
- package/dist/esm/circuit-breaker.js.map +1 -1
- package/dist/esm/client.js +350 -95
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/cookie-parser.js +28 -1
- package/dist/esm/cookie-parser.js.map +1 -1
- package/dist/esm/core.js +227 -5
- package/dist/esm/core.js.map +1 -1
- package/dist/esm/dedup.js +93 -10
- package/dist/esm/dedup.js.map +1 -1
- package/dist/esm/digest.js +36 -15
- package/dist/esm/digest.js.map +1 -1
- package/dist/esm/graphql.js +2 -11
- package/dist/esm/graphql.js.map +1 -1
- package/dist/esm/headers.js +24 -1
- package/dist/esm/headers.js.map +1 -1
- package/dist/esm/interceptors.js +6 -17
- package/dist/esm/interceptors.js.map +1 -1
- package/dist/esm/lifecycle.js +3 -19
- package/dist/esm/lifecycle.js.map +1 -1
- package/dist/esm/logging.js +12 -29
- package/dist/esm/logging.js.map +1 -1
- package/dist/esm/progress.js +1 -3
- package/dist/esm/progress.js.map +1 -1
- package/dist/esm/proxy.js +19 -2
- package/dist/esm/proxy.js.map +1 -1
- package/dist/esm/response.js +202 -75
- package/dist/esm/response.js.map +1 -1
- package/dist/esm/socks5.js +5 -0
- package/dist/esm/socks5.js.map +1 -1
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/url.js.map +1 -1
- package/dist/esm/utils.js +115 -27
- package/dist/esm/utils.js.map +1 -1
- package/dist/esm/ws.js +43 -2
- package/dist/esm/ws.js.map +1 -1
- package/dist/types/aws-sigv4.d.ts.map +1 -1
- package/dist/types/cache.d.ts.map +1 -1
- package/dist/types/circuit-breaker.d.ts +1 -1
- package/dist/types/circuit-breaker.d.ts.map +1 -1
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/cookie-parser.d.ts.map +1 -1
- package/dist/types/core.d.ts +113 -0
- package/dist/types/core.d.ts.map +1 -1
- package/dist/types/dedup.d.ts +38 -3
- package/dist/types/dedup.d.ts.map +1 -1
- package/dist/types/digest.d.ts.map +1 -1
- package/dist/types/graphql.d.ts.map +1 -1
- package/dist/types/headers.d.ts +23 -4
- package/dist/types/headers.d.ts.map +1 -1
- package/dist/types/interceptors.d.ts +24 -38
- package/dist/types/interceptors.d.ts.map +1 -1
- package/dist/types/lifecycle.d.ts.map +1 -1
- package/dist/types/logging.d.ts.map +1 -1
- package/dist/types/progress.d.ts.map +1 -1
- package/dist/types/proxy.d.ts.map +1 -1
- package/dist/types/response.d.ts +42 -1
- package/dist/types/response.d.ts.map +1 -1
- package/dist/types/socks5.d.ts.map +1 -1
- package/dist/types/types.d.ts +105 -23
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/url.d.ts +0 -11
- package/dist/types/url.d.ts.map +1 -1
- package/dist/types/utils.d.ts +69 -1
- package/dist/types/utils.d.ts.map +1 -1
- package/dist/types/ws.d.ts +0 -1
- package/dist/types/ws.d.ts.map +1 -1
- package/package.json +10 -4
package/dist/cjs/response.js
CHANGED
|
@@ -296,6 +296,37 @@ function readWithAbort(reader, signal) {
|
|
|
296
296
|
function normaliseOnExceed(mode) {
|
|
297
297
|
return mode === "truncate" || mode === "abort" ? mode : "throw";
|
|
298
298
|
}
|
|
299
|
+
/**
|
|
300
|
+
* The single size-limit policy, shared by the buffering reader and the
|
|
301
|
+
* streaming one.
|
|
302
|
+
*
|
|
303
|
+
* Both had the same four decisions — when to fire `onExceedCallback`, when to
|
|
304
|
+
* throw, how much of the crossing chunk to keep, when to stop — written out
|
|
305
|
+
* twice. They drifted: the streaming version cancelled with `"size limit"` where
|
|
306
|
+
* the buffered one used `"size limit exceeded"`, which is the kind of difference
|
|
307
|
+
* that only matters in a log nobody diffs.
|
|
308
|
+
*
|
|
309
|
+
* `makeError` is supplied by the caller because the two entry points raise
|
|
310
|
+
* different public classes (`ResponseSizeLimitError` here,
|
|
311
|
+
* `SizeLimitError` from `types.ts` via `core.ts`). Both carry the code
|
|
312
|
+
* `"ESIZELIMIT"`, but they are different classes with different prototypes and
|
|
313
|
+
* collapsing them is a breaking change, so each keeps its own.
|
|
314
|
+
*/
|
|
315
|
+
export function limitAction(chunk, bytesRead, limit, onExceed, makeError) {
|
|
316
|
+
const newTotal = bytesRead + chunk.byteLength;
|
|
317
|
+
if (newTotal <= limit.maxBytes)
|
|
318
|
+
return { kind: "keep" };
|
|
319
|
+
limit.onExceedCallback?.(newTotal, limit.maxBytes);
|
|
320
|
+
if (onExceed === "throw")
|
|
321
|
+
throw makeError(newTotal, limit.maxBytes);
|
|
322
|
+
if (onExceed === "truncate") {
|
|
323
|
+
const remaining = limit.maxBytes - bytesRead;
|
|
324
|
+
// `remaining > 0` always holds here: `newTotal > maxBytes` with a
|
|
325
|
+
// non-negative chunk length implies `bytesRead < maxBytes`.
|
|
326
|
+
return { kind: "truncate", chunk: chunk.slice(0, remaining) };
|
|
327
|
+
}
|
|
328
|
+
return { kind: "stop" };
|
|
329
|
+
}
|
|
299
330
|
/**
|
|
300
331
|
* Read a ReadableStream with a hard size limit.
|
|
301
332
|
*
|
|
@@ -328,31 +359,35 @@ export async function readBodyWithLimit(stream, url, limit, signal) {
|
|
|
328
359
|
}
|
|
329
360
|
break;
|
|
330
361
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
limit
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
348
|
-
break;
|
|
349
|
-
}
|
|
362
|
+
let action;
|
|
363
|
+
try {
|
|
364
|
+
action = limitAction(value, bytesRead, limit, onExceed, (n, max) => new ResponseSizeLimitError(n, max, url));
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
await reader.cancel("size limit exceeded");
|
|
368
|
+
throw err;
|
|
369
|
+
}
|
|
370
|
+
if (action.kind === "stop") {
|
|
371
|
+
await reader.cancel("size limit exceeded");
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
if (action.kind === "truncate") {
|
|
375
|
+
chunks.push(action.chunk);
|
|
376
|
+
bytesRead += action.chunk.byteLength;
|
|
377
|
+
break;
|
|
350
378
|
}
|
|
351
379
|
chunks.push(value);
|
|
352
380
|
bytesRead += value.byteLength;
|
|
353
381
|
}
|
|
354
382
|
}
|
|
355
383
|
finally {
|
|
384
|
+
// Cancel before releasing when bailing out, so the unread remainder of the
|
|
385
|
+
// body is discarded rather than left buffered on a released lock. The loop
|
|
386
|
+
// already cancels on every abort it observes; this covers the race where the
|
|
387
|
+
// signal fired between the last read and the loop exiting.
|
|
388
|
+
if (signal?.aborted) {
|
|
389
|
+
await reader.cancel("aborted").catch(() => { });
|
|
390
|
+
}
|
|
356
391
|
reader.releaseLock();
|
|
357
392
|
}
|
|
358
393
|
return concatUint8Arrays(chunks);
|
|
@@ -413,6 +448,13 @@ function detectBOM(bytes) {
|
|
|
413
448
|
* @returns The decoded string.
|
|
414
449
|
* @throws {ResponseDecodeError} If decoding fails even with lenient fallback.
|
|
415
450
|
*/
|
|
451
|
+
/**
|
|
452
|
+
* Decode a response body to a string.
|
|
453
|
+
*
|
|
454
|
+
* `url` is optional: it is only used to name the originating URL on a
|
|
455
|
+
* `ResponseDecodeError`, and requiring every caller to thread one through just
|
|
456
|
+
* to decode a body it already has in hand made the third parameter a formality.
|
|
457
|
+
*/
|
|
416
458
|
export function decodeBody(bytes, charset, url) {
|
|
417
459
|
const detected = detectBOM(bytes);
|
|
418
460
|
const resolved = detected ?? resolveCharset(charset);
|
|
@@ -427,7 +469,7 @@ export function decodeBody(bytes, charset, url) {
|
|
|
427
469
|
return new TextDecoder(resolved, { fatal: false }).decode(data);
|
|
428
470
|
}
|
|
429
471
|
catch (err2) {
|
|
430
|
-
throw new ResponseDecodeError(String(err2), resolved, url);
|
|
472
|
+
throw new ResponseDecodeError(String(err2), resolved, url ?? "<unknown url>");
|
|
431
473
|
}
|
|
432
474
|
}
|
|
433
475
|
}
|
|
@@ -464,6 +506,13 @@ function createSizeLimitedDecompression(stream, encoding, maxDecompressedSize =
|
|
|
464
506
|
*/
|
|
465
507
|
export function decompressStream(stream, encoding) {
|
|
466
508
|
const normalized = encoding.trim().toLowerCase();
|
|
509
|
+
// An empty body carrying a Content-Encoding is an ordinary response — an
|
|
510
|
+
// empty 204, an empty error body — and every decoder here rejects it,
|
|
511
|
+
// `DecompressionStream("gzip")` and node:zlib alike, with `Z_BUF_ERROR:
|
|
512
|
+
// unexpected end of file` because there is no frame to parse. Without this
|
|
513
|
+
// check the client turned a normal empty reply into an exception. zstd
|
|
514
|
+
// happened to tolerate it on Node 22, which made the defect look
|
|
515
|
+
// runtime-specific when it was encoding-independent and had always been there.
|
|
467
516
|
// ── Brotli (br) ──────────────────────────────────────────────────────────
|
|
468
517
|
// WHATWG DecompressionStream does NOT support brotli in any current runtime.
|
|
469
518
|
// On Node.js we use node:zlib.createBrotliDecompress() via a stream adapter.
|
|
@@ -488,17 +537,100 @@ export function decompressStream(stream, encoding) {
|
|
|
488
537
|
// CompressionFormat is a browser type, we normalize to it
|
|
489
538
|
// Type assertion is safe as we validated the encoding above
|
|
490
539
|
const format = normalized;
|
|
491
|
-
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
|
|
540
|
+
// Constructed here, inside this `try`, so an unrecognised format still
|
|
541
|
+
// throws synchronously and falls through to the pass-through below. Built
|
|
542
|
+
// lazily inside the stream it would throw from `start()`, where nothing
|
|
543
|
+
// catches it, and an unknown encoding would surface as an unhandled
|
|
544
|
+
// TypeError instead of the raw bytes.
|
|
545
|
+
const decoder = new DecompressionStream(format);
|
|
546
|
+
return decodeUnlessEmpty(stream, decoder);
|
|
496
547
|
}
|
|
497
548
|
catch {
|
|
498
549
|
// Unsupported format — return raw (caller sent Accept-Encoding conservatively)
|
|
499
550
|
return stream;
|
|
500
551
|
}
|
|
501
552
|
}
|
|
553
|
+
/**
|
|
554
|
+
* Decodes `source`, or yields nothing at all when `source` is empty.
|
|
555
|
+
*
|
|
556
|
+
* `DecompressionStream("gzip")` rejects a zero-byte stream with the same
|
|
557
|
+
* `Z_BUF_ERROR: unexpected end of file` the node:zlib decoders raise, because
|
|
558
|
+
* there is no frame to parse. An empty body carrying `Content-Encoding` is an
|
|
559
|
+
* ordinary response — an empty 204, an empty error body — so it must produce
|
|
560
|
+
* nothing rather than fail. zstd happened to tolerate this on Node 22, which
|
|
561
|
+
* made the defect look runtime-specific when it was encoding-independent.
|
|
562
|
+
*
|
|
563
|
+
* The decoder is constructed on the first non-empty chunk rather than up front,
|
|
564
|
+
* because that is the only point at which emptiness is known and this has to
|
|
565
|
+
* stay synchronous. Only the chunk that was already in flight is held, so a
|
|
566
|
+
* streaming body still streams.
|
|
567
|
+
*/
|
|
568
|
+
function decodeUnlessEmpty(source, decoder) {
|
|
569
|
+
const reader = source.getReader();
|
|
570
|
+
return new ReadableStream({
|
|
571
|
+
async start(controller) {
|
|
572
|
+
// A source may hand over zero-length chunks before its real payload, or
|
|
573
|
+
// instead of one — counting the chunk rather than its bytes would send an
|
|
574
|
+
// empty body to the decoder and get `Z_BUF_ERROR` back. So: read until
|
|
575
|
+
// something with bytes arrives, or the source ends.
|
|
576
|
+
let first = null;
|
|
577
|
+
for (;;) {
|
|
578
|
+
const next = await reader
|
|
579
|
+
.read()
|
|
580
|
+
.catch(() => ({ done: true, value: undefined }));
|
|
581
|
+
if (next.done || next.value.byteLength > 0) {
|
|
582
|
+
first = next;
|
|
583
|
+
break;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (first.done) {
|
|
587
|
+
// Nothing to decode: an empty body carrying a Content-Encoding is an
|
|
588
|
+
// ordinary response, not a malformed one.
|
|
589
|
+
controller.close();
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
// Re-hydrate the peeked chunk in front of the rest of the source, then
|
|
593
|
+
// let `pipeThrough` do the piping. Doing the write/read interleaving by
|
|
594
|
+
// hand deadlocked on backpressure: a TransformStream's `write()` does not
|
|
595
|
+
// settle until its readable queue drains, and nothing was reading yet.
|
|
596
|
+
const rehydrated = new ReadableStream({
|
|
597
|
+
start(c) {
|
|
598
|
+
c.enqueue(first.value);
|
|
599
|
+
},
|
|
600
|
+
async pull(c) {
|
|
601
|
+
const next = await reader.read();
|
|
602
|
+
if (next.done)
|
|
603
|
+
c.close();
|
|
604
|
+
else
|
|
605
|
+
c.enqueue(next.value);
|
|
606
|
+
},
|
|
607
|
+
cancel(reason) {
|
|
608
|
+
void reader.cancel(reason).catch(() => undefined);
|
|
609
|
+
},
|
|
610
|
+
});
|
|
611
|
+
const out = rehydrated.pipeThrough(decoder);
|
|
612
|
+
const pump = out.getReader();
|
|
613
|
+
void (async () => {
|
|
614
|
+
try {
|
|
615
|
+
for (;;) {
|
|
616
|
+
const chunk = await pump.read();
|
|
617
|
+
if (chunk.done) {
|
|
618
|
+
controller.close();
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
controller.enqueue(chunk.value);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
catch (err) {
|
|
625
|
+
controller.error(err);
|
|
626
|
+
}
|
|
627
|
+
})();
|
|
628
|
+
},
|
|
629
|
+
cancel(reason) {
|
|
630
|
+
void reader.cancel(reason).catch(() => undefined);
|
|
631
|
+
},
|
|
632
|
+
});
|
|
633
|
+
}
|
|
502
634
|
/**
|
|
503
635
|
* Decompress a brotli-encoded ReadableStream.
|
|
504
636
|
* Uses node:zlib on Node.js. Returns the stream unchanged on other runtimes.
|
|
@@ -554,6 +686,14 @@ function nodeZlibDecompressStream(stream, create) {
|
|
|
554
686
|
// can land in that window — an immediately-aborted request is the common
|
|
555
687
|
// case, not an edge one. Remember it and apply it once the reader exists.
|
|
556
688
|
let cancelRequested = false;
|
|
689
|
+
// A zero-byte source is a legitimate response — an empty 204, an empty error
|
|
690
|
+
// body — and it carries a Content-Encoding header like any other. Every
|
|
691
|
+
// zlib decoder rejects it (`Z_BUF_ERROR: unexpected end of file`), so
|
|
692
|
+
// without this the client turned an ordinary empty reply into an exception.
|
|
693
|
+
// zstd happened to tolerate it on Node 22 only, which is why this went
|
|
694
|
+
// unnoticed: the failure looked runtime-specific and was actually encoding-
|
|
695
|
+
// independent, and had been there for gzip, deflate and br the whole time.
|
|
696
|
+
let sawBytes = false;
|
|
557
697
|
return new ReadableStream({
|
|
558
698
|
async start(controller) {
|
|
559
699
|
// Dynamic import so non-Node runtimes never load node:zlib
|
|
@@ -589,9 +729,24 @@ function nodeZlibDecompressStream(stream, create) {
|
|
|
589
729
|
while (true) {
|
|
590
730
|
const { done, value } = await reader.read();
|
|
591
731
|
if (done) {
|
|
732
|
+
// Nothing was ever fed to the decoder, so there is no frame to
|
|
733
|
+
// parse and nothing to fail on. Close with what we have: nothing.
|
|
734
|
+
if (!sawBytes) {
|
|
735
|
+
if (!settled) {
|
|
736
|
+
settled = true;
|
|
737
|
+
controller.close();
|
|
738
|
+
}
|
|
739
|
+
br.destroy();
|
|
740
|
+
break;
|
|
741
|
+
}
|
|
592
742
|
br.end();
|
|
593
743
|
break;
|
|
594
744
|
}
|
|
745
|
+
// Count bytes, not chunks: a source may hand over a zero-length
|
|
746
|
+
// chunk and then close, which is still an empty body. Counting the
|
|
747
|
+
// chunk instead let exactly that case reach the decoder and fail.
|
|
748
|
+
if (value.byteLength > 0)
|
|
749
|
+
sawBytes = true;
|
|
595
750
|
br.write(value);
|
|
596
751
|
}
|
|
597
752
|
}
|
|
@@ -1334,9 +1489,9 @@ export function extractServerTiming(headers) {
|
|
|
1334
1489
|
// ============================================================================
|
|
1335
1490
|
// §14 UTILITIES
|
|
1336
1491
|
// ============================================================================
|
|
1337
|
-
|
|
1492
|
+
function readBodyBytes(response, url, options) {
|
|
1338
1493
|
if (!response.body)
|
|
1339
|
-
return new Uint8Array(0);
|
|
1494
|
+
return Promise.resolve(new Uint8Array(0));
|
|
1340
1495
|
const headers = normalizeHeaders(response.headers);
|
|
1341
1496
|
let stream = response.body;
|
|
1342
1497
|
// Decompression
|
|
@@ -1350,33 +1505,11 @@ async function readBodyBytes(response, url, options) {
|
|
|
1350
1505
|
if (options.sizeLimit) {
|
|
1351
1506
|
return readBodyWithLimit(stream, url, options.sizeLimit, options.signal);
|
|
1352
1507
|
}
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
throw new DOMException("Response reading aborted", "AbortError");
|
|
1359
|
-
}
|
|
1360
|
-
const { done, value } = await readWithAbort(reader, options.signal);
|
|
1361
|
-
if (done) {
|
|
1362
|
-
// See readWithAbort: an aborted read also resolves as done.
|
|
1363
|
-
if (options.signal?.aborted) {
|
|
1364
|
-
throw new DOMException("Response reading aborted", "AbortError");
|
|
1365
|
-
}
|
|
1366
|
-
break;
|
|
1367
|
-
}
|
|
1368
|
-
chunks.push(value);
|
|
1369
|
-
}
|
|
1370
|
-
}
|
|
1371
|
-
finally {
|
|
1372
|
-
// Cancel before releasing when we are bailing out, so the unread remainder
|
|
1373
|
-
// of the body is discarded rather than left buffered on a released lock.
|
|
1374
|
-
if (options.signal?.aborted) {
|
|
1375
|
-
await reader.cancel("aborted").catch(() => { });
|
|
1376
|
-
}
|
|
1377
|
-
reader.releaseLock();
|
|
1378
|
-
}
|
|
1379
|
-
return concatUint8Arrays(chunks);
|
|
1508
|
+
// No configured limit is the same read with an unreachable one. `newTotal`
|
|
1509
|
+
// can never exceed `Infinity`, so the limit policy never engages and
|
|
1510
|
+
// `onExceedCallback` never fires — which is exactly the previous behaviour,
|
|
1511
|
+
// minus a second copy of the read loop to keep in step with the first.
|
|
1512
|
+
return readBodyWithLimit(stream, url, { maxBytes: Infinity }, options.signal);
|
|
1380
1513
|
}
|
|
1381
1514
|
function applySizeLimit(stream, url, limit, signal) {
|
|
1382
1515
|
const onExceed = normaliseOnExceed(limit.onExceed);
|
|
@@ -1403,27 +1536,21 @@ function applySizeLimit(stream, url, limit, signal) {
|
|
|
1403
1536
|
controller.close();
|
|
1404
1537
|
break;
|
|
1405
1538
|
}
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
limit
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
if (remaining > 0)
|
|
1422
|
-
controller.enqueue(value.slice(0, remaining));
|
|
1423
|
-
controller.close();
|
|
1424
|
-
await reader.cancel("size limit");
|
|
1425
|
-
return;
|
|
1426
|
-
}
|
|
1539
|
+
let action;
|
|
1540
|
+
try {
|
|
1541
|
+
action = limitAction(value, bytesRead, limit, onExceed, (n, max) => new ResponseSizeLimitError(n, max, url));
|
|
1542
|
+
}
|
|
1543
|
+
catch (err) {
|
|
1544
|
+
await reader.cancel("size limit exceeded");
|
|
1545
|
+
controller.error(err);
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
if (action.kind !== "keep") {
|
|
1549
|
+
if (action.kind === "truncate")
|
|
1550
|
+
controller.enqueue(action.chunk);
|
|
1551
|
+
controller.close();
|
|
1552
|
+
await reader.cancel("size limit exceeded");
|
|
1553
|
+
return;
|
|
1427
1554
|
}
|
|
1428
1555
|
bytesRead += value.byteLength;
|
|
1429
1556
|
controller.enqueue(value);
|
package/dist/cjs/socks5.js
CHANGED
|
@@ -397,6 +397,11 @@ async function withRetry(fn, maxRetries, delayMs) {
|
|
|
397
397
|
}
|
|
398
398
|
throw lastError;
|
|
399
399
|
}
|
|
400
|
+
// Deliberately kept local. This module has no imports at all, so it can be
|
|
401
|
+
// loaded on its own in restricted runtimes where a relative import of
|
|
402
|
+
// `utils.ts` would pull the whole package graph in with it. `sleep(ms)` with
|
|
403
|
+
// no signal is the one shape that cannot diverge from the shared copy — it
|
|
404
|
+
// resolves after `ms` and attaches nothing.
|
|
400
405
|
function sleep(ms) {
|
|
401
406
|
return new Promise((r) => setTimeout(r, ms));
|
|
402
407
|
}
|
package/dist/cjs/utils.js
CHANGED
|
@@ -668,7 +668,7 @@ function isBlockedIPv6(b) {
|
|
|
668
668
|
* @param allowedSchemes - Allowed URL schemes
|
|
669
669
|
* @returns True if URL is safe
|
|
670
670
|
*/
|
|
671
|
-
export function isSafeURL(url, allowedSchemes = ["http", "https"]) {
|
|
671
|
+
export function isSafeURL(url, allowedSchemes = ["http", "https"], options = {}) {
|
|
672
672
|
try {
|
|
673
673
|
const parsed = typeof url === "string" ? new URL(url) : url;
|
|
674
674
|
const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
|
|
@@ -680,25 +680,31 @@ export function isSafeURL(url, allowedSchemes = ["http", "https"]) {
|
|
|
680
680
|
if (FORBIDDEN_SCHEMES.has(scheme)) {
|
|
681
681
|
return false;
|
|
682
682
|
}
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
// Unparseable IPv6 literal → reject defensively
|
|
695
|
-
if (v6 === null || isBlockedIPv6(v6))
|
|
696
|
-
return false;
|
|
697
|
-
}
|
|
698
|
-
else {
|
|
699
|
-
const v4 = parseIPv4Host(host);
|
|
700
|
-
if (v4 !== null && isBlockedIPv4(v4))
|
|
683
|
+
// `allowPrivateNetwork` relaxes *only* the address checks below. Scheme,
|
|
684
|
+
// path traversal, hostname length, URL length and port validation are not
|
|
685
|
+
// about where the socket lands, so they still apply — otherwise opting in to
|
|
686
|
+
// a loopback fixture would also silently opt in to `file:` and `../`.
|
|
687
|
+
if (!options.allowPrivateNetwork) {
|
|
688
|
+
let host = parsed.hostname.toLowerCase();
|
|
689
|
+
const isV6Literal = host.startsWith("[") && host.endsWith("]");
|
|
690
|
+
if (isV6Literal)
|
|
691
|
+
host = host.slice(1, -1);
|
|
692
|
+
// Block loopback hostnames (including *.localhost subdomains)
|
|
693
|
+
if (host === "localhost" || host.endsWith(".localhost") || host === "0.0.0.0")
|
|
701
694
|
return false;
|
|
695
|
+
// Block private/reserved IPs (SSRF prevention) — numeric comparison over
|
|
696
|
+
// expanded bytes so every IPv6 spelling and numeric IPv4 form is covered.
|
|
697
|
+
if (isV6Literal || host.includes(":")) {
|
|
698
|
+
const v6 = parseIPv6Host(host);
|
|
699
|
+
// Unparseable IPv6 literal → reject defensively
|
|
700
|
+
if (v6 === null || isBlockedIPv6(v6))
|
|
701
|
+
return false;
|
|
702
|
+
}
|
|
703
|
+
else {
|
|
704
|
+
const v4 = parseIPv4Host(host);
|
|
705
|
+
if (v4 !== null && isBlockedIPv4(v4))
|
|
706
|
+
return false;
|
|
707
|
+
}
|
|
702
708
|
}
|
|
703
709
|
// Check for suspicious patterns (path traversal)
|
|
704
710
|
if (parsed.hostname.includes("..") || parsed.pathname.includes("..")) {
|
|
@@ -723,6 +729,35 @@ export function isSafeURL(url, allowedSchemes = ["http", "https"]) {
|
|
|
723
729
|
return false;
|
|
724
730
|
}
|
|
725
731
|
}
|
|
732
|
+
/**
|
|
733
|
+
* True when a literal IPv4/IPv6 address falls in a blocked range.
|
|
734
|
+
*
|
|
735
|
+
* The same decision `isSafeURL` makes about a hostname, applied to an address
|
|
736
|
+
* that has already been resolved. Kept separate from `isSafeURL` because the two
|
|
737
|
+
* answer different questions: `isSafeURL` reads what the caller wrote, this
|
|
738
|
+
* reads what the resolver returned. Both are needed — see
|
|
739
|
+
* `createPinnedLookup` in `core.ts`, which is the only thing that stops a
|
|
740
|
+
* hostname that resolves to a private address from being dialled.
|
|
741
|
+
*
|
|
742
|
+
* IPv4 literals are accepted in every form a resolver can return (dotted-quad
|
|
743
|
+
* and numeric); anything unparseable is treated as blocked, so an address this
|
|
744
|
+
* cannot understand fails closed rather than open.
|
|
745
|
+
*/
|
|
746
|
+
export function isBlockedAddress(address) {
|
|
747
|
+
const host = address.trim().toLowerCase();
|
|
748
|
+
if (host.startsWith("[") && host.endsWith("]"))
|
|
749
|
+
return true;
|
|
750
|
+
if (host === "" || host === "localhost" || host.endsWith(".localhost"))
|
|
751
|
+
return true;
|
|
752
|
+
if (host.includes(":")) {
|
|
753
|
+
const v6 = parseIPv6Host(host);
|
|
754
|
+
return v6 === null || isBlockedIPv6(v6);
|
|
755
|
+
}
|
|
756
|
+
// A resolver returns dotted-quad, but `parseIPv4Host` also accepts the
|
|
757
|
+
// numeric spellings, and a non-IP string must not be read as "safe".
|
|
758
|
+
const v4 = parseIPv4Host(host);
|
|
759
|
+
return v4 === null || isBlockedIPv4(v4);
|
|
760
|
+
}
|
|
726
761
|
/**
|
|
727
762
|
* Check if a URL is absolute (has a scheme).
|
|
728
763
|
*
|
|
@@ -868,7 +903,7 @@ export function formatError(error) {
|
|
|
868
903
|
* another, so the function whose entire job is to turn an error into a string
|
|
869
904
|
* threw instead, taking the `catch` that was logging it down with it.
|
|
870
905
|
*/
|
|
871
|
-
function safeStringify(value) {
|
|
906
|
+
export function safeStringify(value, circularMarker = "[Circular]") {
|
|
872
907
|
const seen = new Set();
|
|
873
908
|
try {
|
|
874
909
|
return (JSON.stringify(value, (_key, v) => {
|
|
@@ -876,7 +911,7 @@ function safeStringify(value) {
|
|
|
876
911
|
return v.toString();
|
|
877
912
|
if (typeof v === "object" && v !== null) {
|
|
878
913
|
if (seen.has(v))
|
|
879
|
-
return
|
|
914
|
+
return circularMarker;
|
|
880
915
|
seen.add(v);
|
|
881
916
|
}
|
|
882
917
|
return v;
|
|
@@ -946,12 +981,12 @@ export function perfNow() {
|
|
|
946
981
|
*/
|
|
947
982
|
export function sleep(ms, signal) {
|
|
948
983
|
if (signal?.aborted) {
|
|
949
|
-
return Promise.reject(
|
|
984
|
+
return Promise.reject(abortError());
|
|
950
985
|
}
|
|
951
986
|
return new Promise((resolve, reject) => {
|
|
952
987
|
const onAbort = () => {
|
|
953
988
|
clearTimeout(timer);
|
|
954
|
-
reject(
|
|
989
|
+
reject(abortError());
|
|
955
990
|
};
|
|
956
991
|
const timer = setTimeout(() => {
|
|
957
992
|
signal?.removeEventListener("abort", onAbort); // cleanup on normal completion
|
|
@@ -1017,6 +1052,50 @@ export function toUint8Array(data) {
|
|
|
1017
1052
|
* @param bytes The buffer to encode.
|
|
1018
1053
|
* @returns Base64-encoded string.
|
|
1019
1054
|
*/
|
|
1055
|
+
/**
|
|
1056
|
+
* SHA-256 of a string or byte array, as lowercase hex.
|
|
1057
|
+
*
|
|
1058
|
+
* The `ArrayBuffer` slice is not optional. `crypto.subtle.digest` takes an
|
|
1059
|
+
* `ArrayBuffer`, and a `Uint8Array`'s `.buffer` is the whole backing store —
|
|
1060
|
+
* pass it directly and a view that starts partway into a larger buffer gets
|
|
1061
|
+
* hashed from offset zero, hashing the wrong bytes. Three copies of this
|
|
1062
|
+
* function existed and one of them made exactly that mistake.
|
|
1063
|
+
*/
|
|
1064
|
+
/**
|
|
1065
|
+
* Copy `headers`, replacing the value of any name in `sensitive` with `mask`.
|
|
1066
|
+
*
|
|
1067
|
+
* The comparison is case-insensitive because HTTP header names are, and the
|
|
1068
|
+
* caller's set is folded once here rather than at each call site — which is
|
|
1069
|
+
* what `noUncheckedIndexedAccess` makes expensive elsewhere, where every
|
|
1070
|
+
* consumer ends up writing `headers["x"] ?? headers["X"]` to find a header that
|
|
1071
|
+
* was only ever stored under one spelling.
|
|
1072
|
+
*
|
|
1073
|
+
* `mask` is a parameter rather than a constant because four call sites already
|
|
1074
|
+
* disagreed on it: the logger and the lifecycle hooks wrote `"***"`, the
|
|
1075
|
+
* logging interceptor wrote `"**REDACTED**"`, and the HAR recorder wrote
|
|
1076
|
+
* `"***REDACTED***"`. Those strings are observable in log output, so unifying
|
|
1077
|
+
* them is a behaviour change and is deliberately not made here — but they are
|
|
1078
|
+
* now in one function, so a future decision to converge is a one-line edit.
|
|
1079
|
+
*/
|
|
1080
|
+
/** True when `name` is one of `sensitive`, compared case-insensitively. */
|
|
1081
|
+
export function isSensitiveHeader(name, sensitive) {
|
|
1082
|
+
return sensitive.has(name.toLowerCase());
|
|
1083
|
+
}
|
|
1084
|
+
export function redactHeaders(headers, sensitive, mask) {
|
|
1085
|
+
const out = {};
|
|
1086
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
1087
|
+
out[k] = isSensitiveHeader(k, sensitive) ? mask : v;
|
|
1088
|
+
}
|
|
1089
|
+
return out;
|
|
1090
|
+
}
|
|
1091
|
+
export async function sha256Hex(data) {
|
|
1092
|
+
const raw = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
|
1093
|
+
const buf = raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength);
|
|
1094
|
+
const hash = await crypto.subtle.digest("SHA-256", buf);
|
|
1095
|
+
return Array.from(new Uint8Array(hash))
|
|
1096
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
1097
|
+
.join("");
|
|
1098
|
+
}
|
|
1020
1099
|
export function uint8ArrayToBase64(bytes) {
|
|
1021
1100
|
// Process in 3-byte (24-bit) aligned chunks to avoid padding issues mid-stream.
|
|
1022
1101
|
// 3 × 2¹³ = 24 576 bytes per chunk — keeps individual strings small.
|
|
@@ -1094,7 +1173,7 @@ export function mergeSignals(...signals) {
|
|
|
1094
1173
|
const abort = () => {
|
|
1095
1174
|
for (const s of validSignals)
|
|
1096
1175
|
s.removeEventListener("abort", abort);
|
|
1097
|
-
controller.abort(
|
|
1176
|
+
controller.abort(abortError());
|
|
1098
1177
|
};
|
|
1099
1178
|
for (const s of validSignals)
|
|
1100
1179
|
s.addEventListener("abort", abort, { once: true });
|
|
@@ -1149,11 +1228,20 @@ export function randomBytes(byteCount) {
|
|
|
1149
1228
|
.join("");
|
|
1150
1229
|
}
|
|
1151
1230
|
// Cross-runtime error creation: DOMException may not exist in all runtimes.
|
|
1152
|
-
|
|
1231
|
+
/**
|
|
1232
|
+
* A platform-shaped abort rejection: a `DOMException` named `AbortError` where
|
|
1233
|
+
* one exists, and a plain `Error` carrying the same name where it does not.
|
|
1234
|
+
*
|
|
1235
|
+
* `message` is a parameter because two callers need different text for the same
|
|
1236
|
+
* event, and they had each written this function. It is deliberately not
|
|
1237
|
+
* `AbortError` from `types.ts`: that is a `KinetexError` with code `EABORT`, a
|
|
1238
|
+
* different and equally deliberate contract used by the request pipeline.
|
|
1239
|
+
*/
|
|
1240
|
+
export function abortError(message = "Aborted") {
|
|
1153
1241
|
if (typeof DOMException !== "undefined") {
|
|
1154
|
-
return new DOMException(
|
|
1242
|
+
return new DOMException(message, "AbortError");
|
|
1155
1243
|
}
|
|
1156
|
-
const err = new Error(
|
|
1244
|
+
const err = new Error(message);
|
|
1157
1245
|
err.name = "AbortError";
|
|
1158
1246
|
return err;
|
|
1159
1247
|
}
|
package/dist/cjs/ws.js
CHANGED
|
@@ -19,11 +19,40 @@
|
|
|
19
19
|
*/
|
|
20
20
|
// FIX (H6): remote WS payloads are untrusted — parsed JSON dispatched to
|
|
21
21
|
// listeners is stripped of prototype-pollution keys (see sanitizeParsedJSON).
|
|
22
|
-
import { sanitizeParsedJSON } from "./utils.js";
|
|
22
|
+
import { sanitizeParsedJSON, isValidHeaderName, isValidHeaderValue } from "./utils.js";
|
|
23
23
|
// ============================================================================
|
|
24
24
|
// §2 ERRORS
|
|
25
25
|
// ============================================================================
|
|
26
26
|
/** WebSocket connection or protocol error. */
|
|
27
|
+
/**
|
|
28
|
+
* Reject a handshake header that cannot be represented as one.
|
|
29
|
+
*
|
|
30
|
+
* `client.ws()` builds an `authorization` header straight from the configured
|
|
31
|
+
* bearer token or API key and hands it to a `WSClient`, and `WSClient` applies
|
|
32
|
+
* it through the runtime's `setHeader` — with no validation anywhere on that
|
|
33
|
+
* path. The HTTP pipeline does validate: `mergeHeaders` runs
|
|
34
|
+
* `isValidHeaderName` / `isValidHeaderValue` and refuses control characters. A
|
|
35
|
+
* WebSocket configured through `client.ws()` therefore skipped the one check
|
|
36
|
+
* that stops a token containing `\r\n` from splitting into a second header on
|
|
37
|
+
* the handshake.
|
|
38
|
+
*
|
|
39
|
+
* Checked in the constructor rather than at connect time so the error names the
|
|
40
|
+
* configuration that is wrong, and so it cannot be reached by adding a header
|
|
41
|
+
* later — `_headers` is assigned once and never mutated.
|
|
42
|
+
*/
|
|
43
|
+
function validateHandshakeHeaders(headers) {
|
|
44
|
+
if (!headers)
|
|
45
|
+
return;
|
|
46
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
47
|
+
if (!isValidHeaderName(name)) {
|
|
48
|
+
throw new WSError(`Invalid WebSocket handshake header name: ${JSON.stringify(name)}`);
|
|
49
|
+
}
|
|
50
|
+
if (!isValidHeaderValue(value)) {
|
|
51
|
+
throw new WSError(`Invalid value for WebSocket handshake header ${JSON.stringify(name)}: ` +
|
|
52
|
+
"must be a string of printable characters with no control characters");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
27
56
|
export class WSError extends Error {
|
|
28
57
|
/** Machine-readable error code for WebSocket errors. */
|
|
29
58
|
code = "EWSCONNECT";
|
|
@@ -191,6 +220,7 @@ export class WSClient {
|
|
|
191
220
|
this._url = cfg.url;
|
|
192
221
|
this._protocols = cfg.protocols;
|
|
193
222
|
this._headers = cfg.headers;
|
|
223
|
+
validateHandshakeHeaders(cfg.headers);
|
|
194
224
|
this._maxReconnects = cfg.maxReconnects ?? 10;
|
|
195
225
|
this._reconnectBaseMs = cfg.reconnectBaseMs ?? 1_000;
|
|
196
226
|
this._reconnectMaxMs = cfg.reconnectMaxMs ?? 30_000;
|
|
@@ -990,7 +1020,18 @@ export class WSClient {
|
|
|
990
1020
|
}
|
|
991
1021
|
/** Buffer an outgoing message and track bytes for backpressure. */
|
|
992
1022
|
_bufferMessage(data, byteLen) {
|
|
993
|
-
if (this._bufferMessages
|
|
1023
|
+
if (!this._bufferMessages) {
|
|
1024
|
+
// With buffering switched off, a send made while the socket is not OPEN
|
|
1025
|
+
// had nowhere to go: the buffer branch was skipped and nothing was sent.
|
|
1026
|
+
// `send()` returns void, so there was no return value to inspect either
|
|
1027
|
+
// — the message vanished with no error and no callback, which is data
|
|
1028
|
+
// loss the application cannot distinguish from a delivered message.
|
|
1029
|
+
if (!this._aborted) {
|
|
1030
|
+
this._cbError?.(new WSError(`WebSocket message dropped: the socket is not open and bufferMessages is disabled (${byteLen} bytes). Reconnect, or enable bufferMessages to queue sends made while reconnecting.`));
|
|
1031
|
+
}
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
if (!this._aborted) {
|
|
994
1035
|
const evicted = this._buffer.length >= this._maxBufferSize ? this._buffer.shift() : undefined;
|
|
995
1036
|
if (evicted !== undefined) {
|
|
996
1037
|
this._bufferedBytes -=
|