arthron 1.0.1 → 1.2.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/index.cjs CHANGED
@@ -1,164 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- //#region src/request/url.ts
3
- const ABSOLUTE_URL = /^[a-z][a-z0-9+.-]*:\/\//i;
4
- const HTTP_URL = /^https?:\/\//i;
5
- function isAbsoluteUrl(path) {
6
- return ABSOLUTE_URL.test(path);
7
- }
8
- /**
9
- * Origin + pathname only — the query and userinfo tend to carry secrets.
10
- * For anything printed: log lines, error messages, `ArthronError.toJSON`.
11
- */
12
- function redactUrl(url) {
13
- try {
14
- const parsed = new URL(url);
15
- return `${parsed.origin}${parsed.pathname}`;
16
- } catch {
17
- return url.split(/[?#]/)[0] ?? url;
18
- }
19
- }
20
- /**
21
- * `undefined` when `url` has no origin of its own. A relative baseURL takes
22
- * the page's origin in a browser and has none elsewhere.
23
- */
24
- function originOf(url) {
25
- const page = globalThis.location?.href;
26
- try {
27
- return new URL(url, page).origin;
28
- } catch {
29
- return;
30
- }
31
- }
32
- /** `path` is absolute and points away from `baseURL`'s origin. */
33
- function leavesBase(baseURL, path) {
34
- if (baseURL === void 0 || !isAbsoluteUrl(path)) return false;
35
- const base = originOf(baseURL);
36
- return base === void 0 || originOf(path) !== base;
37
- }
38
- function toQueryString(params) {
39
- if (!params) return "";
40
- if (Object.prototype.toString.call(params) === "[object URLSearchParams]") return params.toString();
41
- const search = new URLSearchParams();
42
- for (const [key, value] of Object.entries(params)) for (const item of Array.isArray(value) ? value : [value]) {
43
- if (item === null || item === void 0) continue;
44
- if (typeof item === "object" || typeof item === "function") throw new TypeError(`arthron: params.${key} is not a string, number or boolean — pass one of those or an array of them (dates: date.toISOString())`);
45
- search.append(key, String(item));
46
- }
47
- return search.toString();
48
- }
49
- /**
50
- * Fill `{name}` placeholders with encoded values, so each one stays inside
51
- * its segment: "/", "?" and "#" are encoded, and the values WHATWG URL would
52
- * turn into a different path — "", "." and ".." — are refused. A placeholder
53
- * without a value, or a value without a placeholder, is a mistake too.
54
- */
55
- function fillPath(template, values) {
56
- const used = /* @__PURE__ */ new Set();
57
- const filled = template.replace(/\{([^{}/]+)\}/g, (_, name) => {
58
- if (!Object.hasOwn(values, name)) throw new TypeError(`arthron: path "${template}" has {${name}} but no value for it — pass path: { ${name}: … }`);
59
- used.add(name);
60
- const value = String(values[name]);
61
- if (value === "" || value === "." || value === "..") throw new TypeError(`arthron: path value ${name}=${JSON.stringify(value)} would change the path, not fill a segment — pass a real id`);
62
- return encodeURIComponent(value);
63
- });
64
- const unused = Object.keys(values).find((name) => !used.has(name));
65
- if (unused !== void 0) throw new TypeError(`arthron: path "${template}" has no {${unused}} — add the placeholder or drop the value`);
66
- return filled;
67
- }
68
- /**
69
- * Join `baseURL` and `path` by plain string concatenation so a path prefix in
70
- * the base (`/v1`) survives a leading slash in the path. WHATWG `new URL()`
71
- * would drop it. An absolute path replaces the base only when it stays on the
72
- * base's origin, or when `allowAbsoluteUrls` says other origins are fine — a
73
- * path built from user input or an API response must not carry the client's
74
- * credentials somewhere else.
75
- */
76
- function resolveUrl(baseURL, path, params, allowAbsoluteUrls = false) {
77
- let url;
78
- if (isAbsoluteUrl(path)) {
79
- if (!HTTP_URL.test(path)) {
80
- const scheme = path.slice(0, path.indexOf(":") + 1);
81
- throw new TypeError(`arthron: only http(s) URLs can be requested, got a "${scheme}" URL — use an http:// or https:// URL`);
82
- }
83
- if (!allowAbsoluteUrls && leavesBase(baseURL, path)) throw new TypeError(`arthron: ${originOf(path)} is outside baseURL ${(baseURL && originOf(baseURL)) ?? baseURL} — pass a path relative to baseURL, or set arthron({ allowAbsoluteUrls: true }) to allow other origins (config headers are not sent to them)`);
84
- url = path;
85
- } else {
86
- if (baseURL === void 0) throw new TypeError(`arthron: relative path "${redactUrl(path)}" needs a baseURL — pass baseURL in arthron({ baseURL }) or use an absolute URL`);
87
- url = `${baseURL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
88
- }
89
- const query = toQueryString(params);
90
- if (!query) return url;
91
- const hash = url.indexOf("#");
92
- const [base, fragment] = hash === -1 ? [url, ""] : [url.slice(0, hash), url.slice(hash)];
93
- return `${base}${base.includes("?") ? "&" : "?"}${query}${fragment}`;
94
- }
95
- //#endregion
96
- //#region src/error/arthron-error.ts
97
- /**
98
- * Marks an ArthronError across copies of this package. The ESM and CJS
99
- * builds can both end up loaded (a CJS dependency creates the client, ESM
100
- * app code catches), each with its own class — `Symbol.for` is shared.
101
- */
102
- const BRAND = Symbol.for("arthron.ArthronError");
103
- /**
104
- * The only error arthron itself produces. Two sources: a response with
105
- * `ok === false`, or fetch rejecting outright (network failure, `status` 0).
106
- * Everything else — cancellation, interceptor bugs — is rethrown untouched.
107
- */
108
- var ArthronError = class ArthronError extends Error {
109
- name = "ArthronError";
110
- /** HTTP status of the failed response; `0` when fetch itself failed (network). */
111
- status;
112
- statusText;
113
- /** Parsed body: JSON if it parses, else the text, else `undefined`. */
114
- data;
115
- /**
116
- * The server's own explanation, picked out of `data` (by default
117
- * `message` → `error` → `error.message` → `detail`; see the `errorDetail`
118
- * config). `undefined` when the body had none. Untrusted server text: show
119
- * it to users as text, never as HTML. `message`, by contrast, is written by
120
- * arthron — method, URL without query, status — and is meant for logs.
121
- */
122
- detail;
123
- /** The underlying error for network failures. */
124
- cause;
125
- constructor(init) {
126
- super(init.message);
127
- this.status = init.status;
128
- this.statusText = init.statusText;
129
- this.data = init.data;
130
- this.detail = init.detail;
131
- this.cause = init.cause;
132
- Object.defineProperties(this, {
133
- headers: { value: init.headers },
134
- request: { value: init.request },
135
- response: { value: init.response },
136
- [BRAND]: { value: true }
137
- });
138
- }
139
- /** `instanceof ArthronError` holds for an error from any copy of the package. */
140
- static [Symbol.hasInstance](value) {
141
- return this === ArthronError ? isArthronError(value) : Function.prototype[Symbol.hasInstance].call(this, value);
142
- }
143
- /** What serializers see: no headers, no body, no query or userinfo. */
144
- toJSON() {
145
- return {
146
- name: this.name,
147
- message: this.message,
148
- detail: this.detail,
149
- status: this.status,
150
- statusText: this.statusText,
151
- method: this.request.init.method ?? "GET",
152
- url: redactUrl(this.request.url),
153
- data: this.data
154
- };
155
- }
156
- };
157
- /** `true` for errors arthron produced: a `!ok` response or a network failure. Cancellations and plugin bugs are not ArthronErrors. */
158
- function isArthronError(error) {
159
- return typeof error === "object" && error !== null && error[BRAND] === true;
160
- }
161
- //#endregion
2
+ const require_plugins = require("./plugins-Cigqz2SS.cjs");
162
3
  //#region src/request/headers.ts
163
4
  const isPlainRecord = (headers) => !Array.isArray(headers) && Object.prototype.toString.call(headers) !== "[object Headers]";
164
5
  /**
@@ -198,17 +39,19 @@ function cloneBody(body) {
198
39
  * again (retry) must not see the previous attempt's changes, so every attempt
199
40
  * starts from a fresh copy of the headers and of a FormData/URLSearchParams body.
200
41
  */
201
- function cloneContext({ url, init, meta }) {
42
+ function cloneContext({ url, init, meta, fetch }) {
202
43
  const copy = {
203
44
  ...init,
204
45
  headers: toHeaderRecord(init.headers)
205
46
  };
206
47
  if (init.body) copy.body = cloneBody(init.body);
207
- return {
48
+ const out = {
208
49
  url,
209
50
  init: copy,
210
51
  meta: { ...meta }
211
52
  };
53
+ if (fetch) out.fetch = fetch;
54
+ return out;
212
55
  }
213
56
  /**
214
57
  * Put headers back into a plain lowercase record. A plugin written in JS (or
@@ -216,7 +59,7 @@ function cloneContext({ url, init, meta }) {
216
59
  * `{ ...ctx.init.headers }` would otherwise drop every header.
217
60
  */
218
61
  function normalize(ctx, previous) {
219
- return {
62
+ const out = {
220
63
  ...ctx,
221
64
  init: {
222
65
  ...ctx.init,
@@ -224,6 +67,18 @@ function normalize(ctx, previous) {
224
67
  },
225
68
  meta: ctx.meta ?? previous.meta
226
69
  };
70
+ const fetch = ctx.fetch ?? previous.fetch;
71
+ if (fetch) out.fetch = fetch;
72
+ return out;
73
+ }
74
+ /**
75
+ * A plugin written in JS (or with a cast) that forgets to return would hand
76
+ * `undefined` on — and an error interceptor's would be what the caller
77
+ * catches. Name the plugin slot instead.
78
+ */
79
+ function returned(value, who, what, cause, verb = "returned") {
80
+ if (value != null) return value;
81
+ throw new TypeError(`arthron: ${who} ${verb} ${value} — return ${what}`, cause === void 0 ? void 0 : { cause });
227
82
  }
228
83
  /**
229
84
  * Owns the order of one request.
@@ -247,267 +102,35 @@ async function runPipeline(plugins, ctx, send) {
247
102
  }
248
103
  const attempt = async (input) => {
249
104
  let current = cloneContext(input);
105
+ for (const interceptor of request) current = normalize(returned(await interceptor(current), "a request interceptor", "the context (ctx or a changed copy)"), current);
106
+ let res;
250
107
  try {
251
- for (const interceptor of request) current = normalize(await interceptor(current), current);
252
- let res = await send(current);
253
- for (const interceptor of response) res = await interceptor(res, current);
254
- return res;
108
+ res = await send(current);
255
109
  } catch (thrown) {
256
- if (!isArthronError(thrown)) throw thrown;
110
+ if (!require_plugins.isArthronError(thrown)) throw thrown;
257
111
  let err = thrown;
258
- for (const interceptor of error) err = await interceptor(err, current);
112
+ for (const interceptor of error) err = returned(await interceptor(err, current), "an error interceptor", "the error (err or a changed one)", thrown);
259
113
  throw err;
260
114
  }
115
+ for (const interceptor of response) res = returned(await interceptor(res, current), "a response interceptor", "the Response (res or a new one)");
116
+ return res;
261
117
  };
262
- const dispatch = (index, input) => {
118
+ const dispatch = async (index, input) => {
263
119
  const middleware = middlewares[index];
264
120
  if (!middleware) return attempt(input);
265
- return middleware(input, (next) => dispatch(index + 1, {
266
- ...next,
267
- meta: next.meta ?? input.meta
268
- }));
121
+ return returned(await middleware(input, (next) => {
122
+ const ctx = {
123
+ ...next,
124
+ meta: next.meta ?? input.meta
125
+ };
126
+ const fetch = next.fetch ?? input.fetch;
127
+ if (fetch) ctx.fetch = fetch;
128
+ return dispatch(index + 1, ctx);
129
+ }), "a middleware", "the Response, usually `return next(ctx)`", void 0, "resolved with");
269
130
  };
270
131
  return dispatch(0, ctx);
271
132
  }
272
133
  //#endregion
273
- //#region src/request/body.ts
274
- const RAW_TAGS = /* @__PURE__ */ new Set([
275
- "FormData",
276
- "Blob",
277
- "File",
278
- "ArrayBuffer",
279
- "URLSearchParams",
280
- "ReadableStream"
281
- ]);
282
- /**
283
- * `Symbol.toStringTag`, not `instanceof`: a body from another realm or a
284
- * polyfill (formdata-node, fetch-blob) must not be JSON-encoded into `{}`.
285
- */
286
- const tagOf = (value) => Object.prototype.toString.call(value).slice(8, -1);
287
- function isReadableStream(value) {
288
- return tagOf(value) === "ReadableStream";
289
- }
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
- /**
320
- * What JSON.stringify would turn into `{}`, `undefined` or internal state
321
- * instead of the data — with the fix, so it reads as an error message.
322
- */
323
- function mangledByJson(body) {
324
- if (typeof body === "function" || typeof body === "symbol") return `a ${typeof body}, which JSON cannot carry — send its data instead`;
325
- if (typeof body !== "object" || body === null || Array.isArray(body)) return;
326
- if (Symbol.asyncIterator in body) return "an async iterable (a Node stream?) — pass a ReadableStream (Readable.toWeb(stream)) or collect it first";
327
- if (Symbol.iterator in body) return "an iterable such as a Map, Set or generator — convert it to an array or plain object first";
328
- }
329
- /** Bodies fetch already knows how to send. Content-Type is left to the caller / runtime. */
330
- function isRawBody(body) {
331
- if (typeof body === "string") return true;
332
- if (ArrayBuffer.isView(body)) return true;
333
- return RAW_TAGS.has(tagOf(body));
334
- }
335
- /**
336
- * Turn a caller-supplied body into something fetch accepts. Raw bodies pass
337
- * through untouched; anything else is JSON with `content-type` set — unless
338
- * the caller already set one (headers arrive lowercased, so a plain lookup is
339
- * enough).
340
- */
341
- function serializeBody(body, headers) {
342
- if (body === void 0) return {
343
- body: void 0,
344
- headers
345
- };
346
- if (tagOf(body) === "FormData") {
347
- const { "content-type": _dropped, ...rest } = headers;
348
- return {
349
- body,
350
- headers: rest
351
- };
352
- }
353
- if (isRawBody(body)) return {
354
- body,
355
- headers
356
- };
357
- const mangled = mangledByJson(body);
358
- if (mangled) throw new TypeError(`arthron: the body is ${mangled}`);
359
- const type = headers["content-type"];
360
- if (type !== void 0 && !/\bjson\b/i.test(type)) throw new TypeError(`arthron: a ${typeof body} body is sent as JSON, but content-type is "${type}" — pass a string, URLSearchParams or FormData for that content type, or drop the header`);
361
- const withType = "content-type" in headers ? headers : {
362
- ...headers,
363
- "content-type": "application/json"
364
- };
365
- return {
366
- body: JSON.stringify(body),
367
- headers: withType
368
- };
369
- }
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
511
134
  //#region src/response/schema-validation.ts
512
135
  const pathOf = (issue) => (issue.path ?? []).map((part) => String(typeof part === "object" && part !== null ? part.key : part)).join(".");
513
136
  /**
@@ -542,6 +165,10 @@ const DEFAULT_LIMIT = 8388608;
542
165
  */
543
166
  async function* lines(response, maxLineLength) {
544
167
  if (!response.body) return;
168
+ const checked = (line) => {
169
+ if (line.length <= maxLineLength) return line;
170
+ throw new RangeError(`arthron: a stream line is longer than ${maxLineLength} characters — pass a larger maxLineLength to .sse() / .ndjson() if that is expected`);
171
+ };
545
172
  const reader = response.body.getReader();
546
173
  const decoder = new TextDecoder();
547
174
  const breaks = /\r\n|\r|\n/g;
@@ -559,12 +186,11 @@ async function* lines(response, maxLineLength) {
559
186
  let start = 0;
560
187
  breaks.lastIndex = 0;
561
188
  for (let m = breaks.exec(text); m; m = breaks.exec(text)) {
562
- yield pending + text.slice(start, m.index);
189
+ yield checked(pending + text.slice(start, m.index));
563
190
  pending = "";
564
191
  start = breaks.lastIndex;
565
192
  }
566
- pending += text.slice(start);
567
- if (pending.length > maxLineLength) throw new RangeError(`arthron: a stream line is longer than ${maxLineLength} characters — pass a larger maxLineLength to .sse() / .ndjson() if that is expected`);
193
+ pending = checked(pending + text.slice(start));
568
194
  if (done) break;
569
195
  }
570
196
  if (pending) yield pending;
@@ -647,7 +273,7 @@ function iterate(promise, parse, signal) {
647
273
  try {
648
274
  yield* parse(await promise);
649
275
  } catch (error) {
650
- throw cancellationError(error, signal);
276
+ throw require_plugins.cancellationError(error, signal);
651
277
  }
652
278
  } };
653
279
  }
@@ -657,7 +283,7 @@ function iterate(promise, parse, signal) {
657
283
  */
658
284
  function toArthronPromise(promise, signal) {
659
285
  const read = (body) => promise.then(body).catch((error) => {
660
- throw cancellationError(error, signal);
286
+ throw require_plugins.cancellationError(error, signal);
661
287
  });
662
288
  return Object.assign(promise, {
663
289
  json: ((schema) => read(async (res) => {
@@ -737,9 +363,8 @@ function assertSendable(url, method) {
737
363
  try {
738
364
  new Request(url, { method });
739
365
  } catch (cause) {
740
- const shown = redactUrl(url);
741
- const reason = (cause instanceof Error ? cause.message : String(cause)).split(url).join(shown);
742
- throw new TypeError(`arthron: cannot send ${method} ${shown} (${reason}) — use an absolute URL or a baseURL with a scheme (https://…), and a valid HTTP method`, { cause });
366
+ const reason = require_plugins.redactUrlIn(cause instanceof Error ? cause.message : String(cause), url);
367
+ throw new TypeError(`arthron: cannot send ${method} ${require_plugins.redactUrl(url)} (${reason}) — use an absolute URL or a baseURL with a scheme (https://…), and a valid HTTP method`, { cause });
743
368
  }
744
369
  }
745
370
  /**
@@ -749,8 +374,8 @@ function assertSendable(url, method) {
749
374
  * assertSendable.
750
375
  */
751
376
  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`);
377
+ if (!require_plugins.isReadableStream(body) || require_plugins.runtimeStreamsRequestBody()) return;
378
+ throw new TypeError(`arthron: cannot send ${method} ${require_plugins.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
379
  }
755
380
  /**
756
381
  * Non-ok responses the caller asked for are not failures: an opaque one
@@ -762,7 +387,7 @@ function failedByDefault(res, init) {
762
387
  }
763
388
  /** The one place a failure becomes an ArthronError. Cancellation passes through. */
764
389
  async function send(resolved, ctx, throwOn) {
765
- const fetchImpl = resolved.fetch ?? globalThis.fetch;
390
+ const fetchImpl = ctx.fetch ?? resolved.fetch ?? globalThis.fetch;
766
391
  if (resolved.fetch === void 0) {
767
392
  const method = ctx.init.method ?? "GET";
768
393
  assertSendable(ctx.url, method);
@@ -773,12 +398,12 @@ async function send(resolved, ctx, throwOn) {
773
398
  try {
774
399
  res = await fetchImpl(ctx.url, ctx.init);
775
400
  } catch (thrown) {
776
- if (isCancellation(thrown, signal)) throw cancellationError(thrown, signal);
777
- throw networkError(thrown, ctx);
401
+ if (require_plugins.isCancellation(thrown, signal)) throw require_plugins.cancellationError(thrown, signal);
402
+ throw require_plugins.networkError(thrown, ctx);
778
403
  }
779
404
  if (res.ok) return res;
780
405
  if (!(throwOn ? throwOn(res) : failedByDefault(res, ctx.init))) return res;
781
- const error = await responseError(res, ctx, resolved.errorDetail);
406
+ const error = await require_plugins.responseError(res, ctx, resolved.errorDetail);
782
407
  if (signal?.aborted) throw signal.reason;
783
408
  throw error;
784
409
  }
@@ -808,9 +433,10 @@ function arthron(config = {}) {
808
433
  const upper = method.toUpperCase();
809
434
  const verb = STANDARD_METHODS.has(upper) ? upper : method;
810
435
  if (rawBody !== void 0 && BODYLESS_METHODS.has(upper)) throw new TypeError(`arthron: ${upper} requests cannot carry a body — send it as params (query) or switch to POST`);
811
- if (pathValues) path = fillPath(path, pathValues);
812
- const url = resolveUrl(resolved.baseURL, path, params, resolved.allowAbsoluteUrls);
813
- const { body, headers } = serializeBody(rawBody, mergeHeaders(leavesBase(resolved.baseURL, path) ? void 0 : resolved.headers, callHeaders));
436
+ if (pathValues) path = require_plugins.fillPath(path, pathValues);
437
+ const url = require_plugins.resolveUrl(resolved.baseURL, path, params, resolved.allowAbsoluteUrls);
438
+ const defaults = require_plugins.leavesBase(resolved.baseURL, path) ? void 0 : resolved.headers;
439
+ const { body, headers } = require_plugins.serializeBody(rawBody, mergeHeaders(defaults, callHeaders));
814
440
  const requestInit = {
815
441
  ...initDefaults,
816
442
  ...init,
@@ -818,11 +444,12 @@ function arthron(config = {}) {
818
444
  headers
819
445
  };
820
446
  if (body !== void 0) requestInit.body = body;
821
- if (isReadableStream(body)) Object.assign(requestInit, { duplex: "half" });
447
+ if (require_plugins.isReadableStream(body)) Object.assign(requestInit, { duplex: "half" });
822
448
  return toArthronPromise(runPipeline(resolved.use, {
823
449
  url,
824
450
  init: requestInit,
825
- meta: { ...meta }
451
+ meta: { ...meta },
452
+ fetch: resolved.fetch ?? globalThis.fetch
826
453
  }, (ctx) => send(resolved, ctx, throwOn)), init.signal);
827
454
  } catch (thrown) {
828
455
  return toArthronPromise(Promise.reject(thrown));
@@ -870,278 +497,21 @@ const defineInterceptor = {
870
497
  /** Identity helper: type-checks a plugin object literal without an annotation. */
871
498
  const definePlugin = (plugin) => plugin;
872
499
  //#endregion
873
- //#region src/plugins/logging/logging.ts
874
- function consoleSink(logger) {
875
- return (e) => {
876
- if (e.phase === "start") logger.log(`[arthron] → ${e.method} ${e.path}`);
877
- else if (e.phase === "success") logger.log(`[arthron] ← ${e.method} ${e.path} ${e.status} ${e.ms}ms`);
878
- else logger.error(`[arthron] ✕ ${e.method} ${e.path} ${e.status ?? "-"} ${e.ms}ms`);
879
- };
880
- }
881
- /**
882
- * One event when a request starts, one when it settles, with status and
883
- * elapsed time. Wraps the whole call, so retries are included in the time.
884
- * Default output is a console line per event; pass `onEvent` to get the
885
- * structured event instead. Cancellations are not reported.
886
- */
887
- function logging(options = {}) {
888
- const { enabled = true, logger = console, onEvent, now = () => performance.now() } = options;
889
- const emit = onEvent ?? consoleSink(logger);
890
- return {
891
- name: "logging",
892
- middleware: async (ctx, next) => {
893
- if (!enabled) return next(ctx);
894
- const method = ctx.init.method ?? "GET";
895
- const path = redactUrl(ctx.url);
896
- const started = now();
897
- const ms = () => Math.round(now() - started);
898
- emit({
899
- phase: "start",
900
- method,
901
- path
902
- });
903
- let res;
904
- try {
905
- res = await next(ctx);
906
- } catch (error) {
907
- if (!isCancellation(error, ctx.init.signal)) {
908
- const status = isArthronError(error) ? error.status : void 0;
909
- emit({
910
- phase: "error",
911
- method,
912
- path,
913
- status,
914
- ms: ms(),
915
- error
916
- });
917
- }
918
- throw error;
919
- }
920
- emit({
921
- phase: "success",
922
- method,
923
- path,
924
- status: res.status,
925
- ms: ms()
926
- });
927
- return res;
928
- }
929
- };
930
- }
931
- //#endregion
932
- //#region src/plugins/retry/retry.ts
933
- const DEFAULT_RETRY_ON = [
934
- 408,
935
- 429,
936
- 500,
937
- 502,
938
- 503,
939
- 504
940
- ];
941
- const DEFAULT_METHODS = [
942
- "GET",
943
- "HEAD",
944
- "OPTIONS",
945
- "QUERY",
946
- "PUT",
947
- "DELETE"
948
- ];
949
- const DEFAULT_MAX_RETRY_AFTER = 6e4;
950
- /** setTimeout fires at once past this, so every wait is capped to it. */
951
- const MAX_TIMER_MS$1 = 2 ** 31 - 1;
952
- function defaultSleep(ms, signal) {
953
- return new Promise((resolve, reject) => {
954
- if (signal?.aborted) return reject(signal.reason);
955
- const onAbort = () => {
956
- clearTimeout(timer);
957
- reject(signal?.reason);
958
- };
959
- const timer = setTimeout(() => {
960
- signal?.removeEventListener("abort", onAbort);
961
- resolve();
962
- }, ms);
963
- signal?.addEventListener("abort", onAbort, { once: true });
964
- });
965
- }
966
- /** `Retry-After` as ms — seconds or an HTTP-date. `undefined` when absent or unparsable. */
967
- function retryAfterMs(error) {
968
- const value = error.headers.get("retry-after")?.trim();
969
- if (!value) return void 0;
970
- const seconds = Number(value);
971
- if (!Number.isNaN(seconds)) return Math.max(0, seconds * 1e3);
972
- const at = Date.parse(value);
973
- if (Number.isNaN(at)) return void 0;
974
- return Math.max(0, at - Date.now());
975
- }
976
- /** The rule without `shouldRetry`: a network error or a `retryOn` status, on a method in `methods`. */
977
- function byDefault(thrown, method, settings) {
978
- if (!settings.allowed.has(method) || !isArthronError(thrown)) return false;
979
- return thrown.status === 0 || settings.retryOn.includes(thrown.status);
980
- }
981
- /** Defaults filled in and `retries` checked — `attempt >= NaN` is never true, so an unset env var would retry forever. */
982
- function settle(options, where) {
983
- const { retries = 2, retryOn = DEFAULT_RETRY_ON, methods = DEFAULT_METHODS, delay = (retry) => Math.random() * 300 * 2 ** retry, maxRetryAfter = DEFAULT_MAX_RETRY_AFTER, shouldRetry, onRetry } = options;
984
- if (!Number.isInteger(retries) || retries < 0) throw new TypeError(`arthron: ${where} retries: ${retries} needs a whole number ≥ 0 — e.g. retries: 2`);
985
- return {
986
- retries,
987
- retryOn,
988
- allowed: new Set(methods.map((m) => m.toUpperCase())),
989
- delay,
990
- maxRetryAfter,
991
- shouldRetry,
992
- onRetry
993
- };
994
- }
995
- /**
996
- * Retry on network errors and the configured statuses by calling `next`
997
- * again — the whole attempt (request interceptors included) is re-run.
998
- * Only `ArthronError` is considered; cancellation and plugin bugs pass through.
999
- * A call can turn it off or override its options with `meta.retry`.
1000
- */
1001
- function retry(options = {}) {
1002
- const { sleep = defaultSleep, ...rest } = options;
1003
- const base = settle(rest, "retry()");
1004
- return {
1005
- name: "retry",
1006
- middleware: async (ctx, next) => {
1007
- const override = ctx.meta?.retry;
1008
- if (override === false) return next(ctx);
1009
- const settings = override ? settle({
1010
- ...rest,
1011
- ...override
1012
- }, "meta.retry") : base;
1013
- const method = (ctx.init.method ?? "GET").toUpperCase();
1014
- const resendable = !isReadableStream(ctx.init.body);
1015
- for (let attempt = 0;; attempt += 1) try {
1016
- return await next(ctx);
1017
- } catch (thrown) {
1018
- if (!resendable || attempt >= settings.retries) throw thrown;
1019
- if (ctx.init.signal?.aborted) throw thrown;
1020
- const verdict = settings.shouldRetry?.(thrown, attempt, ctx);
1021
- if (verdict === false) throw thrown;
1022
- if (verdict === void 0 && !byDefault(thrown, method, settings)) throw thrown;
1023
- const retryAfter = isArthronError(thrown) ? retryAfterMs(thrown) : void 0;
1024
- if (retryAfter !== void 0 && retryAfter > settings.maxRetryAfter) throw thrown;
1025
- const wait = Math.min(retryAfter ?? settings.delay(attempt, thrown), MAX_TIMER_MS$1);
1026
- settings.onRetry?.(thrown, attempt, wait);
1027
- await sleep(wait, ctx.init.signal);
1028
- }
1029
- }
1030
- };
1031
- }
1032
- //#endregion
1033
- //#region src/plugins/timeout/timeout.ts
1034
- const MAX_TIMER_MS = 2 ** 31 - 1;
1035
- /**
1036
- * Wrap `body` so the deadline keeps running until it is fully read, and so an
1037
- * abort while reading surfaces as a stream error instead of a silent stall.
1038
- */
1039
- function guardBody(body, signal, finish, onChunk) {
1040
- const reader = body.getReader();
1041
- return new ReadableStream({
1042
- start(controller) {
1043
- signal.addEventListener("abort", () => {
1044
- controller.error(signal.reason);
1045
- reader.cancel(signal.reason).catch(() => {});
1046
- }, { once: true });
1047
- },
1048
- async pull(controller) {
1049
- const { value, done } = await reader.read();
1050
- if (done) {
1051
- finish();
1052
- controller.close();
1053
- return;
1054
- }
1055
- onChunk();
1056
- controller.enqueue(value);
1057
- },
1058
- cancel(reason) {
1059
- finish();
1060
- return reader.cancel(reason);
1061
- }
1062
- });
1063
- }
1064
- /** setTimeout fires at once for NaN, negatives and anything past 2^31-1. */
1065
- function checkDeadline(ms, where) {
1066
- if (ms === Number.POSITIVE_INFINITY || ms >= 0 && ms <= MAX_TIMER_MS) return;
1067
- throw new TypeError(`arthron: ${where} needs 0–${MAX_TIMER_MS} ms — pass Infinity for no deadline`);
1068
- }
1069
- /** A timer that aborts `controller` with a TimeoutError and never keeps Node alive on its own. */
1070
- function deadlineTimer(controller, ms, message) {
1071
- const timer = setTimeout(() => controller.abort(new DOMException(message, "TimeoutError")), ms);
1072
- timer.unref?.();
1073
- return timer;
1074
- }
1075
- /**
1076
- * Abort the request when it takes longer than `ms` — counted from the start
1077
- * of the attempt until the body has been fully read, so a server that sends
1078
- * headers and then stalls still trips it. With `idle`, also abort once no
1079
- * data has arrived for that long. Rejects with a `TimeoutError` (a
1080
- * `DOMException`, like `AbortSignal.timeout()` would give); it is not an
1081
- * `ArthronError`, so error interceptors and `retry` leave it alone.
1082
- *
1083
- * A call can change the total deadline with `meta.timeout`.
1084
- *
1085
- * Deadlines are timers on an AbortController — not `AbortSignal.timeout`,
1086
- * since the idle one restarts — joined to the caller's signal with
1087
- * `AbortSignal.any`.
1088
- */
1089
- function timeout(ms, options = {}) {
1090
- checkDeadline(ms, `timeout(${ms})`);
1091
- const { idle } = options;
1092
- if (idle !== void 0) checkDeadline(idle, `timeout(…, { idle: ${idle} })`);
1093
- return {
1094
- name: "timeout",
1095
- middleware: async (ctx, next) => {
1096
- const deadline = ctx.meta?.timeout ?? ms;
1097
- if (deadline !== ms) checkDeadline(deadline, `meta.timeout: ${deadline}`);
1098
- const idleMs = idle === Number.POSITIVE_INFINITY ? void 0 : idle;
1099
- if (deadline === Number.POSITIVE_INFINITY && idleMs === void 0) return next(ctx);
1100
- const controller = new AbortController();
1101
- const outer = ctx.init.signal;
1102
- const signal = outer ? AbortSignal.any([outer, controller.signal]) : controller.signal;
1103
- const total = deadline === Number.POSITIVE_INFINITY ? void 0 : deadlineTimer(controller, deadline, `arthron: request timed out after ${deadline}ms`);
1104
- let quiet;
1105
- const stillAlive = () => {
1106
- if (idleMs === void 0) return;
1107
- clearTimeout(quiet);
1108
- quiet = deadlineTimer(controller, idleMs, `arthron: no data for ${idleMs}ms`);
1109
- };
1110
- const finish = () => {
1111
- clearTimeout(total);
1112
- clearTimeout(quiet);
1113
- };
1114
- stillAlive();
1115
- let res;
1116
- try {
1117
- res = await next({
1118
- ...ctx,
1119
- init: {
1120
- ...ctx.init,
1121
- signal
1122
- }
1123
- });
1124
- } catch (thrown) {
1125
- finish();
1126
- throw thrown;
1127
- }
1128
- if (!res.body) {
1129
- finish();
1130
- return res;
1131
- }
1132
- stillAlive();
1133
- return rebuildResponse(res, guardBody(res.body, signal, finish, stillAlive));
1134
- }
1135
- };
1136
- }
500
+ //#region src/index.ts
501
+ /** @deprecated Import from "arthron/plugins"; this export goes in the next major. */
502
+ const logging = require_plugins.logging;
503
+ /** @deprecated Import from "arthron/plugins"; this export goes in the next major. */
504
+ const retry = require_plugins.retry;
505
+ /** @deprecated Import from "arthron/plugins"; this export goes in the next major. */
506
+ const timeout = require_plugins.timeout;
1137
507
  //#endregion
1138
- exports.ArthronError = ArthronError;
508
+ exports.ArthronError = require_plugins.ArthronError;
1139
509
  exports.SchemaError = SchemaError;
1140
510
  exports.arthron = arthron;
1141
511
  exports.defineInterceptor = defineInterceptor;
1142
512
  exports.defineMiddleware = defineMiddleware;
1143
513
  exports.definePlugin = definePlugin;
1144
- exports.isArthronError = isArthronError;
514
+ exports.isArthronError = require_plugins.isArthronError;
1145
515
  exports.logging = logging;
1146
516
  exports.retry = retry;
1147
517
  exports.timeout = timeout;