kinetex 1.3.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 +413 -52
- package/dist/browser/kinetex.esm.js +38 -22
- package/dist/browser/kinetex.js +3134 -787
- package/dist/browser/kinetex.min.js +38 -22
- package/dist/cjs/aws-sigv4.js +140 -33
- package/dist/cjs/cache.js +58 -7
- package/dist/cjs/circuit-breaker.js +53 -4
- package/dist/cjs/client.js +726 -188
- package/dist/cjs/cookie-parser.js +131 -6
- package/dist/cjs/cookie-store.js +125 -28
- package/dist/cjs/core.js +691 -70
- package/dist/cjs/dedup.js +142 -21
- package/dist/cjs/digest.js +195 -38
- package/dist/cjs/graphql.js +166 -35
- package/dist/cjs/headers.js +327 -46
- package/dist/cjs/interceptors.js +227 -24
- package/dist/cjs/lifecycle.js +92 -59
- package/dist/cjs/logging.js +160 -24
- package/dist/cjs/mod.js +3 -2
- package/dist/cjs/pagination.js +247 -22
- package/dist/cjs/progress.js +178 -30
- package/dist/cjs/proxy.js +429 -0
- package/dist/cjs/response.js +506 -110
- package/dist/cjs/socks5.js +136 -15
- package/dist/cjs/sse.js +173 -43
- package/dist/cjs/url.js +191 -45
- package/dist/cjs/utils.js +335 -73
- package/dist/cjs/ws.js +62 -12
- package/dist/esm/aws-sigv4.js +140 -33
- package/dist/esm/aws-sigv4.js.map +1 -1
- package/dist/esm/cache.js +58 -7
- package/dist/esm/cache.js.map +1 -1
- package/dist/esm/circuit-breaker.js +53 -4
- package/dist/esm/circuit-breaker.js.map +1 -1
- package/dist/esm/client.js +726 -188
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/cookie-parser.js +131 -6
- package/dist/esm/cookie-parser.js.map +1 -1
- package/dist/esm/cookie-store.js +125 -28
- package/dist/esm/cookie-store.js.map +1 -1
- package/dist/esm/core.js +691 -70
- package/dist/esm/core.js.map +1 -1
- package/dist/esm/dedup.js +142 -21
- package/dist/esm/dedup.js.map +1 -1
- package/dist/esm/digest.js +195 -38
- package/dist/esm/digest.js.map +1 -1
- package/dist/esm/graphql.js +166 -35
- package/dist/esm/graphql.js.map +1 -1
- package/dist/esm/headers.js +327 -46
- package/dist/esm/headers.js.map +1 -1
- package/dist/esm/interceptors.js +227 -24
- package/dist/esm/interceptors.js.map +1 -1
- package/dist/esm/lifecycle.js +92 -59
- package/dist/esm/lifecycle.js.map +1 -1
- package/dist/esm/logging.js +160 -24
- 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 +247 -22
- package/dist/esm/pagination.js.map +1 -1
- package/dist/esm/progress.js +178 -30
- package/dist/esm/progress.js.map +1 -1
- package/dist/esm/proxy.js +430 -0
- package/dist/esm/proxy.js.map +1 -0
- package/dist/esm/response.js +506 -110
- package/dist/esm/response.js.map +1 -1
- package/dist/esm/socks5.js +136 -15
- package/dist/esm/socks5.js.map +1 -1
- package/dist/esm/sse.js +173 -43
- 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 +335 -73
- package/dist/esm/utils.js.map +1 -1
- package/dist/esm/ws.js +62 -12
- package/dist/esm/ws.js.map +1 -1
- package/dist/types/aws-sigv4.d.ts.map +1 -1
- package/dist/types/cache.d.ts +19 -1
- package/dist/types/cache.d.ts.map +1 -1
- package/dist/types/circuit-breaker.d.ts +15 -2
- package/dist/types/circuit-breaker.d.ts.map +1 -1
- package/dist/types/client.d.ts +69 -11
- 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 +216 -25
- 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 +17 -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 +68 -31
- package/dist/types/headers.d.ts.map +1 -1
- package/dist/types/interceptors.d.ts +126 -38
- 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 +22 -3
- 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 +49 -2
- 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 +219 -26
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/url.d.ts +0 -25
- 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/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,87 @@ 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
|
+
}
|
|
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
|
+
}
|
|
243
330
|
/**
|
|
244
331
|
* Read a ReadableStream with a hard size limit.
|
|
245
332
|
*
|
|
@@ -252,7 +339,7 @@ export function isBinary(contentType) {
|
|
|
252
339
|
* @throws {DOMException} If the signal fires.
|
|
253
340
|
*/
|
|
254
341
|
export async function readBodyWithLimit(stream, url, limit, signal) {
|
|
255
|
-
const onExceed = limit.onExceed
|
|
342
|
+
const onExceed = normaliseOnExceed(limit.onExceed);
|
|
256
343
|
const reader = stream.getReader();
|
|
257
344
|
const chunks = [];
|
|
258
345
|
let bytesRead = 0;
|
|
@@ -262,34 +349,45 @@ export async function readBodyWithLimit(stream, url, limit, signal) {
|
|
|
262
349
|
await reader.cancel("aborted");
|
|
263
350
|
throw new DOMException("Response reading aborted", "AbortError");
|
|
264
351
|
}
|
|
265
|
-
const { done, value } = await reader
|
|
266
|
-
if (done)
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
await reader.cancel("size limit exceeded");
|
|
273
|
-
throw new ResponseSizeLimitError(newTotal, limit.maxBytes, url);
|
|
274
|
-
}
|
|
275
|
-
if (onExceed === "abort") {
|
|
276
|
-
await reader.cancel("size limit exceeded");
|
|
277
|
-
break;
|
|
278
|
-
}
|
|
279
|
-
if (onExceed === "truncate") {
|
|
280
|
-
const remaining = limit.maxBytes - bytesRead;
|
|
281
|
-
if (remaining > 0) {
|
|
282
|
-
chunks.push(value.slice(0, remaining));
|
|
283
|
-
bytesRead += remaining;
|
|
284
|
-
}
|
|
285
|
-
break;
|
|
352
|
+
const { done, value } = await readWithAbort(reader, signal);
|
|
353
|
+
if (done) {
|
|
354
|
+
// `done` is also how an aborted read settles (see readWithAbort), so
|
|
355
|
+
// the signal has to be re-checked here or a partial body would be
|
|
356
|
+
// returned as a complete one.
|
|
357
|
+
if (signal?.aborted) {
|
|
358
|
+
throw new DOMException("Response reading aborted", "AbortError");
|
|
286
359
|
}
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
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;
|
|
287
378
|
}
|
|
288
379
|
chunks.push(value);
|
|
289
380
|
bytesRead += value.byteLength;
|
|
290
381
|
}
|
|
291
382
|
}
|
|
292
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
|
+
}
|
|
293
391
|
reader.releaseLock();
|
|
294
392
|
}
|
|
295
393
|
return concatUint8Arrays(chunks);
|
|
@@ -314,7 +412,20 @@ const CHARSET_ALIASES = {
|
|
|
314
412
|
function resolveCharset(charset) {
|
|
315
413
|
if (!charset)
|
|
316
414
|
return "utf-8";
|
|
317
|
-
|
|
415
|
+
const key = charset.toLowerCase().trim();
|
|
416
|
+
// Own-property check, not a bare index. `CHARSET_ALIASES` is a plain object
|
|
417
|
+
// literal, so an unrecognised charset naming an `Object.prototype` member
|
|
418
|
+
// resolved to that member instead of falling back: `charset=constructor`
|
|
419
|
+
// reached `TextDecoder` as `function Object() { [native code] }` and
|
|
420
|
+
// `charset=__proto__` as `[object Object]`, either of which throws
|
|
421
|
+
// ERR_ENCODING_NOT_SUPPORTED and fails the whole body with a decode error
|
|
422
|
+
// naming a charset nobody sent. The charset is attacker-controlled — it
|
|
423
|
+
// comes straight off a response header — so that is a denial of service on
|
|
424
|
+
// body parsing, and `__proto__` is the kind of value a scanner fuzzes.
|
|
425
|
+
if (Object.prototype.hasOwnProperty.call(CHARSET_ALIASES, key)) {
|
|
426
|
+
return CHARSET_ALIASES[key];
|
|
427
|
+
}
|
|
428
|
+
return "utf-8";
|
|
318
429
|
}
|
|
319
430
|
/**
|
|
320
431
|
* Detect charset from BOM (byte order mark) in the body.
|
|
@@ -337,6 +448,13 @@ function detectBOM(bytes) {
|
|
|
337
448
|
* @returns The decoded string.
|
|
338
449
|
* @throws {ResponseDecodeError} If decoding fails even with lenient fallback.
|
|
339
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
|
+
*/
|
|
340
458
|
export function decodeBody(bytes, charset, url) {
|
|
341
459
|
const detected = detectBOM(bytes);
|
|
342
460
|
const resolved = detected ?? resolveCharset(charset);
|
|
@@ -351,7 +469,7 @@ export function decodeBody(bytes, charset, url) {
|
|
|
351
469
|
return new TextDecoder(resolved, { fatal: false }).decode(data);
|
|
352
470
|
}
|
|
353
471
|
catch (err2) {
|
|
354
|
-
throw new ResponseDecodeError(String(err2), resolved, url);
|
|
472
|
+
throw new ResponseDecodeError(String(err2), resolved, url ?? "<unknown url>");
|
|
355
473
|
}
|
|
356
474
|
}
|
|
357
475
|
}
|
|
@@ -388,6 +506,13 @@ function createSizeLimitedDecompression(stream, encoding, maxDecompressedSize =
|
|
|
388
506
|
*/
|
|
389
507
|
export function decompressStream(stream, encoding) {
|
|
390
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.
|
|
391
516
|
// ── Brotli (br) ──────────────────────────────────────────────────────────
|
|
392
517
|
// WHATWG DecompressionStream does NOT support brotli in any current runtime.
|
|
393
518
|
// On Node.js we use node:zlib.createBrotliDecompress() via a stream adapter.
|
|
@@ -397,6 +522,14 @@ export function decompressStream(stream, encoding) {
|
|
|
397
522
|
if (normalized === "br") {
|
|
398
523
|
return brotliDecompressStream(stream);
|
|
399
524
|
}
|
|
525
|
+
// ── zstd ─────────────────────────────────────────────────────────────────
|
|
526
|
+
// `DecompressionStream` has no "zstd" format in Node or the browsers as of
|
|
527
|
+
// this writing, but does in Bun and newer edge runtimes. Prefer it when
|
|
528
|
+
// present, otherwise fall back to node:zlib on Node 22+, and only then give
|
|
529
|
+
// up and pass the bytes through.
|
|
530
|
+
if (normalized === "zstd") {
|
|
531
|
+
return zstdDecompressStream(stream);
|
|
532
|
+
}
|
|
400
533
|
// ── gzip / deflate ────────────────────────────────────────────────────────
|
|
401
534
|
if (typeof DecompressionStream === "undefined")
|
|
402
535
|
return stream;
|
|
@@ -404,73 +537,272 @@ export function decompressStream(stream, encoding) {
|
|
|
404
537
|
// CompressionFormat is a browser type, we normalize to it
|
|
405
538
|
// Type assertion is safe as we validated the encoding above
|
|
406
539
|
const format = normalized;
|
|
407
|
-
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
|
|
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);
|
|
412
547
|
}
|
|
413
548
|
catch {
|
|
414
549
|
// Unsupported format — return raw (caller sent Accept-Encoding conservatively)
|
|
415
550
|
return stream;
|
|
416
551
|
}
|
|
417
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
|
+
}
|
|
418
634
|
/**
|
|
419
635
|
* Decompress a brotli-encoded ReadableStream.
|
|
420
636
|
* Uses node:zlib on Node.js. Returns the stream unchanged on other runtimes.
|
|
421
637
|
*/
|
|
422
638
|
function brotliDecompressStream(stream) {
|
|
423
|
-
//
|
|
424
|
-
|
|
639
|
+
// Non-Node runtime: we cannot decompress brotli natively. Return raw bytes —
|
|
640
|
+
// this will likely cause a parse error downstream, which is the correct
|
|
641
|
+
// behaviour (the server shouldn't have sent br in the first place).
|
|
642
|
+
if (!isNodeRuntime())
|
|
643
|
+
return stream;
|
|
644
|
+
return nodeZlibDecompressStream(stream, (zlib) => zlib.createBrotliDecompress());
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* True when running on Node.js.
|
|
648
|
+
*
|
|
649
|
+
* Read defensively: `process` does not exist in edge runtimes and reading it
|
|
650
|
+
* through a hostile global can throw.
|
|
651
|
+
*/
|
|
652
|
+
function isNodeRuntime() {
|
|
425
653
|
try {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
?.node === "string";
|
|
654
|
+
return (typeof globalThis.process?.versions
|
|
655
|
+
?.node === "string");
|
|
429
656
|
}
|
|
430
657
|
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;
|
|
658
|
+
return false;
|
|
438
659
|
}
|
|
439
|
-
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Pipe a web ReadableStream through a `node:zlib` decompressor.
|
|
663
|
+
*
|
|
664
|
+
* @param stream - Compressed input.
|
|
665
|
+
* @param create - Builds the zlib transform from the imported module.
|
|
666
|
+
* @returns The decompressed stream.
|
|
667
|
+
*/
|
|
668
|
+
function nodeZlibDecompressStream(stream, create) {
|
|
669
|
+
// The node stream and the read loop can both try to settle the controller,
|
|
670
|
+
// and `cancel()` settles it too. Whichever arrives second would throw
|
|
671
|
+
// ERR_INVALID_STATE on an already closed controller — an unhandled throw
|
|
672
|
+
// that takes the whole process down, not just the request. Two reachable
|
|
673
|
+
// ways to get there:
|
|
674
|
+
// * an upstream error rejects `reader.read()`, we error the controller,
|
|
675
|
+
// and the `br.destroy()` below makes node emit "end" afterwards;
|
|
676
|
+
// * the consumer cancels (an aborted request), and cancelling the source
|
|
677
|
+
// makes zlib drain and emit "end" against a closed controller.
|
|
678
|
+
// Neither is exotic — a server aborting a response mid-body, or a client
|
|
679
|
+
// cancelling one, is ordinary traffic on any content-encoding response.
|
|
680
|
+
let settled = false;
|
|
681
|
+
// Hoisted so `cancel()` can reach it: the bridge holds a reader lock for the
|
|
682
|
+
// whole stream, and `ReadableStream.cancel()` rejects with ERR_INVALID_STATE
|
|
683
|
+
// on a locked stream. `reader.cancel()` is the supported way to abort it.
|
|
684
|
+
let reader = null;
|
|
685
|
+
// `start()` awaits the dynamic import before locking the source, so a cancel
|
|
686
|
+
// can land in that window — an immediately-aborted request is the common
|
|
687
|
+
// case, not an edge one. Remember it and apply it once the reader exists.
|
|
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;
|
|
440
697
|
return new ReadableStream({
|
|
441
698
|
async start(controller) {
|
|
442
699
|
// Dynamic import so non-Node runtimes never load node:zlib
|
|
443
700
|
const zlib = await import("node:zlib");
|
|
444
|
-
const br = zlib
|
|
445
|
-
|
|
701
|
+
const br = create(zlib);
|
|
702
|
+
reader = stream.getReader();
|
|
703
|
+
if (cancelRequested) {
|
|
704
|
+
// The source may already be gone; there is nothing left to release.
|
|
705
|
+
void reader.cancel().catch(() => undefined);
|
|
706
|
+
reader.releaseLock();
|
|
707
|
+
reader = null;
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
const settleError = (e) => {
|
|
711
|
+
if (settled)
|
|
712
|
+
return;
|
|
713
|
+
settled = true;
|
|
714
|
+
controller.error(e);
|
|
715
|
+
};
|
|
446
716
|
br.on("data", (chunk) => {
|
|
717
|
+
if (settled)
|
|
718
|
+
return;
|
|
447
719
|
controller.enqueue(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength));
|
|
448
720
|
});
|
|
449
|
-
br.on("end", () =>
|
|
450
|
-
|
|
721
|
+
br.on("end", () => {
|
|
722
|
+
if (settled)
|
|
723
|
+
return;
|
|
724
|
+
settled = true;
|
|
725
|
+
controller.close();
|
|
726
|
+
});
|
|
727
|
+
br.on("error", settleError);
|
|
451
728
|
try {
|
|
452
729
|
while (true) {
|
|
453
730
|
const { done, value } = await reader.read();
|
|
454
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
|
+
}
|
|
455
742
|
br.end();
|
|
456
743
|
break;
|
|
457
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;
|
|
458
750
|
br.write(value);
|
|
459
751
|
}
|
|
460
752
|
}
|
|
461
753
|
catch (e) {
|
|
462
754
|
br.destroy(e instanceof Error ? e : new Error(String(e)));
|
|
463
|
-
|
|
755
|
+
settleError(e);
|
|
464
756
|
}
|
|
465
757
|
finally {
|
|
466
758
|
reader.releaseLock();
|
|
759
|
+
reader = null;
|
|
467
760
|
}
|
|
468
761
|
},
|
|
469
762
|
cancel() {
|
|
470
|
-
|
|
763
|
+
// Mark settled first: cancelling the reader resolves the loop's pending
|
|
764
|
+
// read as `done`, which ends the zlib transform and emits "end" against
|
|
765
|
+
// an already-closed controller.
|
|
766
|
+
settled = true;
|
|
767
|
+
cancelRequested = true;
|
|
768
|
+
void reader?.cancel().catch(() => undefined);
|
|
471
769
|
},
|
|
472
770
|
});
|
|
473
771
|
}
|
|
772
|
+
/**
|
|
773
|
+
* Decompress a zstd-encoded ReadableStream.
|
|
774
|
+
*
|
|
775
|
+
* Tries, in order: the standard `DecompressionStream("zstd")` (Bun, newer edge
|
|
776
|
+
* runtimes), then `node:zlib`'s `createZstdDecompress` (Node 22+), then passes
|
|
777
|
+
* the bytes through unchanged. That last case is a genuine failure mode rather
|
|
778
|
+
* than a formality — kinetex does not advertise `zstd` in its default
|
|
779
|
+
* `accept-encoding`, so a server should never choose it, but a caller that sets
|
|
780
|
+
* the header explicitly (or proxies a response through) can still hit it.
|
|
781
|
+
*/
|
|
782
|
+
function zstdDecompressStream(stream) {
|
|
783
|
+
if (typeof DecompressionStream !== "undefined") {
|
|
784
|
+
try {
|
|
785
|
+
// "zstd" is a real CompressionFormat at runtime but is not yet in the
|
|
786
|
+
// TypeScript lib union, so it is asserted here rather than widened.
|
|
787
|
+
const Ctor = DecompressionStream;
|
|
788
|
+
return stream.pipeThrough(new Ctor("zstd"));
|
|
789
|
+
}
|
|
790
|
+
catch {
|
|
791
|
+
// Format not recognised by this runtime — try node:zlib below.
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
if (!isNodeRuntime())
|
|
795
|
+
return stream;
|
|
796
|
+
// node:zlib gained zstd in Node 22; older versions have no such export.
|
|
797
|
+
return nodeZlibDecompressStream(stream, (zlib) => {
|
|
798
|
+
const create = zlib
|
|
799
|
+
.createZstdDecompress;
|
|
800
|
+
if (typeof create !== "function") {
|
|
801
|
+
throw new Error("zstd decompression is not available in this Node.js version");
|
|
802
|
+
}
|
|
803
|
+
return create();
|
|
804
|
+
});
|
|
805
|
+
}
|
|
474
806
|
/**
|
|
475
807
|
* Detect and apply decompression based on Content-Encoding header.
|
|
476
808
|
*
|
|
@@ -620,7 +952,7 @@ export async function* readNDJSON(response, options = {}) {
|
|
|
620
952
|
while (true) {
|
|
621
953
|
if (options.signal?.aborted)
|
|
622
954
|
break;
|
|
623
|
-
const { done, value } = await reader.
|
|
955
|
+
const { done, value } = await readWithAbort(reader, options.signal);
|
|
624
956
|
if (done) {
|
|
625
957
|
// Process remaining buffer
|
|
626
958
|
const trimmed = buffer.trim();
|
|
@@ -684,6 +1016,12 @@ export async function* readJSONStream(response, options = {}) {
|
|
|
684
1016
|
let depth = 0;
|
|
685
1017
|
let buffer = "";
|
|
686
1018
|
let startIdx = -1;
|
|
1019
|
+
// JSON string state for the scan below. Braces and brackets inside a string
|
|
1020
|
+
// are DATA: the counter closed on a lone `}` in a value, cut the object in
|
|
1021
|
+
// half, and reported both halves as parse errors — so the object was
|
|
1022
|
+
// silently dropped. `{"note":"use } to close"}` yielded nothing at all.
|
|
1023
|
+
let inString = false;
|
|
1024
|
+
let escaped = false;
|
|
687
1025
|
const flushBuffer = () => {
|
|
688
1026
|
if (startIdx === -1)
|
|
689
1027
|
return null;
|
|
@@ -695,7 +1033,7 @@ export async function* readJSONStream(response, options = {}) {
|
|
|
695
1033
|
while (true) {
|
|
696
1034
|
if (options.signal?.aborted)
|
|
697
1035
|
break;
|
|
698
|
-
const { done, value } = await reader.
|
|
1036
|
+
const { done, value } = await readWithAbort(reader, options.signal);
|
|
699
1037
|
if (done) {
|
|
700
1038
|
// Try to parse remaining content as final object
|
|
701
1039
|
const remaining = flushBuffer();
|
|
@@ -714,6 +1052,19 @@ export async function* readJSONStream(response, options = {}) {
|
|
|
714
1052
|
buffer += decoder.decode(value, { stream: true });
|
|
715
1053
|
for (let i = 0; i < buffer.length; i++) {
|
|
716
1054
|
const char = buffer[i];
|
|
1055
|
+
if (inString) {
|
|
1056
|
+
if (escaped)
|
|
1057
|
+
escaped = false;
|
|
1058
|
+
else if (char === "\\")
|
|
1059
|
+
escaped = true;
|
|
1060
|
+
else if (char === '"')
|
|
1061
|
+
inString = false;
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
if (char === '"') {
|
|
1065
|
+
inString = true;
|
|
1066
|
+
continue;
|
|
1067
|
+
}
|
|
717
1068
|
if (char === "{" || char === "[") {
|
|
718
1069
|
if (depth === 0)
|
|
719
1070
|
startIdx = i;
|
|
@@ -732,6 +1083,12 @@ export async function* readJSONStream(response, options = {}) {
|
|
|
732
1083
|
options.onParseError?.(err, objStr);
|
|
733
1084
|
}
|
|
734
1085
|
buffer = buffer.slice(i + 1);
|
|
1086
|
+
// The consumed object's index must not survive into the next
|
|
1087
|
+
// object: the buffer was resliced underneath it, and a stale
|
|
1088
|
+
// startIdx is an offset into a string it no longer indexes.
|
|
1089
|
+
startIdx = -1;
|
|
1090
|
+
inString = false;
|
|
1091
|
+
escaped = false;
|
|
735
1092
|
i = -1;
|
|
736
1093
|
}
|
|
737
1094
|
}
|
|
@@ -843,7 +1200,9 @@ export class ResponseCache {
|
|
|
843
1200
|
const entry = this.cache.get(key);
|
|
844
1201
|
if (!entry)
|
|
845
1202
|
return null;
|
|
846
|
-
|
|
1203
|
+
// `>=`, not `>`: an entry is expired AT its expiry instant, so
|
|
1204
|
+
// `ttlMs: 0` served a cached response instead of expiring immediately.
|
|
1205
|
+
if (Date.now() >= entry.expiresAt) {
|
|
847
1206
|
this.cache.delete(key);
|
|
848
1207
|
return null;
|
|
849
1208
|
}
|
|
@@ -892,14 +1251,20 @@ export async function diffResponses(a, b) {
|
|
|
892
1251
|
const headersChanged = {};
|
|
893
1252
|
const headersAdded = {};
|
|
894
1253
|
const headersRemoved = {};
|
|
1254
|
+
// Own-property tests, not `in`. `in` walks the prototype chain, and these
|
|
1255
|
+
// are plain object literals, so a header whose lowercased name is an
|
|
1256
|
+
// `Object.prototype` member read as PRESENT in the other response when it
|
|
1257
|
+
// was absent: a `constructor` header only on `a` came back as
|
|
1258
|
+
// `headersChanged: { constructor: ["yes", null] }` — a removed header
|
|
1259
|
+
// reported as changed-to-nothing, in a field typed `[string, string]`.
|
|
895
1260
|
for (const [k, v] of Object.entries(ha)) {
|
|
896
|
-
if (!(k
|
|
1261
|
+
if (!Object.prototype.hasOwnProperty.call(hb, k))
|
|
897
1262
|
headersRemoved[k] = v;
|
|
898
1263
|
else if (hb[k] !== v)
|
|
899
1264
|
headersChanged[k] = [v, hb[k]];
|
|
900
1265
|
}
|
|
901
1266
|
for (const [k, v] of Object.entries(hb)) {
|
|
902
|
-
if (!(k
|
|
1267
|
+
if (!Object.prototype.hasOwnProperty.call(ha, k))
|
|
903
1268
|
headersAdded[k] = v;
|
|
904
1269
|
}
|
|
905
1270
|
// Body diff (only if both are text)
|
|
@@ -942,7 +1307,10 @@ export async function parseMultipartResponse(response) {
|
|
|
942
1307
|
// Split on boundary
|
|
943
1308
|
// offset tracking via start variable
|
|
944
1309
|
const findBoundary = (from) => {
|
|
945
|
-
|
|
1310
|
+
// `<=`, not `<`: the loop needs `i + boundary.length <= body.length`, so
|
|
1311
|
+
// a boundary sitting flush against the end of the body — no trailing
|
|
1312
|
+
// newline at all — was the one position the search could not reach.
|
|
1313
|
+
for (let i = from; i <= body.length - boundary.length; i++) {
|
|
946
1314
|
if (body.subarray(i, i + boundary.length).every((b, j) => b === boundary[j]))
|
|
947
1315
|
return i;
|
|
948
1316
|
}
|
|
@@ -984,17 +1352,25 @@ export async function parseMultipartResponse(response) {
|
|
|
984
1352
|
// The closing boundary break happens below after parsing
|
|
985
1353
|
}
|
|
986
1354
|
}
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1355
|
+
// Strip the delimiter that precedes this boundary. It was a blind `- 2`
|
|
1356
|
+
// on the assumption of CRLF, so a bare-LF body lost its last two real
|
|
1357
|
+
// bytes, and a part framed some other way lost them too. Measured on the
|
|
1358
|
+
// same two parts: `end - 2` gave "hell" and "worl".
|
|
1359
|
+
let partEnd = end;
|
|
1360
|
+
if (partEnd >= 2 && body[partEnd - 2] === 0x0d && body[partEnd - 1] === 0x0a)
|
|
1361
|
+
partEnd -= 2;
|
|
1362
|
+
else if (partEnd >= 1 && body[partEnd - 1] === 0x0a)
|
|
1363
|
+
partEnd -= 1;
|
|
1364
|
+
const brk = findHeaderBreak(body, start, partEnd);
|
|
1365
|
+
if (!brk) {
|
|
990
1366
|
start = end + boundary.length;
|
|
991
1367
|
continue;
|
|
992
1368
|
}
|
|
993
|
-
const headerBytes =
|
|
994
|
-
const partBody =
|
|
1369
|
+
const headerBytes = body.subarray(start, brk.headerEnd);
|
|
1370
|
+
const partBody = body.subarray(brk.bodyStart, partEnd);
|
|
995
1371
|
const headerText = new TextDecoder().decode(headerBytes);
|
|
996
1372
|
const headers = {};
|
|
997
|
-
for (const line of headerText.split(/\r\n/)) {
|
|
1373
|
+
for (const line of headerText.split(/\r\n|\n/)) {
|
|
998
1374
|
const colon = line.indexOf(":");
|
|
999
1375
|
if (colon !== -1) {
|
|
1000
1376
|
headers[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim();
|
|
@@ -1009,16 +1385,32 @@ export async function parseMultipartResponse(response) {
|
|
|
1009
1385
|
}
|
|
1010
1386
|
return parts;
|
|
1011
1387
|
}
|
|
1012
|
-
|
|
1013
|
-
|
|
1388
|
+
/**
|
|
1389
|
+
* Locate the blank line separating a part's headers from its body, accepting
|
|
1390
|
+
* both CRLF and bare-LF framing.
|
|
1391
|
+
*
|
|
1392
|
+
* The old helper matched `\r\n\r\n` and nothing else, so a multipart body
|
|
1393
|
+
* that used bare LF — non-conformant per RFC 2046, and produced in practice —
|
|
1394
|
+
* parsed as ZERO parts rather than as a slightly-off one: every part failed
|
|
1395
|
+
* the header/body split, each was skipped, and the caller got an empty array
|
|
1396
|
+
* with no error. Both framings are now accepted, and the returned body offset
|
|
1397
|
+
* is the real start of the content either way.
|
|
1398
|
+
*/
|
|
1399
|
+
function findHeaderBreak(bytes, from, to) {
|
|
1400
|
+
for (let i = from; i < to; i++) {
|
|
1401
|
+
// Bare LF: checked first because in `\r\n\r\n` the byte after the
|
|
1402
|
+
// first LF is CR, so the two rules cannot both match at one position.
|
|
1403
|
+
if (bytes[i] === 0x0a && bytes[i + 1] === 0x0a) {
|
|
1404
|
+
return { headerEnd: i, bodyStart: i + 2 };
|
|
1405
|
+
}
|
|
1014
1406
|
if (bytes[i] === 0x0d &&
|
|
1015
1407
|
bytes[i + 1] === 0x0a &&
|
|
1016
1408
|
bytes[i + 2] === 0x0d &&
|
|
1017
1409
|
bytes[i + 3] === 0x0a) {
|
|
1018
|
-
return i;
|
|
1410
|
+
return { headerEnd: i, bodyStart: i + 4 };
|
|
1019
1411
|
}
|
|
1020
1412
|
}
|
|
1021
|
-
return
|
|
1413
|
+
return null;
|
|
1022
1414
|
}
|
|
1023
1415
|
/**
|
|
1024
1416
|
* Read a multipart/form-data response as a FormData object.
|
|
@@ -1080,9 +1472,15 @@ export function extractServerTiming(headers) {
|
|
|
1080
1472
|
const name = (segments[0] ?? "").trim();
|
|
1081
1473
|
const params = parseParams(segments.slice(1).join(";"));
|
|
1082
1474
|
const dur = params.get("dur");
|
|
1475
|
+
// `Number.isFinite`, not `dur !== undefined`: `parseFloat` returns NaN
|
|
1476
|
+
// for anything non-numeric, and `Server-Timing: x;dur=abc` is a
|
|
1477
|
+
// malformed header a server can send. The NaN went straight into the
|
|
1478
|
+
// metrics array, where it is not a number, fails every comparison, and
|
|
1479
|
+
// vanishes from a chart with nothing to indicate why.
|
|
1480
|
+
const parsed = dur !== undefined ? parseFloat(dur) : NaN;
|
|
1083
1481
|
return {
|
|
1084
1482
|
name,
|
|
1085
|
-
duration:
|
|
1483
|
+
duration: Number.isFinite(parsed) ? parsed : null,
|
|
1086
1484
|
description: params.get("desc") ?? null,
|
|
1087
1485
|
};
|
|
1088
1486
|
})
|
|
@@ -1091,9 +1489,9 @@ export function extractServerTiming(headers) {
|
|
|
1091
1489
|
// ============================================================================
|
|
1092
1490
|
// §14 UTILITIES
|
|
1093
1491
|
// ============================================================================
|
|
1094
|
-
|
|
1492
|
+
function readBodyBytes(response, url, options) {
|
|
1095
1493
|
if (!response.body)
|
|
1096
|
-
return new Uint8Array(0);
|
|
1494
|
+
return Promise.resolve(new Uint8Array(0));
|
|
1097
1495
|
const headers = normalizeHeaders(response.headers);
|
|
1098
1496
|
let stream = response.body;
|
|
1099
1497
|
// Decompression
|
|
@@ -1107,26 +1505,14 @@ async function readBodyBytes(response, url, options) {
|
|
|
1107
1505
|
if (options.sizeLimit) {
|
|
1108
1506
|
return readBodyWithLimit(stream, url, options.sizeLimit, options.signal);
|
|
1109
1507
|
}
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
throw new DOMException("Response reading aborted", "AbortError");
|
|
1116
|
-
}
|
|
1117
|
-
const { done, value } = await reader.read();
|
|
1118
|
-
if (done)
|
|
1119
|
-
break;
|
|
1120
|
-
chunks.push(value);
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1123
|
-
finally {
|
|
1124
|
-
reader.releaseLock();
|
|
1125
|
-
}
|
|
1126
|
-
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);
|
|
1127
1513
|
}
|
|
1128
1514
|
function applySizeLimit(stream, url, limit, signal) {
|
|
1129
|
-
const onExceed = limit.onExceed
|
|
1515
|
+
const onExceed = normaliseOnExceed(limit.onExceed);
|
|
1130
1516
|
let bytesRead = 0;
|
|
1131
1517
|
return new ReadableStream({
|
|
1132
1518
|
async start(controller) {
|
|
@@ -1138,32 +1524,33 @@ function applySizeLimit(stream, url, limit, signal) {
|
|
|
1138
1524
|
controller.error(new DOMException("Response reading aborted", "AbortError"));
|
|
1139
1525
|
return;
|
|
1140
1526
|
}
|
|
1141
|
-
const { done, value } = await reader
|
|
1527
|
+
const { done, value } = await readWithAbort(reader, signal);
|
|
1142
1528
|
if (done) {
|
|
1529
|
+
// See readWithAbort: an aborted read also resolves as done, and
|
|
1530
|
+
// here the caller must see the error rather than a clean close.
|
|
1531
|
+
if (signal?.aborted) {
|
|
1532
|
+
await reader.cancel("aborted").catch(() => { });
|
|
1533
|
+
controller.error(new DOMException("Response reading aborted", "AbortError"));
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1143
1536
|
controller.close();
|
|
1144
1537
|
break;
|
|
1145
1538
|
}
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
limit
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
if (remaining > 0)
|
|
1162
|
-
controller.enqueue(value.slice(0, remaining));
|
|
1163
|
-
controller.close();
|
|
1164
|
-
await reader.cancel("size limit");
|
|
1165
|
-
return;
|
|
1166
|
-
}
|
|
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;
|
|
1167
1554
|
}
|
|
1168
1555
|
bytesRead += value.byteLength;
|
|
1169
1556
|
controller.enqueue(value);
|
|
@@ -1180,7 +1567,16 @@ function validateContentType(actual, expected, url) {
|
|
|
1180
1567
|
throw new ContentTypeError(expected, "(none)", url);
|
|
1181
1568
|
const normalizedActual = actual.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
1182
1569
|
const normalizedExpected = expected.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
1183
|
-
|
|
1570
|
+
// Media-type equality, not a string prefix. `startsWith` accepted
|
|
1571
|
+
// `text/plaintext` for `text/plain` and `application/json-seq` for
|
|
1572
|
+
// `application/json` — an `expectedContentType` set to narrow the parse to
|
|
1573
|
+
// one format accepted every format whose name merely begins with it. A bare
|
|
1574
|
+
// top-level type still matches the whole tree, which is the one prefix case
|
|
1575
|
+
// that means something: `image` for `image/png`.
|
|
1576
|
+
const actualType = normalizedActual.split("/")[0] ?? "";
|
|
1577
|
+
const matches = normalizedActual === normalizedExpected ||
|
|
1578
|
+
(normalizedExpected.indexOf("/") === -1 && actualType === normalizedExpected);
|
|
1579
|
+
if (!matches) {
|
|
1184
1580
|
throw new ContentTypeError(expected, actual, url);
|
|
1185
1581
|
}
|
|
1186
1582
|
}
|