arthron 2.0.1 → 2.1.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/dist/shared.cjs CHANGED
@@ -13,7 +13,7 @@ function redactUrl(url) {
13
13
  const parsed = new URL(url);
14
14
  return `${parsed.origin}${parsed.pathname}`;
15
15
  } catch {
16
- return url.split(/[?#]/)[0] ?? url;
16
+ return withoutSecrets(url);
17
17
  }
18
18
  }
19
19
  /**
@@ -66,9 +66,15 @@ function httpOrigin(url) {
66
66
  function httpOrigins(urls, errors) {
67
67
  if (urls.length === 0) throw new TypeError(errors.empty);
68
68
  return new Set(urls.map((url) => {
69
- const origin = httpOrigin(url);
69
+ const value = url;
70
+ if (typeof value !== "string") {
71
+ const kind = value === null ? "null" : typeof value;
72
+ const shown = kind === "null" ? kind : `${/^[aeiou]/.test(kind) ? "an" : "a"} ${kind}`;
73
+ throw new TypeError(errors.invalid(shown));
74
+ }
75
+ const origin = httpOrigin(value);
70
76
  if (origin) return origin;
71
- throw new TypeError(errors.invalid(JSON.stringify(withoutSecrets(url))));
77
+ throw new TypeError(errors.invalid(JSON.stringify(withoutSecrets(value))));
72
78
  }));
73
79
  }
74
80
  /** `allowedOrigins` as origins, checked once when the client is created. */
@@ -326,7 +332,7 @@ function serializeBody(body, headers) {
326
332
  * redirects keep working.
327
333
  */
328
334
  function rebuildResponse(original, body) {
329
- const rebuilt = new Response(body, {
335
+ const rebuilt = createResponse(body, {
330
336
  status: original.status,
331
337
  statusText: original.statusText,
332
338
  headers: original.headers
@@ -338,6 +344,37 @@ function rebuildResponse(original, body) {
338
344
  type
339
345
  });
340
346
  }
347
+ /** A status text `new Response` takes: tab, space, visible ASCII, Latin-1. */
348
+ const REASON_PHRASE = /^[\t\x20-\x7e\x80-\xff]*$/;
349
+ /**
350
+ * `new Response(body, init)` for anything fetch hands over. The constructor
351
+ * takes a status of 200–599 and a Latin-1 status text, but fetch passes on
352
+ * what a server sends (LinkedIn answers bots with 999, and Node's fetch reads
353
+ * a reason phrase as UTF-8). One it refuses is built as 599 or "" and set
354
+ * back as an own property; built as 599, an unusual status is never `ok`.
355
+ */
356
+ function createResponse(body, init) {
357
+ const { status, statusText = "" } = init;
358
+ const statusFits = status >= 200 && status <= 599;
359
+ const textFits = REASON_PHRASE.test(statusText);
360
+ if (statusFits && textFits) return new Response(body, init);
361
+ return withStatus(new Response(body, {
362
+ ...init,
363
+ status: statusFits ? status : 599,
364
+ statusText: textFits ? statusText : ""
365
+ }), status, statusText);
366
+ }
367
+ function withStatus(target, status, statusText) {
368
+ const clone = target.clone.bind(target);
369
+ return Object.defineProperties(target, {
370
+ status: { value: status },
371
+ statusText: { value: statusText },
372
+ clone: {
373
+ value: () => withStatus(clone(), status, statusText),
374
+ configurable: true
375
+ }
376
+ });
377
+ }
341
378
  /** Set those fields on `target` — and on its `clone()`s as well. */
342
379
  function withFields(target, fields) {
343
380
  const clone = target.clone.bind(target);
@@ -357,8 +394,11 @@ function withFields(target, fields) {
357
394
  */
358
395
  function isCancellation(error, signal) {
359
396
  if (signal?.aborted) return true;
360
- const name = error?.name;
361
- return name === "AbortError" || name === "TimeoutError";
397
+ return error?.name === "AbortError" || isTimeout(error);
398
+ }
399
+ /** A deadline ran out: `timeout()`, or `AbortSignal.timeout()` as the signal. Matched by name, like isCancellation. */
400
+ function isTimeout(error) {
401
+ return error?.name === "TimeoutError";
362
402
  }
363
403
  /**
364
404
  * What a cancelled call rejects with: its signal's reason. WebKit's fetch
@@ -390,16 +430,16 @@ async function readCapped(response, limit) {
390
430
  while (size <= limit) {
391
431
  const { value, done } = await reader.read();
392
432
  if (done) break;
393
- chunks.push(value);
433
+ chunks.push(value.subarray(0, Math.max(0, limit - size)));
394
434
  size += value.byteLength;
395
435
  }
396
436
  } catch {
397
437
  return;
398
438
  } finally {
399
- await reader?.cancel().catch(() => {});
439
+ reader?.cancel().catch(() => {});
400
440
  reader?.releaseLock();
401
441
  }
402
- const bytes = new Uint8Array(size);
442
+ const bytes = new Uint8Array(Math.min(size, limit));
403
443
  let offset = 0;
404
444
  for (const chunk of chunks) {
405
445
  bytes.set(chunk, offset);
@@ -454,17 +494,64 @@ async function responseError(response, requestContext, extractDescription = defa
454
494
  * "Failed to fetch" / "Load failed" as a dead server, so name the likely cause.
455
495
  */
456
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
+ /** `error[key]`, or `undefined` if reading it throws. */
498
+ function readField(error, key) {
499
+ try {
500
+ return error[key];
501
+ } catch {
502
+ return;
503
+ }
504
+ }
505
+ /**
506
+ * An error that cannot be changed, copied with its prototype and own fields
507
+ * so the copy can be. Values are read through the original: V8's own
508
+ * `stack` accessor reads nothing on another object, and DOMException's
509
+ * `name`, `message` and `code` getters throw on one. Those become writable
510
+ * fields: stack rewriters assign to `stack`.
511
+ */
512
+ function copyError(error) {
513
+ const descriptors = Object.getOwnPropertyDescriptors(error);
514
+ for (const key of Reflect.ownKeys(descriptors)) {
515
+ const descriptor = descriptors[key];
516
+ descriptors[key] = descriptor.get || descriptor.set ? {
517
+ value: readField(error, key),
518
+ writable: true,
519
+ enumerable: descriptor.enumerable ?? false,
520
+ configurable: true
521
+ } : {
522
+ ...descriptor,
523
+ configurable: true
524
+ };
525
+ }
526
+ for (const key of [
527
+ "name",
528
+ "message",
529
+ "stack",
530
+ "code"
531
+ ]) {
532
+ if (Object.hasOwn(descriptors, key)) continue;
533
+ const value = readField(error, key);
534
+ if (value !== void 0) descriptors[key] = {
535
+ value,
536
+ writable: true,
537
+ configurable: true
538
+ };
539
+ }
540
+ return Object.create(Object.getPrototypeOf(error), descriptors);
541
+ }
457
542
  /**
458
543
  * Take `url`'s query and userinfo out of an error the runtime threw, in
459
- * place: its message and stack, string fields (Node's `input`), and its
460
- * causes. `console.error` and error trackers print that chain, not
461
- * `toJSON()`. It stays the same object, so `cause.code` and `instanceof`
462
- * still work; a field that cannot be redefined (a frozen error) is left.
544
+ * place when possible: its message and stack, string fields (Node's
545
+ * `input`), and its causes. An immutable error is copied with its prototype
546
+ * and fields so it cannot put the full URL back into logs via `cause`.
463
547
  */
464
- function redactErrorUrl(error, url, seen = /* @__PURE__ */ new Set()) {
465
- if (typeof error !== "object" || error === null || seen.has(error)) return;
466
- seen.add(error);
548
+ function redactErrorUrl(error, url, seen = /* @__PURE__ */ new Map()) {
549
+ if (typeof error === "string") return redactUrlIn(error, url);
550
+ if (typeof error !== "object" || error === null) return error;
551
+ if (seen.has(error)) return seen.get(error);
467
552
  const fields = error;
553
+ let safe = error;
554
+ const changes = /* @__PURE__ */ new Map();
468
555
  for (const key of /* @__PURE__ */ new Set([
469
556
  "message",
470
557
  "stack",
@@ -474,15 +561,32 @@ function redactErrorUrl(error, url, seen = /* @__PURE__ */ new Set()) {
474
561
  if (typeof value !== "string") continue;
475
562
  const redacted = redactUrlIn(value, url);
476
563
  if (redacted === value) continue;
477
- try {
564
+ changes.set(key, redacted);
565
+ if (safe === error) try {
478
566
  Object.defineProperty(error, key, { value: redacted });
479
- } catch {}
567
+ } catch {
568
+ safe = copyError(error);
569
+ }
570
+ }
571
+ seen.set(error, safe);
572
+ if (safe !== error) for (const [key, value] of changes) Object.defineProperty(safe, key, {
573
+ value,
574
+ configurable: true
575
+ });
576
+ const cause = fields.cause;
577
+ const safeCause = redactErrorUrl(cause, url, seen);
578
+ if (safeCause !== cause) try {
579
+ Object.defineProperty(safe, "cause", { value: safeCause });
580
+ } catch {
581
+ safe = copyError(safe);
582
+ Object.defineProperty(safe, "cause", { value: safeCause });
583
+ seen.set(error, safe);
480
584
  }
481
- redactErrorUrl(fields.cause, url, seen);
585
+ return safe;
482
586
  }
483
587
  function networkError(cause, requestContext) {
484
- redactErrorUrl(cause, requestContext.url);
485
- const reason = cause instanceof Error && cause.message ? cause.message : "network error";
588
+ const safeCause = redactErrorUrl(cause, requestContext.url);
589
+ const reason = safeCause instanceof Error && safeCause.message ? safeCause.message : "network error";
486
590
  const hint = isReadableStream(requestContext.init.body) ? STREAM_BODY_HINT : "";
487
591
  return new ArthronError({
488
592
  message: `${describeRequest(requestContext)} failed: ${reason}${hint}`,
@@ -491,7 +595,7 @@ function networkError(cause, requestContext) {
491
595
  headers: new Headers(),
492
596
  payload: void 0,
493
597
  requestContext,
494
- cause
598
+ cause: safeCause
495
599
  });
496
600
  }
497
601
  //#endregion
@@ -507,6 +611,12 @@ Object.defineProperty(exports, "cancellationError", {
507
611
  return cancellationError;
508
612
  }
509
613
  });
614
+ Object.defineProperty(exports, "createResponse", {
615
+ enumerable: true,
616
+ get: function() {
617
+ return createResponse;
618
+ }
619
+ });
510
620
  Object.defineProperty(exports, "fillPath", {
511
621
  enumerable: true,
512
622
  get: function() {
@@ -543,6 +653,12 @@ Object.defineProperty(exports, "isReadableStream", {
543
653
  return isReadableStream;
544
654
  }
545
655
  });
656
+ Object.defineProperty(exports, "isTimeout", {
657
+ enumerable: true,
658
+ get: function() {
659
+ return isTimeout;
660
+ }
661
+ });
546
662
  Object.defineProperty(exports, "leavesBase", {
547
663
  enumerable: true,
548
664
  get: function() {
package/dist/shared.mjs CHANGED
@@ -13,7 +13,7 @@ function redactUrl(url) {
13
13
  const parsed = new URL(url);
14
14
  return `${parsed.origin}${parsed.pathname}`;
15
15
  } catch {
16
- return url.split(/[?#]/)[0] ?? url;
16
+ return withoutSecrets(url);
17
17
  }
18
18
  }
19
19
  /**
@@ -66,9 +66,15 @@ function httpOrigin(url) {
66
66
  function httpOrigins(urls, errors) {
67
67
  if (urls.length === 0) throw new TypeError(errors.empty);
68
68
  return new Set(urls.map((url) => {
69
- const origin = httpOrigin(url);
69
+ const value = url;
70
+ if (typeof value !== "string") {
71
+ const kind = value === null ? "null" : typeof value;
72
+ const shown = kind === "null" ? kind : `${/^[aeiou]/.test(kind) ? "an" : "a"} ${kind}`;
73
+ throw new TypeError(errors.invalid(shown));
74
+ }
75
+ const origin = httpOrigin(value);
70
76
  if (origin) return origin;
71
- throw new TypeError(errors.invalid(JSON.stringify(withoutSecrets(url))));
77
+ throw new TypeError(errors.invalid(JSON.stringify(withoutSecrets(value))));
72
78
  }));
73
79
  }
74
80
  /** `allowedOrigins` as origins, checked once when the client is created. */
@@ -326,7 +332,7 @@ function serializeBody(body, headers) {
326
332
  * redirects keep working.
327
333
  */
328
334
  function rebuildResponse(original, body) {
329
- const rebuilt = new Response(body, {
335
+ const rebuilt = createResponse(body, {
330
336
  status: original.status,
331
337
  statusText: original.statusText,
332
338
  headers: original.headers
@@ -338,6 +344,37 @@ function rebuildResponse(original, body) {
338
344
  type
339
345
  });
340
346
  }
347
+ /** A status text `new Response` takes: tab, space, visible ASCII, Latin-1. */
348
+ const REASON_PHRASE = /^[\t\x20-\x7e\x80-\xff]*$/;
349
+ /**
350
+ * `new Response(body, init)` for anything fetch hands over. The constructor
351
+ * takes a status of 200–599 and a Latin-1 status text, but fetch passes on
352
+ * what a server sends (LinkedIn answers bots with 999, and Node's fetch reads
353
+ * a reason phrase as UTF-8). One it refuses is built as 599 or "" and set
354
+ * back as an own property; built as 599, an unusual status is never `ok`.
355
+ */
356
+ function createResponse(body, init) {
357
+ const { status, statusText = "" } = init;
358
+ const statusFits = status >= 200 && status <= 599;
359
+ const textFits = REASON_PHRASE.test(statusText);
360
+ if (statusFits && textFits) return new Response(body, init);
361
+ return withStatus(new Response(body, {
362
+ ...init,
363
+ status: statusFits ? status : 599,
364
+ statusText: textFits ? statusText : ""
365
+ }), status, statusText);
366
+ }
367
+ function withStatus(target, status, statusText) {
368
+ const clone = target.clone.bind(target);
369
+ return Object.defineProperties(target, {
370
+ status: { value: status },
371
+ statusText: { value: statusText },
372
+ clone: {
373
+ value: () => withStatus(clone(), status, statusText),
374
+ configurable: true
375
+ }
376
+ });
377
+ }
341
378
  /** Set those fields on `target` — and on its `clone()`s as well. */
342
379
  function withFields(target, fields) {
343
380
  const clone = target.clone.bind(target);
@@ -357,8 +394,11 @@ function withFields(target, fields) {
357
394
  */
358
395
  function isCancellation(error, signal) {
359
396
  if (signal?.aborted) return true;
360
- const name = error?.name;
361
- return name === "AbortError" || name === "TimeoutError";
397
+ return error?.name === "AbortError" || isTimeout(error);
398
+ }
399
+ /** A deadline ran out: `timeout()`, or `AbortSignal.timeout()` as the signal. Matched by name, like isCancellation. */
400
+ function isTimeout(error) {
401
+ return error?.name === "TimeoutError";
362
402
  }
363
403
  /**
364
404
  * What a cancelled call rejects with: its signal's reason. WebKit's fetch
@@ -390,16 +430,16 @@ async function readCapped(response, limit) {
390
430
  while (size <= limit) {
391
431
  const { value, done } = await reader.read();
392
432
  if (done) break;
393
- chunks.push(value);
433
+ chunks.push(value.subarray(0, Math.max(0, limit - size)));
394
434
  size += value.byteLength;
395
435
  }
396
436
  } catch {
397
437
  return;
398
438
  } finally {
399
- await reader?.cancel().catch(() => {});
439
+ reader?.cancel().catch(() => {});
400
440
  reader?.releaseLock();
401
441
  }
402
- const bytes = new Uint8Array(size);
442
+ const bytes = new Uint8Array(Math.min(size, limit));
403
443
  let offset = 0;
404
444
  for (const chunk of chunks) {
405
445
  bytes.set(chunk, offset);
@@ -454,17 +494,64 @@ async function responseError(response, requestContext, extractDescription = defa
454
494
  * "Failed to fetch" / "Load failed" as a dead server, so name the likely cause.
455
495
  */
456
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
+ /** `error[key]`, or `undefined` if reading it throws. */
498
+ function readField(error, key) {
499
+ try {
500
+ return error[key];
501
+ } catch {
502
+ return;
503
+ }
504
+ }
505
+ /**
506
+ * An error that cannot be changed, copied with its prototype and own fields
507
+ * so the copy can be. Values are read through the original: V8's own
508
+ * `stack` accessor reads nothing on another object, and DOMException's
509
+ * `name`, `message` and `code` getters throw on one. Those become writable
510
+ * fields: stack rewriters assign to `stack`.
511
+ */
512
+ function copyError(error) {
513
+ const descriptors = Object.getOwnPropertyDescriptors(error);
514
+ for (const key of Reflect.ownKeys(descriptors)) {
515
+ const descriptor = descriptors[key];
516
+ descriptors[key] = descriptor.get || descriptor.set ? {
517
+ value: readField(error, key),
518
+ writable: true,
519
+ enumerable: descriptor.enumerable ?? false,
520
+ configurable: true
521
+ } : {
522
+ ...descriptor,
523
+ configurable: true
524
+ };
525
+ }
526
+ for (const key of [
527
+ "name",
528
+ "message",
529
+ "stack",
530
+ "code"
531
+ ]) {
532
+ if (Object.hasOwn(descriptors, key)) continue;
533
+ const value = readField(error, key);
534
+ if (value !== void 0) descriptors[key] = {
535
+ value,
536
+ writable: true,
537
+ configurable: true
538
+ };
539
+ }
540
+ return Object.create(Object.getPrototypeOf(error), descriptors);
541
+ }
457
542
  /**
458
543
  * Take `url`'s query and userinfo out of an error the runtime threw, in
459
- * place: its message and stack, string fields (Node's `input`), and its
460
- * causes. `console.error` and error trackers print that chain, not
461
- * `toJSON()`. It stays the same object, so `cause.code` and `instanceof`
462
- * still work; a field that cannot be redefined (a frozen error) is left.
544
+ * place when possible: its message and stack, string fields (Node's
545
+ * `input`), and its causes. An immutable error is copied with its prototype
546
+ * and fields so it cannot put the full URL back into logs via `cause`.
463
547
  */
464
- function redactErrorUrl(error, url, seen = /* @__PURE__ */ new Set()) {
465
- if (typeof error !== "object" || error === null || seen.has(error)) return;
466
- seen.add(error);
548
+ function redactErrorUrl(error, url, seen = /* @__PURE__ */ new Map()) {
549
+ if (typeof error === "string") return redactUrlIn(error, url);
550
+ if (typeof error !== "object" || error === null) return error;
551
+ if (seen.has(error)) return seen.get(error);
467
552
  const fields = error;
553
+ let safe = error;
554
+ const changes = /* @__PURE__ */ new Map();
468
555
  for (const key of /* @__PURE__ */ new Set([
469
556
  "message",
470
557
  "stack",
@@ -474,15 +561,32 @@ function redactErrorUrl(error, url, seen = /* @__PURE__ */ new Set()) {
474
561
  if (typeof value !== "string") continue;
475
562
  const redacted = redactUrlIn(value, url);
476
563
  if (redacted === value) continue;
477
- try {
564
+ changes.set(key, redacted);
565
+ if (safe === error) try {
478
566
  Object.defineProperty(error, key, { value: redacted });
479
- } catch {}
567
+ } catch {
568
+ safe = copyError(error);
569
+ }
570
+ }
571
+ seen.set(error, safe);
572
+ if (safe !== error) for (const [key, value] of changes) Object.defineProperty(safe, key, {
573
+ value,
574
+ configurable: true
575
+ });
576
+ const cause = fields.cause;
577
+ const safeCause = redactErrorUrl(cause, url, seen);
578
+ if (safeCause !== cause) try {
579
+ Object.defineProperty(safe, "cause", { value: safeCause });
580
+ } catch {
581
+ safe = copyError(safe);
582
+ Object.defineProperty(safe, "cause", { value: safeCause });
583
+ seen.set(error, safe);
480
584
  }
481
- redactErrorUrl(fields.cause, url, seen);
585
+ return safe;
482
586
  }
483
587
  function networkError(cause, requestContext) {
484
- redactErrorUrl(cause, requestContext.url);
485
- const reason = cause instanceof Error && cause.message ? cause.message : "network error";
588
+ const safeCause = redactErrorUrl(cause, requestContext.url);
589
+ const reason = safeCause instanceof Error && safeCause.message ? safeCause.message : "network error";
486
590
  const hint = isReadableStream(requestContext.init.body) ? STREAM_BODY_HINT : "";
487
591
  return new ArthronError({
488
592
  message: `${describeRequest(requestContext)} failed: ${reason}${hint}`,
@@ -491,8 +595,8 @@ function networkError(cause, requestContext) {
491
595
  headers: new Headers(),
492
596
  payload: void 0,
493
597
  requestContext,
494
- cause
598
+ cause: safeCause
495
599
  });
496
600
  }
497
601
  //#endregion
498
- export { withoutSecrets as C, resolveUrl as S, leavesBase as _, responseError as a, redactUrl as b, isReadableStream as c, tagOf as d, ArthronError as f, httpOrigins as g, httpOrigin as h, redactErrorUrl as i, runtimeStreamsRequestBody as l, fillPath as m, isCancellation as n, rebuildResponse as o, isArthronError as p, networkError as r, withFields as s, cancellationError as t, serializeBody as u, originOf as v, redactUrlIn as x, originSet as y };
602
+ export { redactUrlIn as C, redactUrl as S, withoutSecrets as T, httpOrigin as _, redactErrorUrl as a, originOf as b, rebuildResponse as c, runtimeStreamsRequestBody as d, serializeBody as f, fillPath as g, isArthronError as h, networkError as i, withFields as l, ArthronError as m, isCancellation as n, responseError as o, tagOf as p, isTimeout as r, createResponse as s, cancellationError as t, isReadableStream as u, httpOrigins as v, resolveUrl as w, originSet as x, leavesBase as y };
package/llms.txt CHANGED
@@ -30,13 +30,13 @@ api.request(path, { method, body?, ...requestOptions }) // method: HttpMethod
30
30
  // requestOptions = RequestInit minus method/body, plus queryParams?: Record<string, scalar | scalar[] | null | undefined> | URLSearchParams (arrays → repeated keys; object values → TypeError); headers values may be null to drop a default; meta?: ArthronMeta (plugin settings, not sent); pathParams?: Record<string, string|number> fills {name} placeholders, encoded per segment ("", ".", ".." refused) — use it instead of interpolating ids
31
31
  ```
32
32
 
33
- Every method returns `Promise<Response>` plus helpers:
33
+ Every request method returns `Promise<Response>` plus helpers:
34
34
  `.json<T>()` (cast) / `.json(schema)` (an `ArthronSchema`: `StandardSchemaV1` from @standard-schema/spec under arthron's name — pass a Zod, Valibot or ArkType schema as is; typed as `ArthronSchema.Infer<S>`; a mismatch → `SchemaMismatchError` with `.issues: ArthronSchema.Issue[]`, not an ArthronError) `.text()` `.blob()` `.arrayBuffer()` `.formData()` `.sse({ maxLineLength?, maxDataLength? }?): AsyncIterable<{ type, data, id?, lastEventId, reconnectionTime? }>` (`type` is the `event:` field, `"message"` when there is none, as in a browser; `lastEventId` and `reconnectionTime` (ms, from `retry:`) persist across events, per spec; no reconnection) `.ndjson<T>({ maxLineLength? }?): AsyncIterable<T>` — limits default to 8 Mi characters; exceeding one rejects with RangeError and cancels the body
35
35
 
36
36
  - Body: string/FormData/Blob/ArrayBuffer/URLSearchParams/ReadableStream sent as-is (matched by type tag, so polyfill/cross-realm instances count; streams get `duplex: "half"`; FormData is sent without content-type so the runtime sets the boundary). Any other value (incl. null) → JSON + `content-type: application/json`; a content type you set must be JSON-ish (else TypeError). Map/Set/iterables/async iterables (Node streams)/functions → TypeError. GET/HEAD with body → TypeError.
37
37
  - URL: `baseURL` + path by string concatenation (`"https://a/v1" + "/u"` → `https://a/v1/u`). An absolute path must be http(s); with a baseURL it must be on baseURL's origin or in `allowedOrigins`, without one in `allowedOrigins` if set — else TypeError (with neither set, any http(s) URL goes). Config headers go to baseURL's origin only, never to an `allowedOrigins` entry (calls there carry per-call headers alone); a client with neither baseURL nor allowedOrigins sends them everywhere. On cross-origin redirects fetch drops Authorization but keeps custom headers (x-api-key) — use `redirect: "error"` where that matters. `queryParams` appended with `&` if a query exists, placed before `#fragment`.
38
38
  - Argument errors (no baseURL, a URL that does not parse, BigInt body, missing method) reject the promise with TypeError before fetch; they do not throw synchronously and are never retried. URLs are pre-checked only for the global fetch; a `fetchFn` you pass decides its own URL rules (relative URLs are fine if it accepts them).
39
- - `.sse()`/`.ndjson()`: leaving the loop early cancels the body. `.sse()` drops an event the stream ends inside (spec).
39
+ - `.sse()`/`.ndjson()`: leaving the loop early cancels the body. `.sse()` drops an incomplete event at the end of the stream (spec).
40
40
 
41
41
  ## Errors
42
42
 
@@ -57,8 +57,8 @@ class ArthronError extends Error {
57
57
  isArthronError(e): e is ArthronError // branded with Symbol.for: works (and instanceof works) across the ESM and CJS copies
58
58
  ```
59
59
 
60
- - Only `!response.ok` and fetch rejection become ArthronError — and not every non-ok: opaque responses (no-cors) and the 3xx of `redirect: "manual"` are returned; `throwOn(response)` (config or per call) narrows further. An ok response never rejects. Aborts (`AbortError`/`TimeoutError` or any custom `abort(reason)`, including during the error-body read) and exceptions thrown by plugins propagate untouched and skip error interceptors.
61
- - Serializing an ArthronError (JSON.stringify, console.error, error trackers, logging onEvent) never includes request headers, body, query or userinfo. Error messages arthron writes leave the query out too.
60
+ - Only `!response.ok` and fetch rejection become ArthronError — and not every non-ok: opaque responses (no-cors) and the 3xx of `redirect: "manual"` are returned; `throwOn(response)` (config or per call; the call's wins) replaces that default — it decides for every non-ok response, opaque and manual 3xx included. An ok response never rejects. Aborts (`AbortError`/`TimeoutError` or any custom `abort(reason)`, including during the error-body read) and exceptions thrown by plugins propagate untouched and skip error interceptors.
61
+ - ArthronError keeps requestContext, response and response headers non-enumerable; toJSON() omits request headers, body, URL query and userinfo. It includes server-provided payload and description, which may contain secrets if the API puts them there. The built-in logger prints a redacted URL and status; its onEvent callback receives the error object.
62
62
  - `message` is arthron's (for logs); `description` is the server's (for people: `e.description ?? fallback`). Default `description`: payload `message` → `error` (string) → `error.message` → `detail`, else undefined. Override with `extractDescription: (payload) => (payload as { msg?: string }).msg`.
63
63
 
64
64
  ## Plugins
@@ -76,7 +76,7 @@ type RequestContext = { url: string; init: Omit<RequestInit, "headers"> & { head
76
76
  // Add keys: declare module "arthron" { interface ArthronMeta { skipTrace?: boolean } }
77
77
  ```
78
78
 
79
- Order: `middleware[0]( middleware[1]( attempt: request interceptors → fetch → response | error interceptors ) )` — a middleware is called once; code after `await next(requestContext)` runs after the response interceptors. Inside `try`, write `return await next(requestContext)` or the catch misses the error. Default order: `[logging(), bearerAuth(…), timeout(ms), retry()]` (timeout before retry = whole call, after = per attempt). Plugins with the same pluginName replace each other; for two timeouts rename one: `{ ...timeout(30_000), pluginName: "callTimeout" }`. Values that must be fresh per attempt (token, signature) → request interceptor; the same on every attempt (idempotency key) → middleware before retry. Each attempt gets a fresh copy of requestContext (headers, and a FormData/URLSearchParams body).
79
+ Order: `middleware[0]( middleware[1]( attempt: request interceptors → fetch → response | error interceptors ) )` — the outermost middleware runs once per call; one below a middleware that calls `next` again runs again each time; code after `await next(requestContext)` runs after the response interceptors. Inside `try`, write `return await next(requestContext)` or the catch misses the error. Default order: `[logging(), bearerAuth(…), timeout(ms), retry()]` (timeout before retry = whole call, after = per attempt). Plugins with the same pluginName replace each other; for two timeouts rename one: `{ ...timeout(30_000), pluginName: "callTimeout" }`. Values that must be fresh per attempt (token, signature) → request interceptor; the same on every attempt (idempotency key) → middleware before retry. Each attempt gets a fresh copy of requestContext (headers, and a FormData/URLSearchParams body).
80
80
 
81
81
  `api.use(...plugins)`, `api.extend(clientOptions)` and `api.without(...pluginNames)` return NEW clients; the original is unchanged. A plugin replaces the one with the same pluginName at its position (built-ins: "bearerAuth", "retry", "timeout", "logging", "uploadProgress"); other names append.
82
82
 
@@ -85,9 +85,9 @@ Order: `middleware[0]( middleware[1]( attempt: request interceptors → fetch
85
85
  ## Built-in plugins (from "arthron/plugins")
86
86
 
87
87
  - `retry({ retries=2, statuses=[408,429,500,502,503,504], methods=[GET,HEAD,OPTIONS,QUERY,PUT,DELETE], delayFn=(attempt)=>Math.random()*300*2**attempt /* full jitter; Retry-After wins; must return a finite number ≥ 0, else TypeError */, maxRetryAfter=60000, shouldRetry?(error, attempt, requestContext) → true|false|undefined, onRetry?(error, attempt, delay) })` — `attempt` is 0-based in all three. Re-runs the whole attempt (request interceptors included). Honours Retry-After (seconds or HTTP-date). Never retries ReadableStream bodies or aborted requests, nor beyond `retries`, nor past a Retry-After longer than `maxRetryAfter`; by default never non-ArthronErrors (shouldRetry may opt in, e.g. an inner TimeoutError). With methods: ["POST"], network errors (status 0) are retried too — refuse them with shouldRetry where a duplicate would hurt. Per call: `meta: { retry: false }` or `meta: { retry: { retries: 5 } }`. `retries` must be a whole number ≥ 0 (TypeError otherwise); waits are capped at 2^31-1 ms.
88
- - `timeout(ms)` | `timeout({ total?, idle? })` — `total` (= `ms`): deadline from attempt start until the body is fully read; `idle`: aborts after that many ms without data (headers or chunks), so `timeout({ idle: 30_000 })` never cuts a flowing stream. Both together: whichever trips first. `{}` → TypeError; so is an object second argument (the 1.x `timeout(ms, { idle })`). Rejects with TimeoutError (not ArthronError; retry ignores it). Place after `retry` for per-attempt, before it for total. Per call: `meta: { timeout: false }` lifts both, `meta: { timeout: ms }` replaces total, `meta: { timeout: { idle: 30_000 } }` replaces the parts it sets. `Infinity` = no deadline; NaN/negative/>2^31-1 → TypeError. Keeps `res.url`/`redirected`/`type` (through `clone()` too); timers are unref'd.
89
- - `bearerAuth({ tokenOrigins, accessTokenFn, refreshFn? })` — `authorization: Bearer <token>` on every attempt, only to requests on `tokenOrigins` (a list of URLs; only origins count; non-http(s), empty or a single string → TypeError; a relative URL is on the page's origin). `accessTokenFn: () => string | null | undefined | Promise<…>`, called per attempt; empty → no header. With `refreshFn: () => Promise<void>` (refreshes the session so the next accessTokenFn() returns the new token), a 401 refreshes and redoes the call once: concurrent 401s share one refresh, a call whose token was already replaced retries without refreshing, a second 401 is thrown, a rejected refresh reaches every waiting call unchanged. Without `refreshFn`, or for a ReadableStream body (cannot be resent), 401 is thrown without refreshing. Per call: `meta: { bearerAuth: false }` = no token, no refresh — use it on the login/refresh requests if they use the same client. A request refreshFn starts before its first await gets it automatically; one started after an await must pass it (else its 401 waits on the refresh it belongs to). Token is opaque: refresh ahead of a JWT's exp inside `accessTokenFn`.
90
- - `logging({ enabled=true, logger=console, onEvent? })` — start/success/error lines or structured events `{ phase, method, url /* query and userinfo removed */, status?, duration? /* ms, retries included */, error? }`. Does not log cancellations.
88
+ - `timeout(ms)` | `timeout({ total?, idle? })` — `total` (= `ms`): deadline from the start of what it wraps until the body is fully read; `idle`: aborts after that many ms without data while arthron waits on the server (for the headers, then the next chunk, read one ahead of the caller; time the caller spends between reads does not count), so `timeout({ idle: 30_000 })` never cuts a flowing stream — nor a body left unread past its first chunk (cancel it, or add `total`). Both together: whichever trips first. `{}` → TypeError; so is an object second argument (the 1.x `timeout(ms, { idle })`). Rejects with TimeoutError (not ArthronError; retry does not repeat it unless shouldRetry returns true for it). Place after `retry` for per-attempt, before it for total. Per call: `meta: { timeout: false }` lifts both, `meta: { timeout: ms }` replaces total, `meta: { timeout: { idle: 30_000 } }` replaces the parts it sets. `Infinity` = no deadline; NaN/negative/>2^31-1 → TypeError. Keeps `res.url`/`redirected`/`type` (through `clone()` too); timers are unref'd.
89
+ - `bearerAuth({ tokenOrigins, accessTokenFn, refreshFn? })` — `authorization: Bearer <token>` on every attempt, only to requests on `tokenOrigins` (a list of URLs; only origins count; non-http(s), empty or a single string → TypeError; requests from a relative baseURL are on the page's origin, so list that origin — `"/api"` itself → TypeError). `accessTokenFn: () => string | null | undefined | Promise<…>`, called per attempt; empty → no header. With `refreshFn: () => Promise<void>` (refreshes the session so the next accessTokenFn() returns the new token), a 401 refreshes and redoes the call once: concurrent 401s share one refresh, a call whose token was already replaced retries without refreshing, a second 401 is thrown, a rejected refresh reaches every waiting call unchanged. Without `refreshFn`, or for a ReadableStream body (cannot be resent), 401 is thrown without refreshing. Per call: `meta: { bearerAuth: false }` = no token, no refresh — use it on the login/refresh requests if they use the same client. A request refreshFn starts before its first await gets it automatically; one started after an await must pass it (else its 401 waits on the refresh it belongs to). Token is opaque: refresh ahead of a JWT's exp inside `accessTokenFn`.
90
+ - `logging({ enabled=true, logger=console, onEvent?, timeouts=false })` — start/success/error lines or structured events `{ phase, method, url /* query and userinfo removed */, status?, duration? /* ms, retries included */, error? }`. An error without a status prints its name where the status goes (`TimeoutError`, `TypeError`). A cancelled call has a start event but no success or error event; with `timeouts: true` a TimeoutError before the response arrives gets an error event (status undefined) — one while the body is read only rejects that read, after the success event.
91
91
  - `uploadProgress()` — does nothing alone; a call with `meta: { uploadProgress: { onProgress: ({ loaded, total, fraction }) => … } }` and a body reports upload progress (`fraction` is 0–1, not a percentage; total/fraction undefined when the size is unknown; restarts at 0 per attempt; no events after the response or an abort). Browser (incl. Electron renderer): sent over XMLHttpRequest (cross-origin → CORS preflight; response not streamed; redirect "manual"/"error", mode "no-cors", credentials "omit" to the page's origin, a referrerPolicy/referrer of its own, integrity → TypeError before sending; cache/priority/keepalive ignored; ReadableStream body → no progress). Node (incl. jsdom on Node): fetch with the body counted in ≤ 64 KiB steps, content-length and Blob content-type kept (S3 presigned PUT works); FormData encoded with a known length, files streamed. Deno/Bun/Cloudflare Workers/Electron main/service workers, a client fetchFn or another plugin's → sent as usual without progress. A throwing onProgress rejects the call with its own error (not ArthronError, not retried) — keep uploadProgress last in plugins. Works through requestContext.fetchFn.
92
92
 
93
93
  ## Recipes
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "arthron",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "A lightweight fetch client you assemble from plugins. Zero dependencies, returns native Response.",
5
5
  "license": "MIT",
6
6
  "author": "lapidix",
7
+ "homepage": "https://lapidix.github.io/arthron/",
7
8
  "repository": {
8
9
  "type": "git",
9
10
  "url": "git+https://github.com/lapidix/arthron.git"
@@ -61,6 +62,9 @@
61
62
  "check:types-pkg": "attw --pack .",
62
63
  "check:types-consumer": "node test/types-consumer/check.mjs",
63
64
  "check:browsers": "pnpm --dir test/browser test",
65
+ "docs:dev": "pnpm --dir docs dev",
66
+ "docs:build": "pnpm --dir docs build",
67
+ "docs:preview": "pnpm --dir docs preview",
64
68
  "release:approve": "node scripts/approve-release.mjs",
65
69
  "prepublishOnly": "pnpm lint && pnpm typecheck && pnpm test && pnpm build && node scripts/smoke.mjs && pnpm check:types-pkg"
66
70
  },