arthron 1.0.0 → 1.0.1

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/dist/index.mjs CHANGED
@@ -287,6 +287,35 @@ function isReadableStream(value) {
287
287
  return tagOf(value) === "ReadableStream";
288
288
  }
289
289
  /**
290
+ * Whether `RequestCtor` can send a ReadableStream body. Node and Chromium
291
+ * read `duplex` and leave the stream alone; Firefox ignores `duplex` and
292
+ * would send the text "[object ReadableStream]"; older Safari refuses it.
293
+ * The check from
294
+ * https://developer.chrome.com/docs/capabilities/web-apis/fetch-streaming-requests
295
+ */
296
+ function canStreamRequestBody(RequestCtor) {
297
+ let duplexRead = false;
298
+ try {
299
+ const request = new RequestCtor("https://stream.test", {
300
+ method: "POST",
301
+ body: new ReadableStream(),
302
+ get duplex() {
303
+ duplexRead = true;
304
+ return "half";
305
+ }
306
+ });
307
+ return duplexRead && !request.headers.has("content-type");
308
+ } catch {
309
+ return false;
310
+ }
311
+ }
312
+ let runtimeStreams;
313
+ /** `canStreamRequestBody` for the global `Request`, checked once. */
314
+ function runtimeStreamsRequestBody() {
315
+ runtimeStreams ??= typeof Request !== "function" || canStreamRequestBody(Request);
316
+ return runtimeStreams;
317
+ }
318
+ /**
290
319
  * What JSON.stringify would turn into `{}`, `undefined` or internal state
291
320
  * instead of the data — with the fix, so it reads as an error message.
292
321
  */
@@ -338,6 +367,146 @@ function serializeBody(body, headers) {
338
367
  };
339
368
  }
340
369
  //#endregion
