arthron 0.1.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,163 +1,227 @@
1
- //#region src/body.ts
2
- const RAW_CTORS = [
3
- "FormData",
4
- "Blob",
5
- "ArrayBuffer",
6
- "URLSearchParams",
7
- "ReadableStream"
8
- ];
9
- /** Bodies fetch already knows how to send. Content-Type is left to the caller / runtime. */
10
- function isRawBody(body) {
11
- if (typeof body === "string") return true;
12
- if (ArrayBuffer.isView(body)) return true;
13
- return RAW_CTORS.some((name) => {
14
- const ctor = globalThis[name];
15
- return typeof ctor === "function" && body instanceof ctor;
1
+ //#region src/request/url.ts
2
+ const ABSOLUTE_URL = /^[a-z][a-z0-9+.-]*:\/\//i;
3
+ const HTTP_URL = /^https?:\/\//i;
4
+ function isAbsoluteUrl(path) {
5
+ return ABSOLUTE_URL.test(path);
6
+ }
7
+ /**
8
+ * Origin + pathname only — the query and userinfo tend to carry secrets.
9
+ * For anything printed: log lines, error messages, `ArthronError.toJSON`.
10
+ */
11
+ function redactUrl(url) {
12
+ try {
13
+ const parsed = new URL(url);
14
+ return `${parsed.origin}${parsed.pathname}`;
15
+ } catch {
16
+ return url.split(/[?#]/)[0] ?? url;
17
+ }
18
+ }
19
+ /**
20
+ * `undefined` when `url` has no origin of its own. A relative baseURL takes
21
+ * the page's origin in a browser and has none elsewhere.
22
+ */
23
+ function originOf(url) {
24
+ const page = globalThis.location?.href;
25
+ try {
26
+ return new URL(url, page).origin;
27
+ } catch {
28
+ return;
29
+ }
30
+ }
31
+ /** `path` is absolute and points away from `baseURL`'s origin. */
32
+ function leavesBase(baseURL, path) {
33
+ if (baseURL === void 0 || !isAbsoluteUrl(path)) return false;
34
+ const base = originOf(baseURL);
35
+ return base === void 0 || originOf(path) !== base;
36
+ }
37
+ function toQueryString(params) {
38
+ if (!params) return "";
39
+ if (Object.prototype.toString.call(params) === "[object URLSearchParams]") return params.toString();
40
+ const search = new URLSearchParams();
41
+ for (const [key, value] of Object.entries(params)) for (const item of Array.isArray(value) ? value : [value]) {
42
+ if (item === null || item === void 0) continue;
43
+ 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())`);
44
+ search.append(key, String(item));
45
+ }
46
+ return search.toString();
47
+ }
48
+ /**
49
+ * Fill `{name}` placeholders with encoded values, so each one stays inside
50
+ * its segment: "/", "?" and "#" are encoded, and the values WHATWG URL would
51
+ * turn into a different path — "", "." and ".." — are refused. A placeholder
52
+ * without a value, or a value without a placeholder, is a mistake too.
53
+ */
54
+ function fillPath(template, values) {
55
+ const used = /* @__PURE__ */ new Set();
56
+ const filled = template.replace(/\{([^{}/]+)\}/g, (_, name) => {
57
+ if (!Object.hasOwn(values, name)) throw new TypeError(`arthron: path "${template}" has {${name}} but no value for it — pass path: { ${name}: … }`);
58
+ used.add(name);
59
+ const value = String(values[name]);
60
+ 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`);
61
+ return encodeURIComponent(value);
16
62
  });
63
+ const unused = Object.keys(values).find((name) => !used.has(name));
64
+ if (unused !== void 0) throw new TypeError(`arthron: path "${template}" has no {${unused}} — add the placeholder or drop the value`);
65
+ return filled;
17
66
  }
18
67
  /**
19
- * Turn a caller-supplied body into something fetch accepts. Raw bodies pass
20
- * through untouched; anything else is JSON with `content-type` set — unless
21
- * the caller already set one (headers arrive lowercased, so a plain lookup is
22
- * enough).
68
+ * Join `baseURL` and `path` by plain string concatenation so a path prefix in
69
+ * the base (`/v1`) survives a leading slash in the path. WHATWG `new URL()`
70
+ * would drop it. An absolute path replaces the base only when it stays on the
71
+ * base's origin, or when `allowAbsoluteUrls` says other origins are fine — a
72
+ * path built from user input or an API response must not carry the client's
73
+ * credentials somewhere else.
23
74
  */
24
- function serializeBody(body, headers) {
25
- if (body === void 0) return {
26
- body: void 0,
27
- headers
28
- };
29
- if (isRawBody(body)) return {
30
- body,
31
- headers
32
- };
33
- const withType = "content-type" in headers ? headers : {
34
- ...headers,
35
- "content-type": "application/json"
36
- };
37
- return {
38
- body: JSON.stringify(body),
39
- headers: withType
40
- };
75
+ function resolveUrl(baseURL, path, params, allowAbsoluteUrls = false) {
76
+ let url;
77
+ if (isAbsoluteUrl(path)) {
78
+ if (!HTTP_URL.test(path)) {
79
+ const scheme = path.slice(0, path.indexOf(":") + 1);
80
+ throw new TypeError(`arthron: only http(s) URLs can be requested, got a "${scheme}" URL — use an http:// or https:// URL`);
81
+ }
82
+ 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)`);
83
+ url = path;
84
+ } else {
85
+ 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`);
86
+ url = `${baseURL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
87
+ }
88
+ const query = toQueryString(params);
89
+ if (!query) return url;
90
+ const hash = url.indexOf("#");
91
+ const [base, fragment] = hash === -1 ? [url, ""] : [url.slice(0, hash), url.slice(hash)];
92
+ return `${base}${base.includes("?") ? "&" : "?"}${query}${fragment}`;
41
93
  }
42
94
  //#endregion
43
- //#region src/error.ts
95
+ //#region src/error/arthron-error.ts
96
+ /**
97
+ * Marks an ArthronError across copies of this package. The ESM and CJS
98
+ * builds can both end up loaded (a CJS dependency creates the client, ESM
99
+ * app code catches), each with its own class — `Symbol.for` is shared.
100
+ */
101
+ const BRAND = Symbol.for("arthron.ArthronError");
44
102
  /**
45
103
  * The only error arthron itself produces. Two sources: a response with
46
104
  * `ok === false`, or fetch rejecting outright (network failure, `status` 0).
47
105
  * Everything else — cancellation, interceptor bugs — is rethrown untouched.
48
106
  */
49
- var ArthronError = class extends Error {
107
+ var ArthronError = class ArthronError extends Error {
50
108
  name = "ArthronError";
51
109
  /** HTTP status of the failed response; `0` when fetch itself failed (network). */
52
110
  status;
53
111
  statusText;
54
- /** Response headers; empty for network failures. */
55
- headers;
56
112
  /** Parsed body: JSON if it parses, else the text, else `undefined`. */
57
113
  data;
58
- /** The context that was sent — url and init after request interceptors. */
59
- request;
60
- /** The original Response, still readable (the body was parsed from a clone). Absent for network failures. */
61
- response;
114
+ /**
115
+ * The server's own explanation, picked out of `data` (by default
116
+ * `message` → `error` → `error.message` → `detail`; see the `errorDetail`
117
+ * config). `undefined` when the body had none. Untrusted server text: show
118
+ * it to users as text, never as HTML. `message`, by contrast, is written by
119
+ * arthron — method, URL without query, status — and is meant for logs.
120
+ */
121
+ detail;
62
122
  /** The underlying error for network failures. */
63
123
  cause;
64
124
  constructor(init) {
65
125
  super(init.message);
66
126
  this.status = init.status;
67
127
  this.statusText = init.statusText;
68
- this.headers = init.headers;
69
128
  this.data = init.data;
70
- this.request = init.request;
71
- this.response = init.response;
129
+ this.detail = init.detail;
72
130
  this.cause = init.cause;
131
+ Object.defineProperties(this, {
132
+ headers: { value: init.headers },
133
+ request: { value: init.request },
134
+ response: { value: init.response },
135
+ [BRAND]: { value: true }
136
+ });
137
+ }
138
+ /** `instanceof ArthronError` holds for an error from any copy of the package. */
139
+ static [Symbol.hasInstance](value) {
140
+ return this === ArthronError ? isArthronError(value) : Function.prototype[Symbol.hasInstance].call(this, value);
141
+ }
142
+ /** What serializers see: no headers, no body, no query or userinfo. */
143
+ toJSON() {
144
+ return {
145
+ name: this.name,
146
+ message: this.message,
147
+ detail: this.detail,
148
+ status: this.status,
149
+ statusText: this.statusText,
150
+ method: this.request.init.method ?? "GET",
151
+ url: redactUrl(this.request.url),
152
+ data: this.data
153
+ };
73
154
  }
74
155
  };
75
156
  /** `true` for errors arthron produced: a `!ok` response or a network failure. Cancellations and plugin bugs are not ArthronErrors. */
76
157
  function isArthronError(error) {
77
- return error instanceof ArthronError;
78
- }
79
- /** Cancellation is not an HTTP failure. Matched by name so it works across realms. */
80
- function isCancellation(error) {
81
- const name = error?.name;
82
- return name === "AbortError" || name === "TimeoutError";
83
- }
84
- const isRecord = (value) => typeof value === "object" && value !== null;
85
- /** `message` → `error` (string) → `error.message` → `detail`. */
86
- function defaultErrorMessage(data) {
87
- if (!isRecord(data)) return void 0;
88
- if (typeof data.message === "string") return data.message;
89
- if (typeof data.error === "string") return data.error;
90
- if (isRecord(data.error) && typeof data.error.message === "string") return data.error.message;
91
- if (typeof data.detail === "string") return data.detail;
92
- }
93
- /** Body as JSON if it parses, else as text, else `undefined` when empty. */
94
- async function readBody(response) {
95
- const text = await response.text().catch(() => "");
96
- if (text === "") return void 0;
97
- try {
98
- return JSON.parse(text);
99
- } catch {
100
- return text;
101
- }
102
- }
103
- async function responseError(response, request, resolve = defaultErrorMessage) {
104
- const data = await readBody(response.clone());
105
- return new ArthronError({
106
- message: resolve(data, response) ?? `Request failed with status ${response.status}`,
107
- status: response.status,
108
- statusText: response.statusText,
109
- headers: response.headers,
110
- data,
111
- request,
112
- response
113
- });
114
- }
115
- function networkError(cause, request) {
116
- return new ArthronError({
117
- message: cause instanceof Error ? cause.message : "Network error",
118
- status: 0,
119
- statusText: "",
120
- headers: new Headers(),
121
- data: void 0,
122
- request,
123
- cause
124
- });
158
+ return typeof error === "object" && error !== null && error[BRAND] === true;
125
159
  }
126
160
  //#endregion
127
- //#region src/headers.ts
161
+ //#region src/request/headers.ts
162
+ const isPlainRecord = (headers) => !Array.isArray(headers) && Object.prototype.toString.call(headers) !== "[object Headers]";
128
163
  /**
129
- * Normalize any `HeadersInit` (plain object, `Headers`, tuple array) into a
130
- * plain record. Keys come back lowercased — `Headers` does that for us.
164
+ * Layer header sets left to right into one plain record. Names are compared
165
+ * case-insensitively and come back lowercased; a later layer wins, so a
166
+ * caller's `content-type` replaces a default `Content-Type` instead of fetch
167
+ * sending both values joined. `Headers` validates every value it sets.
131
168
  */
169
+ function mergeHeaders(...layers) {
170
+ const merged = new Headers();
171
+ for (const layer of layers) {
172
+ if (!layer) continue;
173
+ if (isPlainRecord(layer)) for (const [name, value] of Object.entries(layer)) if (value == null) merged.delete(name);
174
+ else merged.set(name, value);
175
+ else for (const [name, value] of new Headers(layer)) merged.set(name, value);
176
+ }
177
+ return Object.fromEntries(merged);
178
+ }
179
+ /** Normalize any header shape into a plain record with lowercased names. */
132
180
  function toHeaderRecord(headers) {
133
- if (!headers) return {};
134
- return Object.fromEntries(new Headers(headers).entries());
181
+ return mergeHeaders(headers);
182
+ }
183
+ //#endregion
184
+ //#region src/pipeline/pipeline.ts
185
+ /** The mutable body kinds an interceptor can `append` to; anything else is shared as is. */
186
+ function cloneBody(body) {
187
+ if (typeof FormData !== "undefined" && body instanceof FormData) {
188
+ const copy = new FormData();
189
+ for (const [name, value] of body) copy.append(name, value);
190
+ return copy;
191
+ }
192
+ if (body instanceof URLSearchParams) return new URLSearchParams(body);
193
+ return body;
135
194
  }
136
195
  /**
137
- * Layer `override` on top of `base`. Names are compared case-insensitively and
138
- * the override wins, so a caller's `content-type` replaces a default
139
- * `Content-Type` instead of fetch sending both values joined.
196
+ * Interceptors mutate the context in place. A middleware that calls `next`
197
+ * again (retry) must not see the previous attempt's changes, so every attempt
198
+ * starts from a fresh copy of the headers and of a FormData/URLSearchParams body.
140
199
  */
141
- function mergeHeaders(base, override) {
200
+ function cloneContext({ url, init, meta }) {
201
+ const copy = {
202
+ ...init,
203
+ headers: toHeaderRecord(init.headers)
204
+ };
205
+ if (init.body) copy.body = cloneBody(init.body);
142
206
  return {
143
- ...toHeaderRecord(base),
144
- ...toHeaderRecord(override)
207
+ url,
208
+ init: copy,
209
+ meta: { ...meta }
145
210
  };
146
211
  }
147
- //#endregion
148
- //#region src/pipeline.ts
149
212
  /**
150
- * Interceptors mutate the context in place. A middleware that calls `next`
151
- * again (retry) must not see the previous attempt's changes, so every attempt
152
- * starts from a fresh copy.
213
+ * Put headers back into a plain lowercase record. A plugin written in JS (or
214
+ * with a cast) may hand back a `Headers` object, and the next plugin's
215
+ * `{ ...ctx.init.headers }` would otherwise drop every header.
153
216
  */
154
- function cloneContext({ url, init }) {
217
+ function normalize(ctx, previous) {
155
218
  return {
156
- url,
219
+ ...ctx,
157
220
  init: {
158
- ...init,
159
- headers: toHeaderRecord(init.headers)
160
- }
221
+ ...ctx.init,
222
+ headers: toHeaderRecord(ctx.init.headers)
223
+ },
224
+ meta: ctx.meta ?? previous.meta
161
225
  };
162
226
  }
163
227
  /**
@@ -183,7 +247,7 @@ async function runPipeline(plugins, ctx, send) {
183
247
  const attempt = async (input) => {
184
248
  let current = cloneContext(input);
185
249
  try {
186
- for (const interceptor of request) current = await interceptor(current);
250
+ for (const interceptor of request) current = normalize(await interceptor(current), current);
187
251
  let res = await send(current);
188
252
  for (const interceptor of response) res = await interceptor(res, current);
189
253
  return res;
@@ -197,29 +261,314 @@ async function runPipeline(plugins, ctx, send) {
197
261
  const dispatch = (index, input) => {
198
262
  const middleware = middlewares[index];
199
263
  if (!middleware) return attempt(input);
200
- return middleware(input, (next) => dispatch(index + 1, next));
264
+ return middleware(input, (next) => dispatch(index + 1, {
265
+ ...next,
266
+ meta: next.meta ?? input.meta
267
+ }));
201
268
  };
202
269
  return dispatch(0, ctx);
203
270
  }
204
271
  //#endregion
205
- //#region src/stream.ts
206
- /** Yield the body line by line, reassembling lines split across chunks. */
207
- async function* lines(response) {
272
+ //#region src/request/body.ts
273
+ const RAW_TAGS = /* @__PURE__ */ new Set([
274
+ "FormData",
275
+ "Blob",
276
+ "File",
277
+ "ArrayBuffer",
278
+ "URLSearchParams",
279
+ "ReadableStream"
280
+ ]);
281
+ /**
282
+ * `Symbol.toStringTag`, not `instanceof`: a body from another realm or a
283
+ * polyfill (formdata-node, fetch-blob) must not be JSON-encoded into `{}`.
284
+ */
285
+ const tagOf = (value) => Object.prototype.toString.call(value).slice(8, -1);
286
+ function isReadableStream(value) {
287
+ return tagOf(value) === "ReadableStream";
288
+ }
289
+ /**
290
+ * Whether `RequestCtor` can send a ReadableStream body. Node and Chromium
291
+ * read `duplex` and leave the stream alone; Firefox ignores `duplex` and
292
+ * would send the text "[object ReadableStream]"; older Safari refuses it.
293
+ * The check from
294
+ * https://developer.chrome.com/docs/capabilities/web-apis/fetch-streaming-requests
295
+ */
296
+ function canStreamRequestBody(RequestCtor) {
297
+ let duplexRead = false;
298
+ try {
299
+ const request = new RequestCtor("https://stream.test", {
300
+ method: "POST",
301
+ body: new ReadableStream(),
302
+ get duplex() {
303
+ duplexRead = true;
304
+ return "half";
305
+ }
306
+ });
307
+ return duplexRead && !request.headers.has("content-type");
308
+ } catch {
309
+ return false;
310
+ }
311
+ }
312
+ let runtimeStreams;
313
+ /** `canStreamRequestBody` for the global `Request`, checked once. */
314
+ function runtimeStreamsRequestBody() {
315
+ runtimeStreams ??= typeof Request !== "function" || canStreamRequestBody(Request);
316
+ return runtimeStreams;
317
+ }
318
+ /**
319
+ * What JSON.stringify would turn into `{}`, `undefined` or internal state
320
+ * instead of the data — with the fix, so it reads as an error message.
321
+ */
322
+ function mangledByJson(body) {
323
+ if (typeof body === "function" || typeof body === "symbol") return `a ${typeof body}, which JSON cannot carry — send its data instead`;
324
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return;
325
+ if (Symbol.asyncIterator in body) return "an async iterable (a Node stream?) — pass a ReadableStream (Readable.toWeb(stream)) or collect it first";
326
+ if (Symbol.iterator in body) return "an iterable such as a Map, Set or generator — convert it to an array or plain object first";
327
+ }
328
+ /** Bodies fetch already knows how to send. Content-Type is left to the caller / runtime. */
329
+ function isRawBody(body) {
330
+ if (typeof body === "string") return true;
331
+ if (ArrayBuffer.isView(body)) return true;
332
+ return RAW_TAGS.has(tagOf(body));
333
+ }
334
+ /**
335
+ * Turn a caller-supplied body into something fetch accepts. Raw bodies pass
336
+ * through untouched; anything else is JSON with `content-type` set — unless
337
+ * the caller already set one (headers arrive lowercased, so a plain lookup is
338
+ * enough).
339
+ */
340
+ function serializeBody(body, headers) {
341
+ if (body === void 0) return {
342
+ body: void 0,
343
+ headers
344
+ };
345
+ if (tagOf(body) === "FormData") {
346
+ const { "content-type": _dropped, ...rest } = headers;
347
+ return {
348
+ body,
349
+ headers: rest
350
+ };
351
+ }
352
+ if (isRawBody(body)) return {
353
+ body,
354
+ headers
355
+ };
356
+ const mangled = mangledByJson(body);
357
+ if (mangled) throw new TypeError(`arthron: the body is ${mangled}`);
358
+ const type = headers["content-type"];
359
+ 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`);
360
+ const withType = "content-type" in headers ? headers : {
361
+ ...headers,
362
+ "content-type": "application/json"
363
+ };
364
+ return {
365
+ body: JSON.stringify(body),
366
+ headers: withType
367
+ };
368
+ }
369
+ //#endregion
370
+ //#region src/response/rebuild-response.ts
371
+ /**
372
+ * A new Response with `body` but everything else taken from `original`. The
373
+ * constructor resets `url`, `redirected` and `type`, so those are copied over
374
+ * as own properties — callers that resolve relative links or detect
375
+ * redirects keep working.
376
+ */
377
+ function rebuildResponse(original, body) {
378
+ return withFetchedFields(new Response(body, {
379
+ status: original.status,
380
+ statusText: original.statusText,
381
+ headers: original.headers
382
+ }), original);
383
+ }
384
+ /** Copy the fields the constructor resets — onto `clone()`s as well. */
385
+ function withFetchedFields(target, from) {
386
+ const clone = target.clone.bind(target);
387
+ return Object.defineProperties(target, {
388
+ url: { value: from.url },
389
+ redirected: { value: from.redirected },
390
+ type: { value: from.type },
391
+ clone: { value: () => withFetchedFields(clone(), from) }
392
+ });
393
+ }
394
+ //#endregion
395
+ //#region src/error/create-error.ts
396
+ /**
397
+ * Cancellation is not an HTTP failure. Matched by name so it works across
398
+ * realms — or by the request's own `signal`, since `abort(reason)` rejects
399
+ * with whatever reason the caller chose.
400
+ */
401
+ function isCancellation(error, signal) {
402
+ if (signal?.aborted) return true;
403
+ const name = error?.name;
404
+ return name === "AbortError" || name === "TimeoutError";
405
+ }
406
+ /**
407
+ * What a cancelled call rejects with: its signal's reason. WebKit's fetch
408
+ * rejects, and errors a half-read body, with a generic AbortError instead,
409
+ * dropping a TimeoutError or the caller's own reason.
410
+ */
411
+ function cancellationError(error, signal) {
412
+ return signal?.aborted ? signal.reason : error;
413
+ }
414
+ const isRecord = (value) => typeof value === "object" && value !== null;
415
+ /** `message` → `error` (string) → `error.message` → `detail`. */
416
+ function defaultErrorDetail(data) {
417
+ if (!isRecord(data)) return void 0;
418
+ if (typeof data.message === "string") return data.message;
419
+ if (typeof data.error === "string") return data.error;
420
+ if (isRecord(data.error) && typeof data.error.message === "string") return data.error.message;
421
+ if (typeof data.detail === "string") return data.detail;
422
+ }
423
+ /** How much of an error body arthron reads. Error bodies are for diagnosis; a hostile or broken server must not be able to fill memory with one. */
424
+ const MAX_ERROR_BODY_BYTES = 65536;
425
+ /** Up to `limit` bytes of the body; the rest is cancelled unread. `undefined` when there is no body or reading it failed. */
426
+ async function readCapped(response, limit) {
427
+ if (!response.body) return void 0;
428
+ let reader;
429
+ const chunks = [];
430
+ let size = 0;
431
+ try {
432
+ reader = response.body.getReader();
433
+ while (size <= limit) {
434
+ const { value, done } = await reader.read();
435
+ if (done) break;
436
+ chunks.push(value);
437
+ size += value.byteLength;
438
+ }
439
+ } catch {
440
+ return;
441
+ } finally {
442
+ await reader?.cancel().catch(() => {});
443
+ reader?.releaseLock();
444
+ }
445
+ const bytes = new Uint8Array(size);
446
+ let offset = 0;
447
+ for (const chunk of chunks) {
448
+ bytes.set(chunk, offset);
449
+ offset += chunk.byteLength;
450
+ }
451
+ return size > limit ? {
452
+ bytes: bytes.subarray(0, limit),
453
+ truncated: true
454
+ } : {
455
+ bytes,
456
+ truncated: false
457
+ };
458
+ }
459
+ /** JSON if the whole body parses, else the text (cut at the limit), else `undefined` when empty. */
460
+ function parseBody(read) {
461
+ const text = new TextDecoder().decode(read.bytes);
462
+ if (text === "") return void 0;
463
+ if (read.truncated) return text;
464
+ try {
465
+ return JSON.parse(text);
466
+ } catch {
467
+ return text;
468
+ }
469
+ }
470
+ /** The request as error messages name it: method and URL, no query or userinfo. */
471
+ function describeRequest(request) {
472
+ return `${request.init.method ?? "GET"} ${redactUrl(request.url)}`;
473
+ }
474
+ async function responseError(response, request, resolve = defaultErrorDetail) {
475
+ const read = await readCapped(response, MAX_ERROR_BODY_BYTES);
476
+ const data = read && parseBody(read);
477
+ if (read) response = rebuildResponse(response, read.bytes);
478
+ const status = response.statusText ? `${response.status} ${response.statusText}` : String(response.status);
479
+ return new ArthronError({
480
+ message: `${describeRequest(request)} failed with ${status}`,
481
+ detail: resolve(data, response) || void 0,
482
+ status: response.status,
483
+ statusText: response.statusText,
484
+ headers: response.headers,
485
+ data,
486
+ request,
487
+ response
488
+ });
489
+ }
490
+ /**
491
+ * Browsers that stream a request body (Chromium, current WebKit) do it only
492
+ * over HTTP/2, and fail one sent to an HTTP/1.1 server with the same bare
493
+ * "Failed to fetch" / "Load failed" as a dead server, so name the likely cause.
494
+ */
495
+ const STREAM_BODY_HINT = " — in a browser, a ReadableStream body streams only over HTTP/2; send a Blob or ArrayBuffer to an HTTP/1.1 server";
496
+ function networkError(cause, request) {
497
+ const reason = cause instanceof Error && cause.message ? cause.message : "network error";
498
+ const hint = isReadableStream(request.init.body) ? STREAM_BODY_HINT : "";
499
+ return new ArthronError({
500
+ message: `${describeRequest(request)} failed: ${reason}${hint}`,
501
+ status: 0,
502
+ statusText: "",
503
+ headers: new Headers(),
504
+ data: void 0,
505
+ request,
506
+ cause
507
+ });
508
+ }
509
+ //#endregion
510
+ //#region src/response/schema-validation.ts
511
+ const pathOf = (issue) => (issue.path ?? []).map((part) => String(typeof part === "object" && part !== null ? part.key : part)).join(".");
512
+ /**
513
+ * `.json(schema)` got a body that does not match. Not an ArthronError: the
514
+ * request succeeded — the data is what is wrong.
515
+ */
516
+ var SchemaError = class extends Error {
517
+ name = "SchemaError";
518
+ /** Every problem the validator reported, with the path to it. */
519
+ issues;
520
+ constructor(issues) {
521
+ const [first] = issues;
522
+ const where = first ? pathOf(first) : "";
523
+ const more = issues.length > 1 ? ` (+${issues.length - 1} more)` : "";
524
+ super(`arthron: the response body does not match the schema — ${where ? `${where}: ` : ""}${first?.message ?? "invalid"}${more}. See error.issues`);
525
+ this.issues = issues;
526
+ }
527
+ };
528
+ /** Validate `data`; resolve with the schema's output or reject with a SchemaError. */
529
+ async function validate(schema, data) {
530
+ const result = await schema["~standard"].validate(data);
531
+ if (result.issues) throw new SchemaError(result.issues);
532
+ return result.value;
533
+ }
534
+ //#endregion
535
+ //#region src/response/stream-parser.ts
536
+ const DEFAULT_LIMIT = 8388608;
537
+ /**
538
+ * Yield the body line by line, reassembling lines split across chunks. Only
539
+ * the newly arrived text is scanned, so a long line costs linear time; a line
540
+ * past `maxLineLength` stops the stream instead of growing without bound.
541
+ */
542
+ async function* lines(response, maxLineLength) {
208
543
  if (!response.body) return;
209
544
  const reader = response.body.getReader();
210
545
  const decoder = new TextDecoder();
211
- let buffer = "";
546
+ const breaks = /\r\n|\r|\n/g;
547
+ let pending = "";
548
+ let afterCR = false;
212
549
  try {
213
550
  while (true) {
214
551
  const { value, done } = await reader.read();
215
- buffer += decoder.decode(value, { stream: !done });
216
- const parts = buffer.split(/\r\n|\r|\n/);
217
- buffer = parts.pop() ?? "";
218
- for (const line of parts) yield line;
552
+ let text = decoder.decode(value, { stream: !done });
553
+ if (afterCR && text.startsWith("\n")) {
554
+ text = text.slice(1);
555
+ afterCR = false;
556
+ }
557
+ if (text) afterCR = text.endsWith("\r");
558
+ let start = 0;
559
+ breaks.lastIndex = 0;
560
+ for (let m = breaks.exec(text); m; m = breaks.exec(text)) {
561
+ yield pending + text.slice(start, m.index);
562
+ pending = "";
563
+ start = breaks.lastIndex;
564
+ }
565
+ pending += text.slice(start);
566
+ 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`);
219
567
  if (done) break;
220
568
  }
221
- if (buffer) yield buffer;
569
+ if (pending) yield pending;
222
570
  } finally {
571
+ await reader.cancel().catch(() => {});
223
572
  reader.releaseLock();
224
573
  }
225
574
  }
@@ -227,28 +576,31 @@ async function* lines(response) {
227
576
  * Parse a `text/event-stream` body into events. Follows the spec: a blank
228
577
  * line dispatches, `data:` lines are joined with `\n`, one leading space
229
578
  * after the colon is dropped, lines starting with `:` are comments, and a
230
- * block without `data` is not dispatched. Provider sentinels like
579
+ * block without `data` — or one the stream ends inside — is not dispatched.
580
+ * Provider sentinels like
231
581
  * `[DONE]` are passed through untouched — they are not part of the spec.
232
582
  */
233
- async function* parseSse(response) {
583
+ async function* parseSse(response, { maxLineLength = DEFAULT_LIMIT, maxEventSize = DEFAULT_LIMIT } = {}) {
234
584
  let data = [];
585
+ let size = 0;
235
586
  let event;
236
587
  let id;
588
+ let lastEventId = "";
237
589
  let retry;
238
590
  const flush = () => {
239
- if (data.length === 0) {
240
- event = id = retry = void 0;
241
- return;
242
- }
243
- const out = { data: data.join("\n") };
244
- if (event !== void 0) out.event = event;
245
- if (id !== void 0) out.id = id;
246
- if (retry !== void 0) out.retry = retry;
591
+ const out = data.length === 0 ? void 0 : {
592
+ data: data.join("\n"),
593
+ lastEventId
594
+ };
595
+ if (out && event !== void 0) out.event = event;
596
+ if (out && id !== void 0) out.id = id;
597
+ if (out && retry !== void 0) out.retry = retry;
247
598
  data = [];
248
- event = id = retry = void 0;
599
+ size = 0;
600
+ event = id = void 0;
249
601
  return out;
250
602
  };
251
- for await (const line of lines(response)) {
603
+ for await (const line of lines(response, maxLineLength)) {
252
604
  if (line === "") {
253
605
  const out = flush();
254
606
  if (out) yield out;
@@ -261,137 +613,216 @@ async function* parseSse(response) {
261
613
  if (value.startsWith(" ")) value = value.slice(1);
262
614
  switch (field) {
263
615
  case "data":
616
+ size += value.length + 1;
617
+ if (size > maxEventSize + 1) throw new RangeError(`arthron: an SSE event's data is longer than ${maxEventSize} characters — pass a larger maxEventSize to .sse() if that is expected`);
264
618
  data.push(value);
265
619
  break;
266
620
  case "event":
267
621
  event = value;
268
622
  break;
269
623
  case "id":
270
- id = value;
271
- break;
272
- case "retry": {
273
- const ms = Number(value);
274
- if (Number.isInteger(ms) && ms >= 0) retry = ms;
624
+ if (!value.includes("\0")) id = lastEventId = value;
275
625
  break;
276
- }
626
+ case "retry": if (/^\d+$/.test(value)) retry = Number(value);
277
627
  }
278
628
  }
279
- const out = flush();
280
- if (out) yield out;
281
629
  }
282
630
  /**
283
631
  * Parse newline-delimited JSON. Blank lines are skipped; a malformed line
284
632
  * rejects the iteration with the `SyntaxError` from `JSON.parse`.
285
633
  */
286
- async function* parseNdjson(response) {
287
- for await (const line of lines(response)) {
634
+ async function* parseNdjson(response, { maxLineLength = DEFAULT_LIMIT } = {}) {
635
+ for await (const line of lines(response, maxLineLength)) {
288
636
  if (line.trim() === "") continue;
289
637
  yield JSON.parse(line);
290
638
  }
291
639
  }
292
640
  //#endregion
293
- //#region src/promise.ts
641
+ //#region src/response/arthron-promise.ts
294
642
  /** Defer the Response until iteration starts, so the helper itself never rejects. */
295
- function iterate(promise, parse) {
643
+ function iterate(promise, parse, signal) {
644
+ promise.catch(() => {});
296
645
  return { async *[Symbol.asyncIterator]() {
297
- yield* parse(await promise);
646
+ try {
647
+ yield* parse(await promise);
648
+ } catch (error) {
649
+ throw cancellationError(error, signal);
650
+ }
298
651
  } };
299
652
  }
300
653
  /**
301
654
  * Sugar over `Promise<Response>`: `.json()` is `(await p).json()`. Awaiting
302
655
  * without a helper still yields the untouched `Response`.
303
656
  */
304
- function toArthronPromise(promise) {
657
+ function toArthronPromise(promise, signal) {
658
+ const read = (body) => promise.then(body).catch((error) => {
659
+ throw cancellationError(error, signal);
660
+ });
305
661
  return Object.assign(promise, {
306
- json: () => promise.then((res) => res.json()),
307
- text: () => promise.then((res) => res.text()),
308
- blob: () => promise.then((res) => res.blob()),
309
- arrayBuffer: () => promise.then((res) => res.arrayBuffer()),
310
- formData: () => promise.then((res) => res.formData()),
311
- sse: () => iterate(promise, parseSse),
312
- ndjson: () => iterate(promise, parseNdjson)
662
+ json: ((schema) => read(async (res) => {
663
+ const data = await res.json();
664
+ return schema ? validate(schema, data) : data;
665
+ })),
666
+ text: () => read((res) => res.text()),
667
+ blob: () => read((res) => res.blob()),
668
+ arrayBuffer: () => read((res) => res.arrayBuffer()),
669
+ formData: () => read((res) => res.formData()),
670
+ sse: (options) => iterate(promise, (res) => parseSse(res, options), signal),
671
+ ndjson: (options) => iterate(promise, (res) => parseNdjson(res, options), signal)
313
672
  });
314
673
  }
315
674
  //#endregion
316
- //#region src/url.ts
317
- const ABSOLUTE_URL = /^[a-z][a-z0-9+.-]*:\/\//i;
318
- function isAbsoluteUrl(path) {
319
- return ABSOLUTE_URL.test(path);
320
- }
321
- function toQueryString(params) {
322
- if (!params) return "";
323
- const search = new URLSearchParams();
324
- for (const [key, value] of Object.entries(params)) {
325
- if (value === null || value === void 0) continue;
326
- search.append(key, String(value));
327
- }
328
- return search.toString();
329
- }
675
+ //#region src/client/resolve-config.ts
330
676
  /**
331
- * Join `baseURL` and `path` by plain string concatenation so a path prefix in
332
- * the base (`/v1`) survives a leading slash in the path. WHATWG `new URL()`
333
- * would drop it. Absolute paths skip the base entirely.
677
+ * Config keys that configure arthron itself; every other key is a fetch
678
+ * default. Typed so that a new config key fails to compile until it is
679
+ * listed here — otherwise it would be sent to fetch.
334
680
  */
335
- function resolveUrl(baseURL, path, params) {
336
- let url;
337
- if (isAbsoluteUrl(path)) url = path;
338
- else {
339
- if (baseURL === void 0) throw new TypeError(`arthron: relative path "${path}" needs a baseURL — pass baseURL in arthron({ baseURL }) or use an absolute URL`);
340
- url = `${baseURL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
681
+ const OWN_KEYS = {
682
+ baseURL: true,
683
+ allowAbsoluteUrls: true,
684
+ headers: true,
685
+ fetch: true,
686
+ use: true,
687
+ errorDetail: true,
688
+ throwOn: true
689
+ };
690
+ /** Fields that belong to one request, refused as client defaults. */
691
+ const PER_CALL_ONLY = [
692
+ "signal",
693
+ "body",
694
+ "method"
695
+ ];
696
+ /**
697
+ * Add plugins to a list. A named plugin takes the place of the one already
698
+ * there with that name — same position, so a replaced `logging()` stays
699
+ * outermost and a second `retry()` does not multiply attempts. Unnamed
700
+ * plugins append.
701
+ */
702
+ function addPlugins(base, added) {
703
+ const out = [...base];
704
+ for (const plugin of added) {
705
+ const at = plugin.name === void 0 ? -1 : out.findIndex((p) => p.name === plugin.name);
706
+ if (at === -1) out.push(plugin);
707
+ else out[at] = plugin;
341
708
  }
342
- const query = toQueryString(params);
343
- if (!query) return url;
344
- const hash = url.indexOf("#");
345
- const [base, fragment] = hash === -1 ? [url, ""] : [url.slice(0, hash), url.slice(hash)];
346
- return `${base}${base.includes("?") ? "&" : "?"}${query}${fragment}`;
709
+ return out;
347
710
  }
348
- //#endregion
349
- //#region src/client.ts
350
- const BODYLESS_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD"]);
351
- /** Normalize once so `extend` can merge headers and append plugins cheaply. */
711
+ /** The fetch defaults in a config: every set key that is not arthron's own. */
712
+ function fetchDefaults(config) {
713
+ return Object.fromEntries(Object.entries(config).filter(([key, value]) => value !== void 0 && !(key in OWN_KEYS)));
714
+ }
715
+ /** Normalize once so `extend` can merge headers and add plugins cheaply. */
352
716
  function resolveConfig(config) {
717
+ for (const key of PER_CALL_ONLY) if (config[key] !== void 0) throw new TypeError(`arthron: ${key} cannot be a client default, it belongs to one request — pass it per call (api.get(path, { signal }), api.post(path, body), api.request(path, { method }))`);
353
718
  const { headers, use, ...rest } = config;
354
719
  return {
355
720
  ...rest,
356
721
  headers: toHeaderRecord(headers),
357
- use: use ?? []
722
+ use: addPlugins([], use ?? [])
358
723
  };
359
724
  }
725
+ //#endregion
726
+ //#region src/client/send.ts
727
+ /**
728
+ * The global fetch rejects a malformed URL or method with a TypeError that
729
+ * looks just like a network failure (status 0, retried). A bodiless `Request`
730
+ * runs the same checks first, so those become argument errors instead. Only
731
+ * for the global fetch: an injected one owns its URL rules (SvelteKit's
732
+ * server-side `fetch` accepts relative URLs, for one).
733
+ */
734
+ function assertSendable(url, method) {
735
+ if (typeof Request !== "function") return;
736
+ try {
737
+ new Request(url, { method });
738
+ } catch (cause) {
739
+ const shown = redactUrl(url);
740
+ const reason = (cause instanceof Error ? cause.message : String(cause)).split(url).join(shown);
741
+ 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 });
742
+ }
743
+ }
744
+ /**
745
+ * A ReadableStream body the browser cannot stream is rejected before it is
746
+ * sent: Firefox would send the text "[object ReadableStream]", older Safari
747
+ * fails with a bare network error. Only for the global fetch, like
748
+ * assertSendable.
749
+ */
750
+ function assertStreamable(url, method, body) {
751
+ if (!isReadableStream(body) || runtimeStreamsRequestBody()) return;
752
+ throw new TypeError(`arthron: cannot send ${method} ${redactUrl(url)} with a ReadableStream body — this browser cannot stream a request body and would send it garbled or not at all; pass a Blob or ArrayBuffer instead`);
753
+ }
754
+ /**
755
+ * Non-ok responses the caller asked for are not failures: an opaque one
756
+ * (`mode: "no-cors"`) and the 3xx of `redirect: "manual"`.
757
+ */
758
+ function failedByDefault(res, init) {
759
+ if (res.type === "opaque" || res.type === "opaqueredirect") return false;
760
+ return !(init.redirect === "manual" && res.status >= 300 && res.status < 400);
761
+ }
762
+ /** The one place a failure becomes an ArthronError. Cancellation passes through. */
763
+ async function send(resolved, ctx, throwOn) {
764
+ const fetchImpl = resolved.fetch ?? globalThis.fetch;
765
+ if (resolved.fetch === void 0) {
766
+ const method = ctx.init.method ?? "GET";
767
+ assertSendable(ctx.url, method);
768
+ assertStreamable(ctx.url, method, ctx.init.body);
769
+ }
770
+ const { signal } = ctx.init;
771
+ let res;
772
+ try {
773
+ res = await fetchImpl(ctx.url, ctx.init);
774
+ } catch (thrown) {
775
+ if (isCancellation(thrown, signal)) throw cancellationError(thrown, signal);
776
+ throw networkError(thrown, ctx);
777
+ }
778
+ if (res.ok) return res;
779
+ if (!(throwOn ? throwOn(res) : failedByDefault(res, ctx.init))) return res;
780
+ const error = await responseError(res, ctx, resolved.errorDetail);
781
+ if (signal?.aborted) throw signal.reason;
782
+ throw error;
783
+ }
784
+ //#endregion
785
+ //#region src/client/client.ts
786
+ const BODYLESS_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD"]);
787
+ /** Sent uppercased whatever their case; fetch itself only normalizes six. */
788
+ const STANDARD_METHODS = /* @__PURE__ */ new Set([
789
+ "GET",
790
+ "HEAD",
791
+ "POST",
792
+ "PUT",
793
+ "PATCH",
794
+ "DELETE",
795
+ "OPTIONS",
796
+ "QUERY"
797
+ ]);
360
798
  /**
361
799
  * Build a client from config. The result is immutable — `use` and `extend`
362
800
  * return new clients and never touch the one they were called on.
363
801
  */
364
802
  function arthron(config = {}) {
365
803
  const resolved = resolveConfig(config);
366
- /** The one place a failure becomes an ArthronError. Cancellation passes through. */
367
- const send = async (ctx) => {
368
- const fetchImpl = resolved.fetch ?? globalThis.fetch;
369
- let res;
370
- try {
371
- res = await fetchImpl(ctx.url, ctx.init);
372
- } catch (thrown) {
373
- if (isCancellation(thrown)) throw thrown;
374
- throw networkError(thrown, ctx);
375
- }
376
- if (!res.ok) throw await responseError(res, ctx, resolved.errorMessage);
377
- return res;
378
- };
379
- const dispatch = (path, method, rawBody, { params, headers: callHeaders, ...init }) => {
804
+ const initDefaults = fetchDefaults(resolved);
805
+ const dispatch = (path, method, rawBody, { params, headers: callHeaders, meta, throwOn = resolved.throwOn, path: pathValues, ...init }) => {
380
806
  try {
381
- if (rawBody !== void 0 && BODYLESS_METHODS.has(method.toUpperCase())) throw new TypeError(`arthron: ${method.toUpperCase()} requests cannot carry a body — send it as params (query) or switch to POST`);
382
- const url = resolveUrl(resolved.baseURL, path, params);
383
- const { body, headers } = serializeBody(rawBody, mergeHeaders(resolved.headers, callHeaders));
807
+ const upper = method.toUpperCase();
808
+ const verb = STANDARD_METHODS.has(upper) ? upper : method;
809
+ 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`);
810
+ if (pathValues) path = fillPath(path, pathValues);
811
+ const url = resolveUrl(resolved.baseURL, path, params, resolved.allowAbsoluteUrls);
812
+ const { body, headers } = serializeBody(rawBody, mergeHeaders(leavesBase(resolved.baseURL, path) ? void 0 : resolved.headers, callHeaders));
384
813
  const requestInit = {
385
- ...resolved.credentials !== void 0 && { credentials: resolved.credentials },
814
+ ...initDefaults,
386
815
  ...init,
387
- method,
816
+ method: verb,
388
817
  headers
389
818
  };
390
819
  if (body !== void 0) requestInit.body = body;
820
+ if (isReadableStream(body)) Object.assign(requestInit, { duplex: "half" });
391
821
  return toArthronPromise(runPipeline(resolved.use, {
392
822
  url,
393
- init: requestInit
394
- }, send));
823
+ init: requestInit,
824
+ meta: { ...meta }
825
+ }, (ctx) => send(resolved, ctx, throwOn)), init.signal);
395
826
  } catch (thrown) {
396
827
  return toArthronPromise(Promise.reject(thrown));
397
828
  }
@@ -401,7 +832,7 @@ function arthron(config = {}) {
401
832
  ...resolved,
402
833
  ...next,
403
834
  headers: mergeHeaders(resolved.headers, next.headers),
404
- use: [...resolved.use, ...next.use ?? []]
835
+ use: addPlugins(resolved.use, next.use ?? [])
405
836
  });
406
837
  };
