kinetex 1.2.0 → 1.4.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 +1164 -453
- package/dist/browser/kinetex.esm.js +38 -22
- package/dist/browser/kinetex.js +3127 -715
- package/dist/browser/kinetex.min.js +38 -22
- package/dist/cjs/aws-sigv4.js +137 -20
- package/dist/cjs/cache.js +101 -21
- package/dist/cjs/circuit-breaker.js +69 -7
- package/dist/cjs/client.js +838 -191
- package/dist/cjs/cookie-parser.js +110 -9
- package/dist/cjs/cookie-store.js +141 -36
- package/dist/cjs/core.js +501 -63
- package/dist/cjs/dedup.js +58 -18
- package/dist/cjs/digest.js +185 -23
- package/dist/cjs/graphql.js +164 -24
- package/dist/cjs/headers.js +362 -48
- package/dist/cjs/interceptors.js +285 -29
- package/dist/cjs/lifecycle.js +89 -40
- package/dist/cjs/logging.js +169 -16
- package/dist/cjs/mod.js +3 -2
- package/dist/cjs/pagination.js +261 -28
- package/dist/cjs/progress.js +282 -52
- package/dist/cjs/proxy.js +412 -0
- package/dist/cjs/response.js +316 -47
- package/dist/cjs/socks5.js +167 -36
- package/dist/cjs/sse.js +201 -34
- package/dist/cjs/url.js +191 -45
- package/dist/cjs/utils.js +222 -48
- package/dist/cjs/worker.js +6 -6
- package/dist/cjs/ws.js +32 -16
- package/dist/esm/aws-sigv4.js +137 -20
- package/dist/esm/aws-sigv4.js.map +1 -1
- package/dist/esm/cache.js +101 -21
- package/dist/esm/cache.js.map +1 -1
- package/dist/esm/circuit-breaker.js +69 -7
- package/dist/esm/circuit-breaker.js.map +1 -1
- package/dist/esm/client.js +838 -191
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/cookie-parser.js +110 -9
- package/dist/esm/cookie-parser.js.map +1 -1
- package/dist/esm/cookie-store.js +141 -36
- package/dist/esm/cookie-store.js.map +1 -1
- package/dist/esm/core.js +501 -63
- package/dist/esm/core.js.map +1 -1
- package/dist/esm/dedup.js +58 -18
- package/dist/esm/dedup.js.map +1 -1
- package/dist/esm/digest.js +185 -23
- package/dist/esm/digest.js.map +1 -1
- package/dist/esm/graphql.js +164 -24
- package/dist/esm/graphql.js.map +1 -1
- package/dist/esm/headers.js +362 -48
- package/dist/esm/headers.js.map +1 -1
- package/dist/esm/interceptors.js +285 -29
- package/dist/esm/interceptors.js.map +1 -1
- package/dist/esm/lifecycle.js +89 -40
- package/dist/esm/lifecycle.js.map +1 -1
- package/dist/esm/logging.js +169 -16
- package/dist/esm/logging.js.map +1 -1
- package/dist/esm/mod.js +3 -2
- package/dist/esm/mod.js.map +1 -1
- package/dist/esm/pagination.js +261 -28
- package/dist/esm/pagination.js.map +1 -1
- package/dist/esm/progress.js +282 -52
- package/dist/esm/progress.js.map +1 -1
- package/dist/esm/proxy.js +413 -0
- package/dist/esm/proxy.js.map +1 -0
- package/dist/esm/response.js +316 -47
- package/dist/esm/response.js.map +1 -1
- package/dist/esm/socks5.js +167 -36
- package/dist/esm/socks5.js.map +1 -1
- package/dist/esm/sse.js +201 -34
- package/dist/esm/sse.js.map +1 -1
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/url.js +191 -45
- package/dist/esm/url.js.map +1 -1
- package/dist/esm/utils.js +222 -48
- package/dist/esm/utils.js.map +1 -1
- package/dist/esm/worker.js +6 -6
- package/dist/esm/worker.js.map +1 -1
- package/dist/esm/ws.js +32 -16
- package/dist/esm/ws.js.map +1 -1
- package/dist/types/aws-sigv4.d.ts.map +1 -1
- package/dist/types/cache.d.ts +27 -2
- package/dist/types/cache.d.ts.map +1 -1
- package/dist/types/circuit-breaker.d.ts +14 -1
- package/dist/types/circuit-breaker.d.ts.map +1 -1
- package/dist/types/client.d.ts +98 -23
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/cookie-parser.d.ts +0 -17
- package/dist/types/cookie-parser.d.ts.map +1 -1
- package/dist/types/cookie-store.d.ts.map +1 -1
- package/dist/types/core.d.ts +109 -25
- package/dist/types/core.d.ts.map +1 -1
- package/dist/types/dedup.d.ts +0 -7
- package/dist/types/dedup.d.ts.map +1 -1
- package/dist/types/digest.d.ts +31 -37
- package/dist/types/digest.d.ts.map +1 -1
- package/dist/types/graphql.d.ts.map +1 -1
- package/dist/types/headers.d.ts +62 -29
- package/dist/types/headers.d.ts.map +1 -1
- package/dist/types/interceptors.d.ts +102 -0
- package/dist/types/interceptors.d.ts.map +1 -1
- package/dist/types/lifecycle.d.ts +19 -2
- package/dist/types/lifecycle.d.ts.map +1 -1
- package/dist/types/logging.d.ts +23 -4
- package/dist/types/logging.d.ts.map +1 -1
- package/dist/types/mod.d.ts +5 -3
- package/dist/types/mod.d.ts.map +1 -1
- package/dist/types/pagination.d.ts +0 -25
- package/dist/types/pagination.d.ts.map +1 -1
- package/dist/types/progress.d.ts +1 -1
- package/dist/types/progress.d.ts.map +1 -1
- package/dist/types/proxy.d.ts +50 -0
- package/dist/types/proxy.d.ts.map +1 -0
- package/dist/types/response.d.ts +7 -1
- package/dist/types/response.d.ts.map +1 -1
- package/dist/types/socks5.d.ts.map +1 -1
- package/dist/types/sse.d.ts.map +1 -1
- package/dist/types/types.d.ts +139 -5
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/url.d.ts +0 -14
- package/dist/types/url.d.ts.map +1 -1
- package/dist/types/utils.d.ts.map +1 -1
- package/dist/types/worker.d.ts +6 -6
- package/dist/types/worker.d.ts.map +1 -1
- package/dist/types/ws.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/esm/response.js
CHANGED
|
@@ -57,9 +57,15 @@ export class HTTPResponseError extends Error {
|
|
|
57
57
|
get isClientError() {
|
|
58
58
|
return this.status >= 400 && this.status < 500;
|
|
59
59
|
}
|
|
60
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* Returns true if the status code is a server error (5xx).
|
|
62
|
+
*
|
|
63
|
+
* Bounded above, like `isClientError` is at both ends. Unbounded, any
|
|
64
|
+
* status at or past 600 — a non-standard code, or anything a caller invents
|
|
65
|
+
* — reported itself as a server error.
|
|
66
|
+
*/
|
|
61
67
|
get isServerError() {
|
|
62
|
-
return this.status >= 500;
|
|
68
|
+
return this.status >= 500 && this.status < 600;
|
|
63
69
|
}
|
|
64
70
|
/** Returns true if the status is 404 Not Found. */
|
|
65
71
|
get isNotFound() {
|
|
@@ -240,6 +246,56 @@ export function isBinary(contentType) {
|
|
|
240
246
|
// ============================================================================
|
|
241
247
|
// §5 SIZE-LIMITED BODY READER
|
|
242
248
|
// ============================================================================
|
|
249
|
+
/**
|
|
250
|
+
* One `reader.read()` that also settles when the signal aborts.
|
|
251
|
+
*
|
|
252
|
+
* Checking `signal.aborted` at the top of the read loop is not enough, because
|
|
253
|
+
* the loop only gets another turn when the source produces a chunk or closes.
|
|
254
|
+
* A connection that stalls after its last byte leaves the read pending
|
|
255
|
+
* forever, so the abort was never observed and the promise never settled at
|
|
256
|
+
* all — `readText`/`readJSON`/`readBytes` simply hung, which is the one thing
|
|
257
|
+
* a signal exists to prevent. Racing the signal against the read is what makes
|
|
258
|
+
* an abort actually abort.
|
|
259
|
+
*
|
|
260
|
+
* An aborted read resolves as `{ done: true }` rather than rejecting, so each
|
|
261
|
+
* caller keeps its own established behaviour for an abort: the collecting
|
|
262
|
+
* readers re-check the signal and throw, while the streaming parsers stop
|
|
263
|
+
* iterating. `cancel()` is still issued so the source is released.
|
|
264
|
+
*/
|
|
265
|
+
function readWithAbort(reader, signal) {
|
|
266
|
+
if (!signal)
|
|
267
|
+
return reader.read();
|
|
268
|
+
if (signal.aborted)
|
|
269
|
+
return Promise.resolve({ done: true, value: undefined });
|
|
270
|
+
return new Promise((resolve, reject) => {
|
|
271
|
+
const onAbort = () => {
|
|
272
|
+
void reader.cancel("aborted").catch(() => { });
|
|
273
|
+
resolve({ done: true, value: undefined });
|
|
274
|
+
};
|
|
275
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
276
|
+
reader
|
|
277
|
+
.read()
|
|
278
|
+
.then(resolve, reject)
|
|
279
|
+
.finally(() => {
|
|
280
|
+
signal.removeEventListener("abort", onAbort);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Normalise the `onExceed` mode, failing closed.
|
|
286
|
+
*
|
|
287
|
+
* The three modes were tested as a chain of `if`s that each ended in a
|
|
288
|
+
* `break`/`return`. A value matching none of them therefore fell straight
|
|
289
|
+
* through to the ordinary "push the chunk" line below and the size limit did
|
|
290
|
+
* nothing at all: `onExceed: "ignore"` against a 10-byte cap returned all
|
|
291
|
+
* 1000 bytes, through both `readBodyWithLimit` and `readText`. That is a
|
|
292
|
+
* fail-open on the guard this option exists to provide, and the config is a
|
|
293
|
+
* plain interface, so a value read from JSON or asserted at a call site
|
|
294
|
+
* reaches it. An unrecognised mode is treated as `"throw"`.
|
|
295
|
+
*/
|
|
296
|
+
function normaliseOnExceed(mode) {
|
|
297
|
+
return mode === "truncate" || mode === "abort" ? mode : "throw";
|
|
298
|
+
}
|
|
243
299
|
/**
|
|
244
300
|
* Read a ReadableStream with a hard size limit.
|
|
245
301
|
*
|
|
@@ -252,7 +308,7 @@ export function isBinary(contentType) {
|
|
|
252
308
|
* @throws {DOMException} If the signal fires.
|
|
253
309
|
*/
|
|
254
310
|
export async function readBodyWithLimit(stream, url, limit, signal) {
|
|
255
|
-
const onExceed = limit.onExceed
|
|
311
|
+
const onExceed = normaliseOnExceed(limit.onExceed);
|
|
256
312
|
const reader = stream.getReader();
|
|
257
313
|
const chunks = [];
|
|
258
314
|
let bytesRead = 0;
|
|
@@ -262,9 +318,16 @@ export async function readBodyWithLimit(stream, url, limit, signal) {
|
|
|
262
318
|
await reader.cancel("aborted");
|
|
263
319
|
throw new DOMException("Response reading aborted", "AbortError");
|
|
264
320
|
}
|
|
265
|
-
const { done, value } = await reader
|
|
266
|
-
if (done)
|
|
321
|
+
const { done, value } = await readWithAbort(reader, signal);
|
|
322
|
+
if (done) {
|
|
323
|
+
// `done` is also how an aborted read settles (see readWithAbort), so
|
|
324
|
+
// the signal has to be re-checked here or a partial body would be
|
|
325
|
+
// returned as a complete one.
|
|
326
|
+
if (signal?.aborted) {
|
|
327
|
+
throw new DOMException("Response reading aborted", "AbortError");
|
|
328
|
+
}
|
|
267
329
|
break;
|
|
330
|
+
}
|
|
268
331
|
const newTotal = bytesRead + value.byteLength;
|
|
269
332
|
if (newTotal > limit.maxBytes) {
|
|
270
333
|
limit.onExceedCallback?.(newTotal, limit.maxBytes);
|
|
@@ -314,7 +377,20 @@ const CHARSET_ALIASES = {
|
|
|
314
377
|
function resolveCharset(charset) {
|
|
315
378
|
if (!charset)
|
|
316
379
|
return "utf-8";
|
|
317
|
-
|
|
380
|
+
const key = charset.toLowerCase().trim();
|
|
381
|
+
// Own-property check, not a bare index. `CHARSET_ALIASES` is a plain object
|
|
382
|
+
// literal, so an unrecognised charset naming an `Object.prototype` member
|
|
383
|
+
// resolved to that member instead of falling back: `charset=constructor`
|
|
384
|
+
// reached `TextDecoder` as `function Object() { [native code] }` and
|
|
385
|
+
// `charset=__proto__` as `[object Object]`, either of which throws
|
|
386
|
+
// ERR_ENCODING_NOT_SUPPORTED and fails the whole body with a decode error
|
|
387
|
+
// naming a charset nobody sent. The charset is attacker-controlled — it
|
|
388
|
+
// comes straight off a response header — so that is a denial of service on
|
|
389
|
+
// body parsing, and `__proto__` is the kind of value a scanner fuzzes.
|
|
390
|
+
if (Object.prototype.hasOwnProperty.call(CHARSET_ALIASES, key)) {
|
|
391
|
+
return CHARSET_ALIASES[key];
|
|
392
|
+
}
|
|
393
|
+
return "utf-8";
|
|
318
394
|
}
|
|
319
395
|
/**
|
|
320
396
|
* Detect charset from BOM (byte order mark) in the body.
|
|
@@ -397,6 +473,14 @@ export function decompressStream(stream, encoding) {
|
|
|
397
473
|
if (normalized === "br") {
|
|
398
474
|
return brotliDecompressStream(stream);
|
|
399
475
|
}
|
|
476
|
+
// ── zstd ─────────────────────────────────────────────────────────────────
|
|
477
|
+
// `DecompressionStream` has no "zstd" format in Node or the browsers as of
|
|
478
|
+
// this writing, but does in Bun and newer edge runtimes. Prefer it when
|
|
479
|
+
// present, otherwise fall back to node:zlib on Node 22+, and only then give
|
|
480
|
+
// up and pass the bytes through.
|
|
481
|
+
if (normalized === "zstd") {
|
|
482
|
+
return zstdDecompressStream(stream);
|
|
483
|
+
}
|
|
400
484
|
// ── gzip / deflate ────────────────────────────────────────────────────────
|
|
401
485
|
if (typeof DecompressionStream === "undefined")
|
|
402
486
|
return stream;
|
|
@@ -420,34 +504,87 @@ export function decompressStream(stream, encoding) {
|
|
|
420
504
|
* Uses node:zlib on Node.js. Returns the stream unchanged on other runtimes.
|
|
421
505
|
*/
|
|
422
506
|
function brotliDecompressStream(stream) {
|
|
423
|
-
//
|
|
424
|
-
|
|
507
|
+
// Non-Node runtime: we cannot decompress brotli natively. Return raw bytes —
|
|
508
|
+
// this will likely cause a parse error downstream, which is the correct
|
|
509
|
+
// behaviour (the server shouldn't have sent br in the first place).
|
|
510
|
+
if (!isNodeRuntime())
|
|
511
|
+
return stream;
|
|
512
|
+
return nodeZlibDecompressStream(stream, (zlib) => zlib.createBrotliDecompress());
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* True when running on Node.js.
|
|
516
|
+
*
|
|
517
|
+
* Read defensively: `process` does not exist in edge runtimes and reading it
|
|
518
|
+
* through a hostile global can throw.
|
|
519
|
+
*/
|
|
520
|
+
function isNodeRuntime() {
|
|
425
521
|
try {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
?.node === "string";
|
|
522
|
+
return (typeof globalThis.process?.versions
|
|
523
|
+
?.node === "string");
|
|
429
524
|
}
|
|
430
525
|
catch {
|
|
431
|
-
|
|
432
|
-
}
|
|
433
|
-
if (!isNode) {
|
|
434
|
-
// Non-Node runtime: we cannot decompress brotli natively.
|
|
435
|
-
// Return raw bytes — this will likely cause a parse error downstream,
|
|
436
|
-
// which is the correct behaviour (server shouldn't have sent br).
|
|
437
|
-
return stream;
|
|
526
|
+
return false;
|
|
438
527
|
}
|
|
439
|
-
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Pipe a web ReadableStream through a `node:zlib` decompressor.
|
|
531
|
+
*
|
|
532
|
+
* @param stream - Compressed input.
|
|
533
|
+
* @param create - Builds the zlib transform from the imported module.
|
|
534
|
+
* @returns The decompressed stream.
|
|
535
|
+
*/
|
|
536
|
+
function nodeZlibDecompressStream(stream, create) {
|
|
537
|
+
// The node stream and the read loop can both try to settle the controller,
|
|
538
|
+
// and `cancel()` settles it too. Whichever arrives second would throw
|
|
539
|
+
// ERR_INVALID_STATE on an already closed controller — an unhandled throw
|
|
540
|
+
// that takes the whole process down, not just the request. Two reachable
|
|
541
|
+
// ways to get there:
|
|
542
|
+
// * an upstream error rejects `reader.read()`, we error the controller,
|
|
543
|
+
// and the `br.destroy()` below makes node emit "end" afterwards;
|
|
544
|
+
// * the consumer cancels (an aborted request), and cancelling the source
|
|
545
|
+
// makes zlib drain and emit "end" against a closed controller.
|
|
546
|
+
// Neither is exotic — a server aborting a response mid-body, or a client
|
|
547
|
+
// cancelling one, is ordinary traffic on any content-encoding response.
|
|
548
|
+
let settled = false;
|
|
549
|
+
// Hoisted so `cancel()` can reach it: the bridge holds a reader lock for the
|
|
550
|
+
// whole stream, and `ReadableStream.cancel()` rejects with ERR_INVALID_STATE
|
|
551
|
+
// on a locked stream. `reader.cancel()` is the supported way to abort it.
|
|
552
|
+
let reader = null;
|
|
553
|
+
// `start()` awaits the dynamic import before locking the source, so a cancel
|
|
554
|
+
// can land in that window — an immediately-aborted request is the common
|
|
555
|
+
// case, not an edge one. Remember it and apply it once the reader exists.
|
|
556
|
+
let cancelRequested = false;
|
|
440
557
|
return new ReadableStream({
|
|
441
558
|
async start(controller) {
|
|
442
559
|
// Dynamic import so non-Node runtimes never load node:zlib
|
|
443
560
|
const zlib = await import("node:zlib");
|
|
444
|
-
const br = zlib
|
|
445
|
-
|
|
561
|
+
const br = create(zlib);
|
|
562
|
+
reader = stream.getReader();
|
|
563
|
+
if (cancelRequested) {
|
|
564
|
+
// The source may already be gone; there is nothing left to release.
|
|
565
|
+
void reader.cancel().catch(() => undefined);
|
|
566
|
+
reader.releaseLock();
|
|
567
|
+
reader = null;
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
const settleError = (e) => {
|
|
571
|
+
if (settled)
|
|
572
|
+
return;
|
|
573
|
+
settled = true;
|
|
574
|
+
controller.error(e);
|
|
575
|
+
};
|
|
446
576
|
br.on("data", (chunk) => {
|
|
577
|
+
if (settled)
|
|
578
|
+
return;
|
|
447
579
|
controller.enqueue(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength));
|
|
448
580
|
});
|
|
449
|
-
br.on("end", () =>
|
|
450
|
-
|
|
581
|
+
br.on("end", () => {
|
|
582
|
+
if (settled)
|
|
583
|
+
return;
|
|
584
|
+
settled = true;
|
|
585
|
+
controller.close();
|
|
586
|
+
});
|
|
587
|
+
br.on("error", settleError);
|
|
451
588
|
try {
|
|
452
589
|
while (true) {
|
|
453
590
|
const { done, value } = await reader.read();
|
|
@@ -460,17 +597,57 @@ function brotliDecompressStream(stream) {
|
|
|
460
597
|
}
|
|
461
598
|
catch (e) {
|
|
462
599
|
br.destroy(e instanceof Error ? e : new Error(String(e)));
|
|
463
|
-
|
|
600
|
+
settleError(e);
|
|
464
601
|
}
|
|
465
602
|
finally {
|
|
466
603
|
reader.releaseLock();
|
|
604
|
+
reader = null;
|
|
467
605
|
}
|
|
468
606
|
},
|
|
469
607
|
cancel() {
|
|
470
|
-
|
|
608
|
+
// Mark settled first: cancelling the reader resolves the loop's pending
|
|
609
|
+
// read as `done`, which ends the zlib transform and emits "end" against
|
|
610
|
+
// an already-closed controller.
|
|
611
|
+
settled = true;
|
|
612
|
+
cancelRequested = true;
|
|
613
|
+
void reader?.cancel().catch(() => undefined);
|
|
471
614
|
},
|
|
472
615
|
});
|
|
473
616
|
}
|
|
617
|
+
/**
|
|
618
|
+
* Decompress a zstd-encoded ReadableStream.
|
|
619
|
+
*
|
|
620
|
+
* Tries, in order: the standard `DecompressionStream("zstd")` (Bun, newer edge
|
|
621
|
+
* runtimes), then `node:zlib`'s `createZstdDecompress` (Node 22+), then passes
|
|
622
|
+
* the bytes through unchanged. That last case is a genuine failure mode rather
|
|
623
|
+
* than a formality — kinetex does not advertise `zstd` in its default
|
|
624
|
+
* `accept-encoding`, so a server should never choose it, but a caller that sets
|
|
625
|
+
* the header explicitly (or proxies a response through) can still hit it.
|
|
626
|
+
*/
|
|
627
|
+
function zstdDecompressStream(stream) {
|
|
628
|
+
if (typeof DecompressionStream !== "undefined") {
|
|
629
|
+
try {
|
|
630
|
+
// "zstd" is a real CompressionFormat at runtime but is not yet in the
|
|
631
|
+
// TypeScript lib union, so it is asserted here rather than widened.
|
|
632
|
+
const Ctor = DecompressionStream;
|
|
633
|
+
return stream.pipeThrough(new Ctor("zstd"));
|
|
634
|
+
}
|
|
635
|
+
catch {
|
|
636
|
+
// Format not recognised by this runtime — try node:zlib below.
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
if (!isNodeRuntime())
|
|
640
|
+
return stream;
|
|
641
|
+
// node:zlib gained zstd in Node 22; older versions have no such export.
|
|
642
|
+
return nodeZlibDecompressStream(stream, (zlib) => {
|
|
643
|
+
const create = zlib
|
|
644
|
+
.createZstdDecompress;
|
|
645
|
+
if (typeof create !== "function") {
|
|
646
|
+
throw new Error("zstd decompression is not available in this Node.js version");
|
|
647
|
+
}
|
|
648
|
+
return create();
|
|
649
|
+
});
|
|
650
|
+
}
|
|
474
651
|
/**
|
|
475
652
|
* Detect and apply decompression based on Content-Encoding header.
|
|
476
653
|
*
|
|
@@ -620,7 +797,7 @@ export async function* readNDJSON(response, options = {}) {
|
|
|
620
797
|
while (true) {
|
|
621
798
|
if (options.signal?.aborted)
|
|
622
799
|
break;
|
|
623
|
-
const { done, value } = await reader.
|
|
800
|
+
const { done, value } = await readWithAbort(reader, options.signal);
|
|
624
801
|
if (done) {
|
|
625
802
|
// Process remaining buffer
|
|
626
803
|
const trimmed = buffer.trim();
|
|
@@ -684,6 +861,12 @@ export async function* readJSONStream(response, options = {}) {
|
|
|
684
861
|
let depth = 0;
|
|
685
862
|
let buffer = "";
|
|
686
863
|
let startIdx = -1;
|
|
864
|
+
// JSON string state for the scan below. Braces and brackets inside a string
|
|
865
|
+
// are DATA: the counter closed on a lone `}` in a value, cut the object in
|
|
866
|
+
// half, and reported both halves as parse errors — so the object was
|
|
867
|
+
// silently dropped. `{"note":"use } to close"}` yielded nothing at all.
|
|
868
|
+
let inString = false;
|
|
869
|
+
let escaped = false;
|
|
687
870
|
const flushBuffer = () => {
|
|
688
871
|
if (startIdx === -1)
|
|
689
872
|
return null;
|
|
@@ -695,7 +878,7 @@ export async function* readJSONStream(response, options = {}) {
|
|
|
695
878
|
while (true) {
|
|
696
879
|
if (options.signal?.aborted)
|
|
697
880
|
break;
|
|
698
|
-
const { done, value } = await reader.
|
|
881
|
+
const { done, value } = await readWithAbort(reader, options.signal);
|
|
699
882
|
if (done) {
|
|
700
883
|
// Try to parse remaining content as final object
|
|
701
884
|
const remaining = flushBuffer();
|
|
@@ -714,6 +897,19 @@ export async function* readJSONStream(response, options = {}) {
|
|
|
714
897
|
buffer += decoder.decode(value, { stream: true });
|
|
715
898
|
for (let i = 0; i < buffer.length; i++) {
|
|
716
899
|
const char = buffer[i];
|
|
900
|
+
if (inString) {
|
|
901
|
+
if (escaped)
|
|
902
|
+
escaped = false;
|
|
903
|
+
else if (char === "\\")
|
|
904
|
+
escaped = true;
|
|
905
|
+
else if (char === '"')
|
|
906
|
+
inString = false;
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
if (char === '"') {
|
|
910
|
+
inString = true;
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
717
913
|
if (char === "{" || char === "[") {
|
|
718
914
|
if (depth === 0)
|
|
719
915
|
startIdx = i;
|
|
@@ -732,6 +928,12 @@ export async function* readJSONStream(response, options = {}) {
|
|
|
732
928
|
options.onParseError?.(err, objStr);
|
|
733
929
|
}
|
|
734
930
|
buffer = buffer.slice(i + 1);
|
|
931
|
+
// The consumed object's index must not survive into the next
|
|
932
|
+
// object: the buffer was resliced underneath it, and a stale
|
|
933
|
+
// startIdx is an offset into a string it no longer indexes.
|
|
934
|
+
startIdx = -1;
|
|
935
|
+
inString = false;
|
|
936
|
+
escaped = false;
|
|
735
937
|
i = -1;
|
|
736
938
|
}
|
|
737
939
|
}
|
|
@@ -843,7 +1045,9 @@ export class ResponseCache {
|
|
|
843
1045
|
const entry = this.cache.get(key);
|
|
844
1046
|
if (!entry)
|
|
845
1047
|
return null;
|
|
846
|
-
|
|
1048
|
+
// `>=`, not `>`: an entry is expired AT its expiry instant, so
|
|
1049
|
+
// `ttlMs: 0` served a cached response instead of expiring immediately.
|
|
1050
|
+
if (Date.now() >= entry.expiresAt) {
|
|
847
1051
|
this.cache.delete(key);
|
|
848
1052
|
return null;
|
|
849
1053
|
}
|
|
@@ -892,14 +1096,20 @@ export async function diffResponses(a, b) {
|
|
|
892
1096
|
const headersChanged = {};
|
|
893
1097
|
const headersAdded = {};
|
|
894
1098
|
const headersRemoved = {};
|
|
1099
|
+
// Own-property tests, not `in`. `in` walks the prototype chain, and these
|
|
1100
|
+
// are plain object literals, so a header whose lowercased name is an
|
|
1101
|
+
// `Object.prototype` member read as PRESENT in the other response when it
|
|
1102
|
+
// was absent: a `constructor` header only on `a` came back as
|
|
1103
|
+
// `headersChanged: { constructor: ["yes", null] }` — a removed header
|
|
1104
|
+
// reported as changed-to-nothing, in a field typed `[string, string]`.
|
|
895
1105
|
for (const [k, v] of Object.entries(ha)) {
|
|
896
|
-
if (!(k
|
|
1106
|
+
if (!Object.prototype.hasOwnProperty.call(hb, k))
|
|
897
1107
|
headersRemoved[k] = v;
|
|
898
1108
|
else if (hb[k] !== v)
|
|
899
1109
|
headersChanged[k] = [v, hb[k]];
|
|
900
1110
|
}
|
|
901
1111
|
for (const [k, v] of Object.entries(hb)) {
|
|
902
|
-
if (!(k
|
|
1112
|
+
if (!Object.prototype.hasOwnProperty.call(ha, k))
|
|
903
1113
|
headersAdded[k] = v;
|
|
904
1114
|
}
|
|
905
1115
|
// Body diff (only if both are text)
|
|
@@ -942,7 +1152,10 @@ export async function parseMultipartResponse(response) {
|
|
|
942
1152
|
// Split on boundary
|
|
943
1153
|
// offset tracking via start variable
|
|
944
1154
|
const findBoundary = (from) => {
|
|
945
|
-
|
|
1155
|
+
// `<=`, not `<`: the loop needs `i + boundary.length <= body.length`, so
|
|
1156
|
+
// a boundary sitting flush against the end of the body — no trailing
|
|
1157
|
+
// newline at all — was the one position the search could not reach.
|
|
1158
|
+
for (let i = from; i <= body.length - boundary.length; i++) {
|
|
946
1159
|
if (body.subarray(i, i + boundary.length).every((b, j) => b === boundary[j]))
|
|
947
1160
|
return i;
|
|
948
1161
|
}
|
|
@@ -984,17 +1197,25 @@ export async function parseMultipartResponse(response) {
|
|
|
984
1197
|
// The closing boundary break happens below after parsing
|
|
985
1198
|
}
|
|
986
1199
|
}
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1200
|
+
// Strip the delimiter that precedes this boundary. It was a blind `- 2`
|
|
1201
|
+
// on the assumption of CRLF, so a bare-LF body lost its last two real
|
|
1202
|
+
// bytes, and a part framed some other way lost them too. Measured on the
|
|
1203
|
+
// same two parts: `end - 2` gave "hell" and "worl".
|
|
1204
|
+
let partEnd = end;
|
|
1205
|
+
if (partEnd >= 2 && body[partEnd - 2] === 0x0d && body[partEnd - 1] === 0x0a)
|
|
1206
|
+
partEnd -= 2;
|
|
1207
|
+
else if (partEnd >= 1 && body[partEnd - 1] === 0x0a)
|
|
1208
|
+
partEnd -= 1;
|
|
1209
|
+
const brk = findHeaderBreak(body, start, partEnd);
|
|
1210
|
+
if (!brk) {
|
|
990
1211
|
start = end + boundary.length;
|
|
991
1212
|
continue;
|
|
992
1213
|
}
|
|
993
|
-
const headerBytes =
|
|
994
|
-
const partBody =
|
|
1214
|
+
const headerBytes = body.subarray(start, brk.headerEnd);
|
|
1215
|
+
const partBody = body.subarray(brk.bodyStart, partEnd);
|
|
995
1216
|
const headerText = new TextDecoder().decode(headerBytes);
|
|
996
1217
|
const headers = {};
|
|
997
|
-
for (const line of headerText.split(/\r\n/)) {
|
|
1218
|
+
for (const line of headerText.split(/\r\n|\n/)) {
|
|
998
1219
|
const colon = line.indexOf(":");
|
|
999
1220
|
if (colon !== -1) {
|
|
1000
1221
|
headers[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim();
|
|
@@ -1009,16 +1230,32 @@ export async function parseMultipartResponse(response) {
|
|
|
1009
1230
|
}
|
|
1010
1231
|
return parts;
|
|
1011
1232
|
}
|
|
1012
|
-
|
|
1013
|
-
|
|
1233
|
+
/**
|
|
1234
|
+
* Locate the blank line separating a part's headers from its body, accepting
|
|
1235
|
+
* both CRLF and bare-LF framing.
|
|
1236
|
+
*
|
|
1237
|
+
* The old helper matched `\r\n\r\n` and nothing else, so a multipart body
|
|
1238
|
+
* that used bare LF — non-conformant per RFC 2046, and produced in practice —
|
|
1239
|
+
* parsed as ZERO parts rather than as a slightly-off one: every part failed
|
|
1240
|
+
* the header/body split, each was skipped, and the caller got an empty array
|
|
1241
|
+
* with no error. Both framings are now accepted, and the returned body offset
|
|
1242
|
+
* is the real start of the content either way.
|
|
1243
|
+
*/
|
|
1244
|
+
function findHeaderBreak(bytes, from, to) {
|
|
1245
|
+
for (let i = from; i < to; i++) {
|
|
1246
|
+
// Bare LF: checked first because in `\r\n\r\n` the byte after the
|
|
1247
|
+
// first LF is CR, so the two rules cannot both match at one position.
|
|
1248
|
+
if (bytes[i] === 0x0a && bytes[i + 1] === 0x0a) {
|
|
1249
|
+
return { headerEnd: i, bodyStart: i + 2 };
|
|
1250
|
+
}
|
|
1014
1251
|
if (bytes[i] === 0x0d &&
|
|
1015
1252
|
bytes[i + 1] === 0x0a &&
|
|
1016
1253
|
bytes[i + 2] === 0x0d &&
|
|
1017
1254
|
bytes[i + 3] === 0x0a) {
|
|
1018
|
-
return i;
|
|
1255
|
+
return { headerEnd: i, bodyStart: i + 4 };
|
|
1019
1256
|
}
|
|
1020
1257
|
}
|
|
1021
|
-
return
|
|
1258
|
+
return null;
|
|
1022
1259
|
}
|
|
1023
1260
|
/**
|
|
1024
1261
|
* Read a multipart/form-data response as a FormData object.
|
|
@@ -1080,9 +1317,15 @@ export function extractServerTiming(headers) {
|
|
|
1080
1317
|
const name = (segments[0] ?? "").trim();
|
|
1081
1318
|
const params = parseParams(segments.slice(1).join(";"));
|
|
1082
1319
|
const dur = params.get("dur");
|
|
1320
|
+
// `Number.isFinite`, not `dur !== undefined`: `parseFloat` returns NaN
|
|
1321
|
+
// for anything non-numeric, and `Server-Timing: x;dur=abc` is a
|
|
1322
|
+
// malformed header a server can send. The NaN went straight into the
|
|
1323
|
+
// metrics array, where it is not a number, fails every comparison, and
|
|
1324
|
+
// vanishes from a chart with nothing to indicate why.
|
|
1325
|
+
const parsed = dur !== undefined ? parseFloat(dur) : NaN;
|
|
1083
1326
|
return {
|
|
1084
1327
|
name,
|
|
1085
|
-
duration:
|
|
1328
|
+
duration: Number.isFinite(parsed) ? parsed : null,
|
|
1086
1329
|
description: params.get("desc") ?? null,
|
|
1087
1330
|
};
|
|
1088
1331
|
})
|
|
@@ -1114,19 +1357,29 @@ async function readBodyBytes(response, url, options) {
|
|
|
1114
1357
|
if (options.signal?.aborted) {
|
|
1115
1358
|
throw new DOMException("Response reading aborted", "AbortError");
|
|
1116
1359
|
}
|
|
1117
|
-
const { done, value } = await reader.
|
|
1118
|
-
if (done)
|
|
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
|
+
}
|
|
1119
1366
|
break;
|
|
1367
|
+
}
|
|
1120
1368
|
chunks.push(value);
|
|
1121
1369
|
}
|
|
1122
1370
|
}
|
|
1123
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
|
+
}
|
|
1124
1377
|
reader.releaseLock();
|
|
1125
1378
|
}
|
|
1126
1379
|
return concatUint8Arrays(chunks);
|
|
1127
1380
|
}
|
|
1128
1381
|
function applySizeLimit(stream, url, limit, signal) {
|
|
1129
|
-
const onExceed = limit.onExceed
|
|
1382
|
+
const onExceed = normaliseOnExceed(limit.onExceed);
|
|
1130
1383
|
let bytesRead = 0;
|
|
1131
1384
|
return new ReadableStream({
|
|
1132
1385
|
async start(controller) {
|
|
@@ -1138,8 +1391,15 @@ function applySizeLimit(stream, url, limit, signal) {
|
|
|
1138
1391
|
controller.error(new DOMException("Response reading aborted", "AbortError"));
|
|
1139
1392
|
return;
|
|
1140
1393
|
}
|
|
1141
|
-
const { done, value } = await reader
|
|
1394
|
+
const { done, value } = await readWithAbort(reader, signal);
|
|
1142
1395
|
if (done) {
|
|
1396
|
+
// See readWithAbort: an aborted read also resolves as done, and
|
|
1397
|
+
// here the caller must see the error rather than a clean close.
|
|
1398
|
+
if (signal?.aborted) {
|
|
1399
|
+
await reader.cancel("aborted").catch(() => { });
|
|
1400
|
+
controller.error(new DOMException("Response reading aborted", "AbortError"));
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1143
1403
|
controller.close();
|
|
1144
1404
|
break;
|
|
1145
1405
|
}
|
|
@@ -1180,7 +1440,16 @@ function validateContentType(actual, expected, url) {
|
|
|
1180
1440
|
throw new ContentTypeError(expected, "(none)", url);
|
|
1181
1441
|
const normalizedActual = actual.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
1182
1442
|
const normalizedExpected = expected.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
1183
|
-
|
|
1443
|
+
// Media-type equality, not a string prefix. `startsWith` accepted
|
|
1444
|
+
// `text/plaintext` for `text/plain` and `application/json-seq` for
|
|
1445
|
+
// `application/json` — an `expectedContentType` set to narrow the parse to
|
|
1446
|
+
// one format accepted every format whose name merely begins with it. A bare
|
|
1447
|
+
// top-level type still matches the whole tree, which is the one prefix case
|
|
1448
|
+
// that means something: `image` for `image/png`.
|
|
1449
|
+
const actualType = normalizedActual.split("/")[0] ?? "";
|
|
1450
|
+
const matches = normalizedActual === normalizedExpected ||
|
|
1451
|
+
(normalizedExpected.indexOf("/") === -1 && actualType === normalizedExpected);
|
|
1452
|
+
if (!matches) {
|
|
1184
1453
|
throw new ContentTypeError(expected, actual, url);
|
|
1185
1454
|
}
|
|
1186
1455
|
}
|