370
+ //#region src/response/rebuild-response.ts
371
+ /**
372
+ * A new Response with `body` but everything else taken from `original`. The
373
+ * constructor resets `url`, `redirected` and `type`, so those are copied over
374
+ * as own properties — callers that resolve relative links or detect
375
+ * redirects keep working.
376
+ */
377
+ function rebuildResponse(original, body) {
378
+ return withFetchedFields(new Response(body, {
379
+ status: original.status,
380
+ statusText: original.statusText,
381
+ headers: original.headers
382
+ }), original);
383
+ }
384
+ /** Copy the fields the constructor resets — onto `clone()`s as well. */
385
+ function withFetchedFields(target, from) {
386
+ const clone = target.clone.bind(target);
387
+ return Object.defineProperties(target, {
388
+ url: { value: from.url },
389
+ redirected: { value: from.redirected },
390
+ type: { value: from.type },
391
+ clone: { value: () => withFetchedFields(clone(), from) }
392
+ });
393
+ }
394
+ //#endregion
395
+ //#region src/error/create-error.ts
396
+ /**
397
+ * Cancellation is not an HTTP failure. Matched by name so it works across
398
+ * realms — or by the request's own `signal`, since `abort(reason)` rejects
399
+ * with whatever reason the caller chose.
400
+ */
401
+ function isCancellation(error, signal) {
402
+ if (signal?.aborted) return true;
403
+ const name = error?.name;
404
+ return name === "AbortError" || name === "TimeoutError";
405
+ }
406
+ /**
407
+ * What a cancelled call rejects with: its signal's reason. WebKit's fetch
408
+ * rejects, and errors a half-read body, with a generic AbortError instead,
409
+ * dropping a TimeoutError or the caller's own reason.
410
+ */
411
+ function cancellationError(error, signal) {
412
+ return signal?.aborted ? signal.reason : error;
413
+ }
414
+ const isRecord = (value) => typeof value === "object" && value !== null;
415
+ /** `message` → `error` (string) → `error.message` → `detail`. */
416
+ function defaultErrorDetail(data) {
417
+ if (!isRecord(data)) return void 0;
418
+ if (typeof data.message === "string") return data.message;
419
+ if (typeof data.error === "string") return data.error;
420
+ if (isRecord(data.error) && typeof data.error.message === "string") return data.error.message;
421
+ if (typeof data.detail === "string") return data.detail;
422
+ }
423
+ /** How much of an error body arthron reads. Error bodies are for diagnosis; a hostile or broken server must not be able to fill memory with one. */
424
+ const MAX_ERROR_BODY_BYTES = 65536;
425
+ /** Up to `limit` bytes of the body; the rest is cancelled unread. `undefined` when there is no body or reading it failed. */
426
+ async function readCapped(response, limit) {
427
+ if (!response.body) return void 0;
428
+ let reader;
429
+ const chunks = [];
430
+ let size = 0;
431
+ try {
432
+ reader = response.body.getReader();
433
+ while (size <= limit) {
434
+ const { value, done } = await reader.read();
435
+ if (done) break;
436
+ chunks.push(value);
437
+ size += value.byteLength;
438
+ }
439
+ } catch {
440
+ return;
441
+ } finally {
442
+ await reader?.cancel().catch(() => {});
443
+ reader?.releaseLock();
444
+ }
445
+ const bytes = new Uint8Array(size);
446
+ let offset = 0;
447
+ for (const chunk of chunks) {
448
+ bytes.set(chunk, offset);
449
+ offset += chunk.byteLength;
450
+ }
451
+ return size > limit ? {
452
+ bytes: bytes.subarray(0, limit),
453
+ truncated: true
454
+ } : {
455
+ bytes,
456
+ truncated: false
457
+ };
458
+ }
459
+ /** JSON if the whole body parses, else the text (cut at the limit), else `undefined` when empty. */
460
+ function parseBody(read) {
461
+ const text = new TextDecoder().decode(read.bytes);
462
+ if (text === "") return void 0;
463
+ if (read.truncated) return text;
464
+ try {
465
+ return JSON.parse(text);
466
+ } catch {
467
+ return text;
468
+ }
469
+ }
470
+ /** The request as error messages name it: method and URL, no query or userinfo. */
471
+ function describeRequest(request) {
472
+ return `${request.init.method ?? "GET"} ${redactUrl(request.url)}`;
473
+ }
474
+ async function responseError(response, request, resolve = defaultErrorDetail) {
475
+ const read = await readCapped(response, MAX_ERROR_BODY_BYTES);
476
+ const data = read && parseBody(read);
477
+ if (read) response = rebuildResponse(response, read.bytes);
478
+ const status = response.statusText ? `${response.status} ${response.statusText}` : String(response.status);
479
+ return new ArthronError({
480
+ message: `${describeRequest(request)} failed with ${status}`,
481
+ detail: resolve(data, response) || void 0,
482
+ status: response.status,
483
+ statusText: response.statusText,
484
+ headers: response.headers,
485
+ data,
486
+ request,
487
+ response
488
+ });
489
+ }
490
+ /**
491
+ * Browsers that stream a request body (Chromium, current WebKit) do it only
492
+ * over HTTP/2, and fail one sent to an HTTP/1.1 server with the same bare
493
+ * "Failed to fetch" / "Load failed" as a dead server, so name the likely cause.
494
+ */
495
+ const STREAM_BODY_HINT = " — in a browser, a ReadableStream body streams only over HTTP/2; send a Blob or ArrayBuffer to an HTTP/1.1 server";
496
+ function networkError(cause, request) {
497
+ const reason = cause instanceof Error && cause.message ? cause.message : "network error";
498
+ const hint = isReadableStream(request.init.body) ? STREAM_BODY_HINT : "";
499
+ return new ArthronError({
500
+ message: `${describeRequest(request)} failed: ${reason}${hint}`,
501
+ status: 0,
502
+ statusText: "",
503
+ headers: new Headers(),
504
+ data: void 0,
505
+ request,
506
+ cause
507
+ });
508
+ }
509
+ //#endregion
341
510
  //#region src/response/schema-validation.ts
342
511
  const pathOf = (issue) => (issue.path ?? []).map((part) => String(typeof part === "object" && part !== null ? part.key : part)).join(".");
343
512
  /**
@@ -471,28 +640,35 @@ async function* parseNdjson(response, { maxLineLength = DEFAULT_LIMIT } = {}) {
471
640
  //#endregion
472
641
  //#region src/response/arthron-promise.ts
473
642
  /** Defer the Response until iteration starts, so the helper itself never rejects. */