407
838
  return {
@@ -411,17 +842,22 @@ function arthron(config = {}) {
411
842
  post: (path, body, init = {}) => dispatch(path, "POST", body, init),
412
843
  put: (path, body, init = {}) => dispatch(path, "PUT", body, init),
413
844
  patch: (path, body, init = {}) => dispatch(path, "PATCH", body, init),
845
+ query: (path, body, init = {}) => dispatch(path, "QUERY", body, init),
414
846
  request: (path, init) => {
415
847
  const { method, body, ...rest } = init;
416
848
  if (!method) return toArthronPromise(Promise.reject(/* @__PURE__ */ new TypeError("arthron: request() needs init.method — e.g. request(path, { method: \"OPTIONS\" }); use get/post/... for the common verbs")));
417
849
  return dispatch(path, method, body, rest);
418
850
  },
419
851
  use: (...plugins) => derive({ use: plugins }),
420
- extend: (next) => derive(next)
852
+ extend: (next) => derive(next),
853
+ without: (...names) => arthron({
854
+ ...resolved,
855
+ use: resolved.use.filter((p) => p.name === void 0 || !names.includes(p.name))
856
+ })
421
857
  };
422
858
  }
423
859
  //#endregion
424
- //#region src/define.ts
860
+ //#region src/pipeline/define-plugin.ts
425
861
  /** Identity helper: gives a middleware written in its own file the right parameter types. */
