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/README.md CHANGED
@@ -16,7 +16,7 @@ const user = await api.get("/me").json<User>();
16
16
  - **Plain `Response` in, plain `Response` out.** No wrapper type to learn. `.json<T>()` and friends are there when you want the body.
17
17
  - **Two kinds of plugin, kept apart by the types.** Middleware wraps the request and controls the flow — it can call `next` again to retry. Interceptors transform one value and return it; they cannot retry, skip, or swallow. Auth refresh, retry, timeouts, logging, and anything that reacts to how a call ended (error pages, domain errors) are middleware. Headers, trace ids and per-attempt error details are interceptors.
18
18
  - **One error type, two causes.** `ArthronError` means the server said no (`!response.ok`) or the network failed (`status: 0`). Everything else — cancellation, a bug in a plugin — is rethrown as-is.
19
- - Zero dependencies. ESM and CJS. TypeScript types included. Node 22+ and browsers.
19
+ - Zero dependencies. ESM and CJS. TypeScript types included, for TypeScript 5.0+. Node 22+ and browsers.
20
20
 
21
21
  ## Install
22
22
 
@@ -355,6 +355,7 @@ class ArthronError extends Error {
355
355
  - **Redirects follow fetch's rules.** On a redirect to another origin, fetch drops `Authorization` but keeps other headers such as `x-api-key`. Pass `redirect: "error"` (or `"manual"`) for calls that carry such keys if the server could redirect off-origin.
356
356
  - **Bodies.** `string`, `FormData`, `Blob`, `ArrayBuffer`/views, `URLSearchParams` and `ReadableStream` go through untouched — recognised by type tag, so polyfill and cross-realm instances count too. A `ReadableStream` is sent with `duplex: "half"`, which fetch requires. Anything else — including `null` — becomes JSON with `content-type: application/json`; a content type you set must be a JSON one. Bodies JSON would mangle — a `Map`, `Set`, generator, Node stream or function — are rejected with a `TypeError` that says what to pass instead. A `FormData` body is always sent without a `content-type` header, even with a JSON default, so the runtime can add the multipart boundary. `GET`/`HEAD` with a body is rejected with a `TypeError`.
357
357
  - **Fetch defaults are flat and only sent when set.** Config takes the same fetch options per-call calls do (`credentials`, `cache`, `redirect`, …) as defaults; a per-call value wins and `extend` overrides. Nothing is sent that you did not set — cross-origin cookies need `credentials: "include"`. `signal`, `body` and `method` are per call: a client-wide signal would fail every call once aborted.
358
+ - **In browsers, CORS and the engine have the last word.** CI runs the built package in Chromium, Firefox and WebKit against a server on another origin. A JSON body, a custom header and `query()`'s QUERY method each make the browser send a preflight, so the API must allow them (`Access-Control-Allow-Methods: QUERY`, `Access-Control-Allow-Headers`). `retry()` reads `Retry-After` only if the API exposes it (`Access-Control-Expose-Headers: retry-after`); otherwise it waits its own delay. `redirect: "manual"` gives an opaque redirect (`status` 0, `type` `"opaqueredirect"`), returned rather than thrown. A `ReadableStream` body streams only over HTTP/2 (Chromium, WebKit), and the `ArthronError` for one sent to an HTTP/1.1 server says so. Where the browser cannot stream a request body at all (Firefox would send the text `"[object ReadableStream]"`), the call rejects with a `TypeError` before anything is sent. Either way, send a `Blob` or `ArrayBuffer` there.
358
359
  - **Response bodies are single-use.** A response interceptor that wants to inspect the body must `res.clone()`; one that changes it returns a new `Response`.
359
360
  - **Argument errors reject, they don't throw.** No `baseURL` with a relative path, a URL the global fetch cannot parse (`baseURL: "api.example.com"` without `https://`), a `BigInt` in a JSON body, a missing `method` in `request` — all surface as a rejected `TypeError` before anything is sent, never as a retried `status: 0`. An injected `fetch` is the judge of its own URLs: arthron does not pre-check them, so one that accepts relative URLs (SvelteKit's server-side `fetch`) works with `baseURL: "/api"`.
360
361
 