474
- function iterate(promise, parse) {
643
+ function iterate(promise, parse, signal) {
475
644
  promise.catch(() => {});
476
645
  return { async *[Symbol.asyncIterator]() {
477
- yield* parse(await promise);
646
+ try {
647
+ yield* parse(await promise);
648
+ } catch (error) {
649
+ throw cancellationError(error, signal);
650
+ }
478
651
  } };
479
652
  }
480
653
  /**
481
654
  * Sugar over `Promise<Response>`: `.json()` is `(await p).json()`. Awaiting
482
655
  * without a helper still yields the untouched `Response`.
483
656
  */
484
- function toArthronPromise(promise) {
657
+ function toArthronPromise(promise, signal) {
658
+ const read = (body) => promise.then(body).catch((error) => {
659
+ throw cancellationError(error, signal);
660
+ });
485
661
  return Object.assign(promise, {
486
- json: ((schema) => promise.then(async (res) => {
662
+ json: ((schema) => read(async (res) => {
487
663
  const data = await res.json();
488
664
  return schema ? validate(schema, data) : data;
489
665
  })),
490
- text: () => promise.then((res) => res.text()),
491
- blob: () => promise.then((res) => res.blob()),
492
- arrayBuffer: () => promise.then((res) => res.arrayBuffer()),
493
- formData: () => promise.then((res) => res.formData()),
494
- sse: (options) => iterate(promise, (res) => parseSse(res, options)),
495
- ndjson: (options) => iterate(promise, (res) => parseNdjson(res, options))
666
+ text: () => read((res) => res.text()),
667
+ blob: () => read((res) => res.blob()),
668
+ arrayBuffer: () => read((res) => res.arrayBuffer()),
669
+ formData: () => read((res) => res.formData()),
670
+ sse: (options) => iterate(promise, (res) => parseSse(res, options), signal),
671
+ ndjson: (options) => iterate(promise, (res) => parseNdjson(res, options), signal)
496
672
  });
497
673
  }
498
674
  //#endregion
@@ -547,130 +723,6 @@ function resolveConfig(config) {
547
723
  };
548
724
  }
549
725
  //#endregion
550
- //#region src/response/rebuild-response.ts
551
- /**
552
- * A new Response with `body` but everything else taken from `original`. The
553
- * constructor resets `url`, `redirected` and `type`, so those are copied over
554
- * as own properties — callers that resolve relative links or detect
555
- * redirects keep working.
556
- */
557
- function rebuildResponse(original, body) {
558
- return withFetchedFields(new Response(body, {
559
- status: original.status,
560
- statusText: original.statusText,
561
- headers: original.headers
562
- }), original);
563
- }
564
- /** Copy the fields the constructor resets — onto `clone()`s as well. */
565
- function withFetchedFields(target, from) {
566
- const clone = target.clone.bind(target);
567
- return Object.defineProperties(target, {
568
- url: { value: from.url },
569
- redirected: { value: from.redirected },
570
- type: { value: from.type },
571
- clone: { value: () => withFetchedFields(clone(), from) }
572
- });
573
- }
574
- //#endregion
575
- //#region src/error/create-error.ts
576
- /**
577
- * Cancellation is not an HTTP failure. Matched by name so it works across
578
- * realms — or by the request's own `signal`, since `abort(reason)` rejects
579
- * with whatever reason the caller chose.
580
- */
581
- function isCancellation(error, signal) {
582
- if (signal?.aborted) return true;
583
- const name = error?.name;
584
- return name === "AbortError" || name === "TimeoutError";
585
- }
586
- const isRecord = (value) => typeof value === "object" && value !== null;
587
- /** `message` → `error` (string) → `error.message` → `detail`. */
588
- function defaultErrorDetail(data) {
589
- if (!isRecord(data)) return void 0;
590
- if (typeof data.message === "string") return data.message;
591
- if (typeof data.error === "string") return data.error;
592
- if (isRecord(data.error) && typeof data.error.message === "string") return data.error.message;
593
- if (typeof data.detail === "string") return data.detail;
594
- }
595
- /** How much of an error body arthron reads. Error bodies are for diagnosis; a hostile or broken server must not be able to fill memory with one. */
596
- const MAX_ERROR_BODY_BYTES = 65536;
597
- /** Up to `limit` bytes of the body; the rest is cancelled unread. `undefined` when there is no body or reading it failed. */
598
- async function readCapped(response, limit) {
599
- if (!response.body) return void 0;
600
- let reader;
601
- const chunks = [];
602
- let size = 0;
603
- try {
604
- reader = response.body.getReader();
605
- while (size <= limit) {
606
- const { value, done } = await reader.read();
607
- if (done) break;
608
- chunks.push(value);
609
- size += value.byteLength;
610
- }
611
- } catch {
612
- return;
613
- } finally {
614
- await reader?.cancel().catch(() => {});
615
- reader?.releaseLock();
616
- }
617
- const bytes = new Uint8Array(size);
618
- let offset = 0;
619
- for (const chunk of chunks) {
620
- bytes.set(chunk, offset);
621
- offset += chunk.byteLength;
622
- }
623
- return size > limit ? {
624
- bytes: bytes.subarray(0, limit),
625
- truncated: true
626
- } : {
627
- bytes,
628
- truncated: false
629
- };
630
- }
631
- /** JSON if the whole body parses, else the text (cut at the limit), else `undefined` when empty. */
632
- function parseBody(read) {
633
- const text = new TextDecoder().decode(read.bytes);
634
- if (text === "") return void 0;
635
- if (read.truncated) return text;
636
- try {
637
- return JSON.parse(text);
638
- } catch {
639
- return text;
640
- }
641
- }
642
- /** The request as error messages name it: method and URL, no query or userinfo. */
643
- function describeRequest(request) {
644
- return `${request.init.method ?? "GET"} ${redactUrl(request.url)}`;
645
- }
646
- async function responseError(response, request, resolve = defaultErrorDetail) {
647
- const read = await readCapped(response, MAX_ERROR_BODY_BYTES);
648
- const data = read && parseBody(read);
649
- if (read) response = rebuildResponse(response, read.bytes);
650
- const status = response.statusText ? `${response.status} ${response.statusText}` : String(response.status);
651
- return new ArthronError({
652
- message: `${describeRequest(request)} failed with ${status}`,
653
- detail: resolve(data, response) || void 0,
654
- status: response.status,
655
- statusText: response.statusText,
656
- headers: response.headers,
657
- data,
658
- request,
659
- response
660
- });
661
- }
662
- function networkError(cause, request) {
663
- return new ArthronError({
664
- message: `${describeRequest(request)} failed: ${cause instanceof Error && cause.message ? cause.message : "network error"}`,
665
- status: 0,
666
- statusText: "",
667
- headers: new Headers(),
668
- data: void 0,
669
- request,
670
- cause
671
- });
672
- }
673
- //#endregion
674
726
  //#region src/client/send.ts
675
727
  /**
676
728
  * The global fetch rejects a malformed URL or method with a TypeError that
@@ -690,6 +742,16 @@ function assertSendable(url, method) {
690
742
  }
691
743
  }
692
744
  /**
745
+ * A ReadableStream body the browser cannot stream is rejected before it is
746
+ * sent: Firefox would send the text "[object ReadableStream]", older Safari
747
+ * fails with a bare network error. Only for the global fetch, like
748
+ * assertSendable.
749
+ */
750
+ function assertStreamable(url, method, body) {
751
+ if (!isReadableStream(body) || runtimeStreamsRequestBody()) return;
752
+ throw new TypeError(`arthron: cannot send ${method} ${redactUrl(url)} with a ReadableStream body — this browser cannot stream a request body and would send it garbled or not at all; pass a Blob or ArrayBuffer instead`);
753
+ }
754
+ /**
693
755
  * Non-ok responses the caller asked for are not failures: an opaque one
694
756
  * (`mode: "no-cors"`) and the 3xx of `redirect: "manual"`.
695
757
  */
@@ -700,13 +762,17 @@ function failedByDefault(res, init) {
700
762
  /** The one place a failure becomes an ArthronError. Cancellation passes through. */
701
763
  async function send(resolved, ctx, throwOn) {
702
764
  const fetchImpl = resolved.fetch ?? globalThis.fetch;
703
- if (resolved.fetch === void 0) assertSendable(ctx.url, ctx.init.method ?? "GET");
765
+ if (resolved.fetch === void 0) {
766
+ const method = ctx.init.method ?? "GET";
767
+ assertSendable(ctx.url, method);
768
+ assertStreamable(ctx.url, method, ctx.init.body);
769
+ }
704
770
  const { signal } = ctx.init;
705
771
  let res;
706
772
  try {
707
773
  res = await fetchImpl(ctx.url, ctx.init);
708
774
  } catch (thrown) {
709
- if (isCancellation(thrown, signal)) throw thrown;
775
+ if (isCancellation(thrown, signal)) throw cancellationError(thrown, signal);
710
776
  throw networkError(thrown, ctx);
711
777
  }
712
778
  if (res.ok) return res;
@@ -756,7 +822,7 @@ function arthron(config = {}) {
756
822
  url,
757
823
  init: requestInit,
758
824
  meta: { ...meta }
759
- }, (ctx) => send(resolved, ctx, throwOn)));
825
+ }, (ctx) => send(resolved, ctx, throwOn)), init.signal);
760
826
  } catch (thrown) {
761
827
  return toArthronPromise(Promise.reject(thrown));
762
828
  }