426
862
  const defineMiddleware = (middleware) => middleware;
427
863
  /** Identity helpers for each interceptor kind — `defineInterceptor.request(fn)` etc. */
@@ -433,16 +869,7 @@ const defineInterceptor = {
433
869
  /** Identity helper: type-checks a plugin object literal without an annotation. */
434
870
  const definePlugin = (plugin) => plugin;
435
871
  //#endregion
436
- //#region src/plugins/logging.ts
437
- /** Origin + pathname only — the query and userinfo tend to carry secrets. */
438
- function displayPath(url) {
439
- try {
440
- const parsed = new URL(url);
441
- return `${parsed.origin}${parsed.pathname}`;
442
- } catch {
443
- return url.split("?")[0] ?? url;
444
- }
445
- }
872
+ //#region src/plugins/logging/logging.ts
446
873
  function consoleSink(logger) {
447
874
  return (e) => {
448
875
  if (e.phase === "start") logger.log(`[arthron] → ${e.method} ${e.path}`);
@@ -464,7 +891,7 @@ function logging(options = {}) {
464
891
  middleware: async (ctx, next) => {
465
892
  if (!enabled) return next(ctx);
466
893
  const method = ctx.init.method ?? "GET";
467
- const path = displayPath(ctx.url);
894
+ const path = redactUrl(ctx.url);
468
895
  const started = now();
469
896
  const ms = () => Math.round(now() - started);
470
897
  emit({
@@ -472,18 +899,11 @@ function logging(options = {}) {
472
899
  method,
473
900
  path
474
901
  });
902
+ let res;
475
903
  try {
476
- const res = await next(ctx);
477
- emit({
478
- phase: "success",
479
- method,
480
- path,
481
- status: res.status,
482
- ms: ms()
483
- });
484
- return res;
904
+ res = await next(ctx);
485
905
  } catch (error) {
486
- if (!isCancellation(error)) {
906
+ if (!isCancellation(error, ctx.init.signal)) {
487
907
  const status = isArthronError(error) ? error.status : void 0;
488
908
  emit({
489
909
  phase: "error",
@@ -496,11 +916,19 @@ function logging(options = {}) {
496
916
  }
497
917
  throw error;
498
918
  }
919
+ emit({
920
+ phase: "success",
921
+ method,
922
+ path,
923
+ status: res.status,
924
+ ms: ms()
925
+ });
926
+ return res;
499
927
  }
500
928
  };
501
929
  }
502
930
  //#endregion
503
- //#region src/plugins/retry.ts
931
+ //#region src/plugins/retry/retry.ts
504
932
  const DEFAULT_RETRY_ON = [
505
933
  408,
506
934
  429,
@@ -513,10 +941,13 @@ const DEFAULT_METHODS = [
513
941
  "GET",
514
942
  "HEAD",
515
943
  "OPTIONS",
944
+ "QUERY",
516
945
  "PUT",
517
946
  "DELETE"
518
947
  ];
519
948
  const DEFAULT_MAX_RETRY_AFTER = 6e4;
949
+ /** setTimeout fires at once past this, so every wait is capped to it. */
950
+ const MAX_TIMER_MS$1 = 2 ** 31 - 1;
520
951
  function defaultSleep(ms, signal) {
521
952
  return new Promise((resolve, reject) => {
522
953
  if (signal?.aborted) return reject(signal.reason);
@@ -541,43 +972,70 @@ function retryAfterMs(error) {
541
972
  if (Number.isNaN(at)) return void 0;
542
973
  return Math.max(0, at - Date.now());
543
974
  }
544
- function isStreamBody(ctx) {
545
- return typeof ReadableStream !== "undefined" && ctx.init.body instanceof ReadableStream;
975
+ /** The rule without `shouldRetry`: a network error or a `retryOn` status, on a method in `methods`. */
976
+ function byDefault(thrown, method, settings) {
977
+ if (!settings.allowed.has(method) || !isArthronError(thrown)) return false;
978
+ return thrown.status === 0 || settings.retryOn.includes(thrown.status);
979
+ }
980
+ /** Defaults filled in and `retries` checked — `attempt >= NaN` is never true, so an unset env var would retry forever. */
981
+ function settle(options, where) {
982
+ 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;
983
+ if (!Number.isInteger(retries) || retries < 0) throw new TypeError(`arthron: ${where} retries: ${retries} needs a whole number ≥ 0 — e.g. retries: 2`);
984
+ return {
985
+ retries,
986
+ retryOn,
987
+ allowed: new Set(methods.map((m) => m.toUpperCase())),
988
+ delay,
989
+ maxRetryAfter,
990
+ shouldRetry,
991
+ onRetry
992
+ };
546
993
  }
547
994
  /**
548
995
  * Retry on network errors and the configured statuses by calling `next`
549
996
  * again — the whole attempt (request interceptors included) is re-run.
550
997
  * Only `ArthronError` is considered; cancellation and plugin bugs pass through.
998
+ * A call can turn it off or override its options with `meta.retry`.
551
999
  */
552
1000
  function retry(options = {}) {
553
- const { retries = 2, retryOn = DEFAULT_RETRY_ON, methods = DEFAULT_METHODS, delay = (retry) => 300 * 2 ** retry, sleep = defaultSleep, maxRetryAfter = DEFAULT_MAX_RETRY_AFTER } = options;
554
- const allowed = new Set(methods.map((m) => m.toUpperCase()));
1001
+ const { sleep = defaultSleep, ...rest } = options;
1002
+ const base = settle(rest, "retry()");
555
1003
  return {
556
1004
  name: "retry",
557
1005
  middleware: async (ctx, next) => {
1006
+ const override = ctx.meta?.retry;
1007
+ if (override === false) return next(ctx);
1008
+ const settings = override ? settle({
1009
+ ...rest,
1010
+ ...override
1011
+ }, "meta.retry") : base;
558
1012
  const method = (ctx.init.method ?? "GET").toUpperCase();
559
- const eligible = allowed.has(method) && !isStreamBody(ctx);
1013
+ const resendable = !isReadableStream(ctx.init.body);
560
1014
  for (let attempt = 0;; attempt += 1) try {
561
1015
  return await next(ctx);
562
1016
  } catch (thrown) {
563
- if (!eligible || attempt >= retries) throw thrown;
564
- if (!isArthronError(thrown)) throw thrown;
565
- if (thrown.status !== 0 && !retryOn.includes(thrown.status)) throw thrown;
1017
+ if (!resendable || attempt >= settings.retries) throw thrown;
566
1018
  if (ctx.init.signal?.aborted) throw thrown;
567
- const retryAfter = retryAfterMs(thrown);
568
- if (retryAfter !== void 0 && retryAfter > maxRetryAfter) throw thrown;
569
- await sleep(retryAfter ?? delay(attempt, thrown), ctx.init.signal);
1019
+ const verdict = settings.shouldRetry?.(thrown, attempt, ctx);
1020
+ if (verdict === false) throw thrown;
1021
+ if (verdict === void 0 && !byDefault(thrown, method, settings)) throw thrown;
1022
+ const retryAfter = isArthronError(thrown) ? retryAfterMs(thrown) : void 0;
1023
+ if (retryAfter !== void 0 && retryAfter > settings.maxRetryAfter) throw thrown;
1024
+ const wait = Math.min(retryAfter ?? settings.delay(attempt, thrown), MAX_TIMER_MS$1);
1025
+ settings.onRetry?.(thrown, attempt, wait);
1026
+ await sleep(wait, ctx.init.signal);
570
1027
  }
571
1028
  }
572
1029
  };
573
1030
  }
574
1031
  //#endregion
575
- //#region src/plugins/timeout.ts
1032
+ //#region src/plugins/timeout/timeout.ts
1033
+ const MAX_TIMER_MS = 2 ** 31 - 1;
576
1034
  /**
577
1035
  * Wrap `body` so the deadline keeps running until it is fully read, and so an
578
1036
  * abort while reading surfaces as a stream error instead of a silent stall.
579
1037
  */
580
- function guardBody(body, signal, finish) {
1038
+ function guardBody(body, signal, finish, onChunk) {
581
1039
  const reader = body.getReader();
582
1040
  return new ReadableStream({
583
1041
  start(controller) {
@@ -593,6 +1051,7 @@ function guardBody(body, signal, finish) {
593
1051
  controller.close();
594
1052
  return;
595
1053
  }
1054
+ onChunk();
596
1055
  controller.enqueue(value);
597
1056
  },
598
1057
  cancel(reason) {
@@ -601,37 +1060,64 @@ function guardBody(body, signal, finish) {
601
1060
  }
602
1061
  });
603
1062
  }
1063
+ /** setTimeout fires at once for NaN, negatives and anything past 2^31-1. */
1064
+ function checkDeadline(ms, where) {
1065
+ if (ms === Number.POSITIVE_INFINITY || ms >= 0 && ms <= MAX_TIMER_MS) return;
1066
+ throw new TypeError(`arthron: ${where} needs 0–${MAX_TIMER_MS} ms — pass Infinity for no deadline`);
1067
+ }
1068
+ /** A timer that aborts `controller` with a TimeoutError and never keeps Node alive on its own. */
1069
+ function deadlineTimer(controller, ms, message) {
1070
+ const timer = setTimeout(() => controller.abort(new DOMException(message, "TimeoutError")), ms);
1071
+ timer.unref?.();
1072
+ return timer;
1073
+ }
604
1074
  /**
605
1075
  * Abort the request when it takes longer than `ms` — counted from the start
606
1076
  * of the attempt until the body has been fully read, so a server that sends
607
- * headers and then stalls still trips it. Rejects with a `TimeoutError` (a
1077
+ * headers and then stalls still trips it. With `idle`, also abort once no
1078
+ * data has arrived for that long. Rejects with a `TimeoutError` (a
608
1079
  * `DOMException`, like `AbortSignal.timeout()` would give); it is not an
609
1080
  * `ArthronError`, so error interceptors and `retry` leave it alone.
610
1081
  *
611
- * Built on `AbortController` + `setTimeout` rather than `AbortSignal.any` /
612
- * `AbortSignal.timeout` so it runs on Node 18.
1082
+ * A call can change the total deadline with `meta.timeout`.
1083
+ *
1084
+ * Deadlines are timers on an AbortController — not `AbortSignal.timeout`,
1085
+ * since the idle one restarts — joined to the caller's signal with
1086
+ * `AbortSignal.any`.
613
1087
  */
614
- function timeout(ms) {
1088
+ function timeout(ms, options = {}) {
1089
+ checkDeadline(ms, `timeout(${ms})`);
1090
+ const { idle } = options;
1091
+ if (idle !== void 0) checkDeadline(idle, `timeout(…, { idle: ${idle} })`);
615
1092
  return {
616
1093
  name: "timeout",
617
1094
  middleware: async (ctx, next) => {
1095
+ const deadline = ctx.meta?.timeout ?? ms;
1096
+ if (deadline !== ms) checkDeadline(deadline, `meta.timeout: ${deadline}`);
1097
+ const idleMs = idle === Number.POSITIVE_INFINITY ? void 0 : idle;
1098
+ if (deadline === Number.POSITIVE_INFINITY && idleMs === void 0) return next(ctx);
618
1099
  const controller = new AbortController();
619
1100
  const outer = ctx.init.signal;
620
- const forward = () => controller.abort(outer?.reason);
621
- if (outer?.aborted) forward();
622
- else outer?.addEventListener("abort", forward, { once: true });
623
- const timer = setTimeout(() => controller.abort(new DOMException(`arthron: request timed out after ${ms}ms`, "TimeoutError")), ms);
1101
+ const signal = outer ? AbortSignal.any([outer, controller.signal]) : controller.signal;
1102
+ const total = deadline === Number.POSITIVE_INFINITY ? void 0 : deadlineTimer(controller, deadline, `arthron: request timed out after ${deadline}ms`);
1103
+ let quiet;
1104
+ const stillAlive = () => {
1105
+ if (idleMs === void 0) return;
1106
+ clearTimeout(quiet);
1107
+ quiet = deadlineTimer(controller, idleMs, `arthron: no data for ${idleMs}ms`);
1108
+ };
624
1109
  const finish = () => {
625
- clearTimeout(timer);
626
- outer?.removeEventListener("abort", forward);
1110
+ clearTimeout(total);
1111
+ clearTimeout(quiet);
627
1112
  };
1113
+ stillAlive();
628
1114
  let res;
629
1115
  try {
630
1116
  res = await next({
631
1117
  ...ctx,
632
1118
  init: {
633
1119
  ...ctx.init,
634
- signal: controller.signal
1120
+ signal
635
1121
  }
636
1122
  });
637
1123
  } catch (thrown) {
@@ -642,15 +1128,12 @@ function timeout(ms) {
642
1128
  finish();
643
1129
  return res;
644
1130
  }
645
- return new Response(guardBody(res.body, controller.signal, finish), {
646
- status: res.status,
647
- statusText: res.statusText,
648
- headers: res.headers
649
- });
1131
+ stillAlive();
1132
+ return rebuildResponse(res, guardBody(res.body, signal, finish, stillAlive));
650
1133
  }
651
1134
  };
652
1135
  }
653
1136
  //#endregion
654
- export { ArthronError, arthron, defineInterceptor, defineMiddleware, definePlugin, isArthronError, logging, retry, timeout };
1137
+ export { ArthronError, SchemaError, arthron, defineInterceptor, defineMiddleware, definePlugin, isArthronError, logging, retry, timeout };
655
1138
 
656
1139
  //# sourceMappingURL=index.mjs.map