@@ -365,6 +366,10 @@ class ArthronError extends Error {
365
366
  - `examples/` holds type-checked, runnable recipes: LLM streaming, auth refresh, an error page, upload/download. `examples/recipes.test.ts` keeps the auth and error-page recipes honest.
366
367
  - Error messages state the fix (`… — pass baseURL in arthron({ baseURL }) or use an absolute URL`).
367
368
 
369
+ ## Security
370
+
371
+ Report vulnerabilities privately through GitHub — see [SECURITY.md](SECURITY.md) for supported versions, response times and disclosure.
372
+
368
373
  ## License
369
374
 
370
375
  MIT
package/dist/index.cjs CHANGED
@@ -288,6 +288,35 @@ function isReadableStream(value) {
288
288
  return tagOf(value) === "ReadableStream";
289
289
  }
290
290
  /**
291
+ * Whether `RequestCtor` can send a ReadableStream body. Node and Chromium
292
+ * read `duplex` and leave the stream alone; Firefox ignores `duplex` and
293
+ * would send the text "[object ReadableStream]"; older Safari refuses it.
294
+ * The check from
295
+ * https://developer.chrome.com/docs/capabilities/web-apis/fetch-streaming-requests
296
+ */
297
+ function canStreamRequestBody(RequestCtor) {
298
+ let duplexRead = false;
299
+ try {
300
+ const request = new RequestCtor("https://stream.test", {
301
+ method: "POST",
302
+ body: new ReadableStream(),
303
+ get duplex() {
304
+ duplexRead = true;
305
+ return "half";
306
+ }
307
+ });
308
+ return duplexRead && !request.headers.has("content-type");
309
+ } catch {
310
+ return false;
311
+ }
312
+ }
313
+ let runtimeStreams;
314
+ /** `canStreamRequestBody` for the global `Request`, checked once. */
315
+ function runtimeStreamsRequestBody() {
316
+ runtimeStreams ??= typeof Request !== "function" || canStreamRequestBody(Request);
317
+ return runtimeStreams;
318
+ }
319
+ /**
291
320
  * What JSON.stringify would turn into `{}`, `undefined` or internal state
292
321
  * instead of the data — with the fix, so it reads as an error message.
293
322
  */
@@ -339,6 +368,146 @@ function serializeBody(body, headers) {
339
368
  };
340
369
  }
341
370
  //#endregion
