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