371
+ //#region src/response/rebuild-response.ts
372
+ /**
373
+ * A new Response with `body` but everything else taken from `original`. The
374
+ * constructor resets `url`, `redirected` and `type`, so those are copied over
375
+ * as own properties — callers that resolve relative links or detect
376
+ * redirects keep working.
377
+ */
378
+ function rebuildResponse(original, body) {
379
+ return withFetchedFields(new Response(body, {
380
+ status: original.status,
381
+ statusText: original.statusText,
382
+ headers: original.headers
383
+ }), original);
384
+ }
385
+ /** Copy the fields the constructor resets — onto `clone()`s as well. */
386
+ function withFetchedFields(target, from) {
387
+ const clone = target.clone.bind(target);
388
+ return Object.defineProperties(target, {
389
+ url: { value: from.url },
390
+ redirected: { value: from.redirected },
391
+ type: { value: from.type },
392
+ clone: { value: () => withFetchedFields(clone(), from) }
393
+ });
394
+ }
395
+ //#endregion
396
+ //#region src/error/create-error.ts
397
+ /**
398
+ * Cancellation is not an HTTP failure. Matched by name so it works across
399
+ * realms — or by the request's own `signal`, since `abort(reason)` rejects
400
+ * with whatever reason the caller chose.
401
+ */
402
+ function isCancellation(error, signal) {
403
+ if (signal?.aborted) return true;
404
+ const name = error?.name;
405
+ return name === "AbortError" || name === "TimeoutError";
406
+ }
407
+ /**
408
+ * What a cancelled call rejects with: its signal's reason. WebKit's fetch
409
+ * rejects, and errors a half-read body, with a generic AbortError instead,
410
+ * dropping a TimeoutError or the caller's own reason.
411
+ */
412
+ function cancellationError(error, signal) {
413
+ return signal?.aborted ? signal.reason : error;
414
+ }
415
+ const isRecord = (value) => typeof value === "object" && value !== null;
416
+ /** `message` → `error` (string) → `error.message` → `detail`. */
417
+ function defaultErrorDetail(data) {
418
+ if (!isRecord(data)) return void 0;
419
+ if (typeof data.message === "string") return data.message;
420
+ if (typeof data.error === "string") return data.error;
421
+ if (isRecord(data.error) && typeof data.error.message === "string") return data.error.message;
422
+ if (typeof data.detail === "string") return data.detail;
423
+ }
424
+ /** 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. */
425
+ const MAX_ERROR_BODY_BYTES = 65536;
426
+ /** Up to `limit` bytes of the body; the rest is cancelled unread. `undefined` when there is no body or reading it failed. */
427
+ async function readCapped(response, limit) {
428
+ if (!response.body) return void 0;
429
+ let reader;
430
+ const chunks = [];
431
+ let size = 0;
432
+ try {
433
+ reader = response.body.getReader();
434
+ while (size <= limit) {
435
+ const { value, done } = await reader.read();
436
+ if (done) break;
437
+ chunks.push(value);
438
+ size += value.byteLength;
439
+ }
440
+ } catch {
441
+ return;
442
+ } finally {
443
+ await reader?.cancel().catch(() => {});
444
+ reader?.releaseLock();
445
+ }
446
+ const bytes = new Uint8Array(size);
447
+ let offset = 0;
448
+ for (const chunk of chunks) {
449
+ bytes.set(chunk, offset);
450
+ offset += chunk.byteLength;
451
+ }
452
+ return size > limit ? {
453
+ bytes: bytes.subarray(0, limit),
454
+ truncated: true
455
+ } : {
456
+ bytes,
457
+ truncated: false
458
+ };
459
+ }
460
+ /** JSON if the whole body parses, else the text (cut at the limit), else `undefined` when empty. */
461
+ function parseBody(read) {
462
+ const text = new TextDecoder().decode(read.bytes);
463
+ if (text === "") return void 0;
464
+ if (read.truncated) return text;
465
+ try {
466
+ return JSON.parse(text);
467
+ } catch {
468
+ return text;
469
+ }
470
+ }
471
+ /** The request as error messages name it: method and URL, no query or userinfo. */
472
+ function describeRequest(request) {
473
+ return `${request.init.method ?? "GET"} ${redactUrl(request.url)}`;
474
+ }
475
+ async function responseError(response, request, resolve = defaultErrorDetail) {
476
+ const read = await readCapped(response, MAX_ERROR_BODY_BYTES);
477
+ const data = read && parseBody(read);
478
+ if (read) response = rebuildResponse(response, read.bytes);
479
+ const status = response.statusText ? `${response.status} ${response.statusText}` : String(response.status);
480
+ return new ArthronError({
481
+ message: `${describeRequest(request)} failed with ${status}`,
482
+ detail: resolve(data, response) || void 0,
483
+ status: response.status,
484
+ statusText: response.statusText,
485
+ headers: response.headers,
486
+ data,
487
+ request,
488
+ response
489
+ });
490
+ }
491
+ /**
492
+ * Browsers that stream a request body (Chromium, current WebKit) do it only
493
+ * over HTTP/2, and fail one sent to an HTTP/1.1 server with the same bare
494
+ * "Failed to fetch" / "Load failed" as a dead server, so name the likely cause.
495
+ */
496
+ 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";
497
+ function networkError(cause, request) {
498
+ const reason = cause instanceof Error && cause.message ? cause.message : "network error";
499
+ const hint = isReadableStream(request.init.body) ? STREAM_BODY_HINT : "";
500
+ return new ArthronError({
501
+ message: `${describeRequest(request)} failed: ${reason}${hint}`,
502
+ status: 0,
503
+ statusText: "",
504
+ headers: new Headers(),
505
+ data: void 0,
506
+ request,
507
+ cause
508
+ });
509
+ }
510
+ //#endregion
342
511
  //#region src/response/schema-validation.ts
343
512
  const pathOf = (issue) => (issue.path ?? []).map((part) => String(typeof part === "object" && part !== null ? part.key : part)).join(".");
344
513
  /**
@@ -472,28 +641,35 @@ async function* parseNdjson(response, { maxLineLength = DEFAULT_LIMIT } = {}) {
472
641
  //#endregion
473
642
  //#region src/response/arthron-promise.ts
474
643
  /** Defer the Response until iteration starts, so the helper itself never rejects. */
475
- function iterate(promise, parse) {
644
+ function iterate(promise, parse, signal) {
476
645
  promise.catch(() => {});
477
646
  return { async *[Symbol.asyncIterator]() {
478
- yield* parse(await promise);
647
+ try {
648
+ yield* parse(await promise);
649
+ } catch (error) {
650
+ throw cancellationError(error, signal);
651
+ }
479
652
  } };
480
653
  }
481
654
  /**
482
655
  * Sugar over `Promise<Response>`: `.json()` is `(await p).json()`. Awaiting
483
656
  * without a helper still yields the untouched `Response`.
484
657
  */
485
- function toArthronPromise(promise) {
658
+ function toArthronPromise(promise, signal) {
659
+ const read = (body) => promise.then(body).catch((error) => {
660
+ throw cancellationError(error, signal);
661
+ });
486
662
  return Object.assign(promise, {
487
- json: ((schema) => promise.then(async (res) => {
663
+ json: ((schema) => read(async (res) => {
488
664
  const data = await res.json();
489
665
  return schema ? validate(schema, data) : data;
490
666
  })),
491
- text: () => promise.then((res) => res.text()),
492
- blob: () => promise.then((res) => res.blob()),
493
- arrayBuffer: () => promise.then((res) => res.arrayBuffer()),
494
- formData: () => promise.then((res) => res.formData()),
495
- sse: (options) => iterate(promise, (res) => parseSse(res, options)),
496
- ndjson: (options) => iterate(promise, (res) => parseNdjson(res, options))
667
+ text: () => read((res) => res.text()),
668
+ blob: () => read((res) => res.blob()),
669
+ arrayBuffer: () => read((res) => res.arrayBuffer()),
670
+ formData: () => read((res) => res.formData()),
671
+ sse: (options) => iterate(promise, (res) => parseSse(res, options), signal),
672
+ ndjson: (options) => iterate(promise, (res) => parseNdjson(res, options), signal)
497
673
  });
498
674
  }
499
675
  //#endregion
@@ -548,130 +724,6 @@ function resolveConfig(config) {
548
724
  };
549
725
  }
550
726
  //#endregion
551
- //#region src/response/rebuild-response.ts
552
- /**
553
- * A new Response with `body` but everything else taken from `original`. The
554
- * constructor resets `url`, `redirected` and `type`, so those are copied over
555
- * as own properties — callers that resolve relative links or detect
556
- * redirects keep working.
557
- */
558
- function rebuildResponse(original, body) {
559
- return withFetchedFields(new Response(body, {
560
- status: original.status,
561
- statusText: original.statusText,
562
- headers: original.headers
563
- }), original);
564
- }
565
- /** Copy the fields the constructor resets — onto `clone()`s as well. */
566
- function withFetchedFields(target, from) {
567
- const clone = target.clone.bind(target);
568
- return Object.defineProperties(target, {
569
- url: { value: from.url },
570
- redirected: { value: from.redirected },
571
- type: { value: from.type },
572
- clone: { value: () => withFetchedFields(clone(), from) }
573
- });
574
- }
575
- //#endregion
576
- //#region src/error/create-error.ts
577
- /**
578
- * Cancellation is not an HTTP failure. Matched by name so it works across
579
- * realms — or by the request's own `signal`, since `abort(reason)` rejects
580
- * with whatever reason the caller chose.
581
- */
582
- function isCancellation(error, signal) {
583
- if (signal?.aborted) return true;
584
- const name = error?.name;
585
- return name === "AbortError" || name === "TimeoutError";
586
- }
587
- const isRecord = (value) => typeof value === "object" && value !== null;
588
- /** `message` → `error` (string) → `error.message` → `detail`. */
589
- function defaultErrorDetail(data) {
590
- if (!isRecord(data)) return void 0;
591
- if (typeof data.message === "string") return data.message;
592
- if (typeof data.error === "string") return data.error;
593
- if (isRecord(data.error) && typeof data.error.message === "string") return data.error.message;
594
- if (typeof data.detail === "string") return data.detail;
595
- }
596
- /** 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. */
597
- const MAX_ERROR_BODY_BYTES = 65536;
598
- /** Up to `limit` bytes of the body; the rest is cancelled unread. `undefined` when there is no body or reading it failed. */
599
- async function readCapped(response, limit) {
600
- if (!response.body) return void 0;
601
- let reader;
602
- const chunks = [];
603
- let size = 0;
604
- try {
605
- reader = response.body.getReader();
606
- while (size <= limit) {
607
- const { value, done } = await reader.read();
608
- if (done) break;
609
- chunks.push(value);
610
- size += value.byteLength;
611
- }
612
- } catch {
613
- return;
614
- } finally {
615
- await reader?.cancel().catch(() => {});
616
- reader?.releaseLock();
617
- }
618
- const bytes = new Uint8Array(size);
619
- let offset = 0;
620
- for (const chunk of chunks) {
621
- bytes.set(chunk, offset);
622
- offset += chunk.byteLength;
623
- }
624
- return size > limit ? {
625
- bytes: bytes.subarray(0, limit),
626
- truncated: true
627
- } : {
628
- bytes,
629
- truncated: false
630
- };
631
- }
632
- /** JSON if the whole body parses, else the text (cut at the limit), else `undefined` when empty. */
633
- function parseBody(read) {
634
- const text = new TextDecoder().decode(read.bytes);
635
- if (text === "") return void 0;
636
- if (read.truncated) return text;
637
- try {
638
- return JSON.parse(text);
639
- } catch {
640
- return text;
641
- }
642
- }
643
- /** The request as error messages name it: method and URL, no query or userinfo. */
644
- function describeRequest(request) {
645
- return `${request.init.method ?? "GET"} ${redactUrl(request.url)}`;
646
- }
647
- async function responseError(response, request, resolve = defaultErrorDetail) {
648
- const read = await readCapped(response, MAX_ERROR_BODY_BYTES);
649
- const data = read && parseBody(read);
650
- if (read) response = rebuildResponse(response, read.bytes);
651
- const status = response.statusText ? `${response.status} ${response.statusText}` : String(response.status);
652
- return new ArthronError({
653
- message: `${describeRequest(request)} failed with ${status}`,
654
- detail: resolve(data, response) || void 0,
655
- status: response.status,
656
- statusText: response.statusText,
657
- headers: response.headers,
658
- data,
659
- request,
660
- response
661
- });
662
- }
663
- function networkError(cause, request) {
664
- return new ArthronError({
665
- message: `${describeRequest(request)} failed: ${cause instanceof Error && cause.message ? cause.message : "network error"}`,
666
- status: 0,
667
- statusText: "",
668
- headers: new Headers(),
669
- data: void 0,
670
- request,
671
- cause
672
- });
673
- }
674
- //#endregion
675
727
  //#region src/client/send.ts
676
728
  /**
677
729
  * The global fetch rejects a malformed URL or method with a TypeError that
@@ -691,6 +743,16 @@ function assertSendable(url, method) {
691
743
  }
692
744
  }
693
745
  /**
746
+ * A ReadableStream body the browser cannot stream is rejected before it is
747
+ * sent: Firefox would send the text "[object ReadableStream]", older Safari
748
+ * fails with a bare network error. Only for the global fetch, like
749
+ * assertSendable.
750
+ */
751
+ function assertStreamable(url, method, body) {
752
+ if (!isReadableStream(body) || runtimeStreamsRequestBody()) return;
753
+ 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`);
754
+ }
755
+ /**
694
756
  * Non-ok responses the caller asked for are not failures: an opaque one
695
757
  * (`mode: "no-cors"`) and the 3xx of `redirect: "manual"`.
696
758
  */
@@ -701,13 +763,17 @@ function failedByDefault(res, init) {
701
763
  /** The one place a failure becomes an ArthronError. Cancellation passes through. */
702
764
  async function send(resolved, ctx, throwOn) {
703
765
  const fetchImpl = resolved.fetch ?? globalThis.fetch;
704
- if (resolved.fetch === void 0) assertSendable(ctx.url, ctx.init.method ?? "GET");
766
+ if (resolved.fetch === void 0) {
767
+ const method = ctx.init.method ?? "GET";
768
+ assertSendable(ctx.url, method);
769
+ assertStreamable(ctx.url, method, ctx.init.body);
770
+ }
705
771
  const { signal } = ctx.init;
706
772
  let res;
707
773
  try {
708
774
  res = await fetchImpl(ctx.url, ctx.init);
709
775
  } catch (thrown) {
710
- if (isCancellation(thrown, signal)) throw thrown;
776
+ if (isCancellation(thrown, signal)) throw cancellationError(thrown, signal);
711
777
  throw networkError(thrown, ctx);
712
778
  }
713
779
  if (res.ok) return res;
@@ -757,7 +823,7 @@ function arthron(config = {}) {
757
823
  url,
758
824
  init: requestInit,
759
825
  meta: { ...meta }
760
- }, (ctx) => send(resolved, ctx, throwOn)));
826
+ }, (ctx) => send(resolved, ctx, throwOn)), init.signal);
761
827
  } catch (thrown) {
762
828
  return toArthronPromise(Promise.reject(thrown));
763
829
  }