arthron 1.2.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,50 +1,71 @@
1
- # arthron
1
+ <h1 align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset=".github/assets/logo/arthron-lockup-dark.svg">
4
+ <img alt="arthron" src=".github/assets/logo/arthron-lockup.svg" width="300">
5
+ </picture>
6
+ </h1>
7
+
8
+ <p align="center">A lightweight <code>fetch</code> client you assemble from plugins.</p>
9
+
10
+ <p align="center">
11
+ <a href="https://www.npmjs.com/package/arthron"><img alt="npm version" src="https://img.shields.io/npm/v/arthron?color=14b8a6"></a>
12
+ <a href="https://github.com/lapidix/arthron/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/lapidix/arthron/actions/workflows/ci.yml/badge.svg?branch=main"></a>
13
+ <a href="https://bundlejs.com/?q=arthron"><img alt="minified and gzipped size" src="https://img.shields.io/bundlejs/size/arthron?color=14b8a6"></a>
14
+ <a href="https://www.npmjs.com/package/arthron?activeTab=dependencies"><img alt="zero dependencies" src="https://img.shields.io/badge/dependencies-0-14b8a6"></a>
15
+ <a href="#quick-start"><img alt="Node 22 or later" src="https://img.shields.io/node/v/arthron?color=14b8a6"></a>
16
+ <a href="#reference"><img alt="TypeScript types included" src="https://img.shields.io/npm/types/arthron?color=14b8a6"></a>
17
+ <a href="https://github.com/lapidix/arthron/blob/main/LICENSE"><img alt="MIT license" src="https://img.shields.io/npm/l/arthron?color=14b8a6"></a>
18
+ </p>
19
+
20
+ <p align="center">
21
+ <a href="#quick-start">Quick start</a> ·
22
+ <a href="#guide">Guide</a> ·
23
+ <a href="#how-a-call-flows">How a call flows</a> ·
24
+ <a href="#plugins">Plugins</a> ·
25
+ <a href="#reference">Reference</a> ·
26
+ <a href="https://github.com/lapidix/arthron/blob/main/CHANGELOG.md">Changelog</a>
27
+ </p>
28
+
29
+ ## Quick start
2
30
 
3
- A `fetch` client you assemble from plugins.
31
+ ```sh
32
+ npm i arthron
33
+ ```
4
34
 
5
35
  ```ts
6
- import { arthron } from "arthron";
36
+ import { arthron, isArthronError } from "arthron";
7
37
  import { logging, retry, timeout } from "arthron/plugins";
8
38
 
9
39
  const api = arthron({
10
40
  baseURL: "https://api.example.com",
11
- use: [logging(), timeout(10_000), retry()], // 10 s for the whole call, retries included
41
+ plugins: [logging(), timeout(10_000), retry()], // 10 s for the whole call, retries included
12
42
  });
13
43
 
14
- const user = await api.get("/me").json<User>();
15
- ```
16
-
17
- - **Plain `Response` in, plain `Response` out.** No wrapper type to learn. `.json<T>()` and friends are there when you want the body.
18
- - **A plugin holds middleware and interceptors, kept apart by the types.** Middleware wraps the call and controls the flow — it can call `next` again to retry, or not at all. Interceptors transform one value and return it; they cannot retry, skip, or swallow. Retry, timeouts, logging, auth refresh, and anything that reacts to how a call ended (error pages, domain errors) are middleware. Headers, trace ids and per-attempt error details are interceptors. One plugin can use both: `bearerAuth` sets the header in an interceptor and refreshes in middleware.
19
- - **One error type, two causes.** `ArthronError` means the server said no (`!response.ok`) or the network failed (`status: 0`). Everything else — cancellation, a bug in a plugin — is rethrown as-is.
20
- - Zero dependencies. ESM and CJS. TypeScript types included, for TypeScript 5.0+. Node 22+ and browsers.
21
-
22
- ## Install
23
-
24
- ```sh
25
- npm i arthron
44
+ try {
45
+ const user = await api.get("/users/{id}", { pathParams: { id } }).json<User>();
46
+ } catch (e) {
47
+ if (isArthronError(e)) toast(e.description ?? "Something went wrong"); // 4xx/5xx or a network failure
48
+ else throw e; // cancellation, or a bug in a plugin
49
+ }
26
50
  ```
27
51
 
28
- ## Usage
52
+ Coming from 1.x? Most old names throw a `TypeError` that names the new one; the full list is in [Moving from 1.x](CHANGELOG.md#moving-from-1x). Writing code with an AI agent? Point it at `llms.txt`, which ships in the package — see [For agents](#for-agents).
29
53
 
30
- ### Create a client
54
+ ## Why arthron
31
55
 
32
- ```ts
33
- const api = arthron({
34
- baseURL: "https://api.example.com", // prefix for relative paths
35
- headers: { accept: "application/json" }, // defaults; per-call headers win
36
- credentials: "include", // only if you need cookies cross-origin
37
- cache: "no-store", // any fetch option works as a default; per-call values win
38
- use: [retry()], // plugins, outermost first
39
- });
40
- ```
56
+ - **Plain `Response` in, plain `Response` out.** No wrapper type to learn: `await api.get(…)` is the `Response` fetch gave, and `.json()`, `.sse()` and friends are there when you want the body.
57
+ - **Everything beyond fetch is a plugin.** Retry, timeouts, auth refresh, logging and upload progress are plugins built on the same API yours use. A plugin holds middleware, which controls the flow (retry, skip, deadlines), and interceptors, which transform one value; the types keep the two apart.
58
+ - **One error type, two causes.** `ArthronError` means the server said no (`!response.ok`) or the network failed (`status: 0`). Everything else — cancellation, a bug in a plugin — is rethrown as-is.
59
+ - **Safe by default.** Requests stay on `baseURL`'s origin unless you list others, and the client's headers never leave it. Path values cannot escape their segment. Errors never print your tokens or query strings. Error bodies and streams are read within limits.
60
+ - **Mistakes fail loudly.** A wrong option, a body JSON would mangle, a 1.x name: each is a `TypeError` that says what to write instead, before anything is sent.
61
+ - **Small and portable.** Zero dependencies. ESM and CJS. TypeScript 5.0+. Node 22+ and browsers — CI runs the built package in Chromium, Firefox and WebKit.
41
62
 
42
- Every field is optional. Without `baseURL`, only absolute URLs are accepted. With one, an absolute URL must stay on `baseURL`'s origin unless you set `allowAbsoluteUrls: true` — and even then config `headers` are only sent to that origin.
63
+ ## Guide
43
64
 
44
- ### Make requests
65
+ ### Requests
45
66
 
46
67
  ```ts
47
- await api.get("/users", { params: { page: 2 } }); // → Response
68
+ await api.get("/users", { queryParams: { page: 2 } }); // → Response
48
69
  await api.post("/users", { name: "kim" }); // object body → JSON
49
70
  await api.post("/upload", formData); // FormData/Blob/string/stream → sent as-is
50
71
  await api.put("/users/1", { name: "lee" });
@@ -52,30 +73,46 @@ await api.patch("/users/1", { name: "lee" });
52
73
  await api.query("/search", { filter: { tag: "x" } }); // RFC 10008 QUERY: GET semantics, body carries the query
53
74
  await api.delete("/users/1");
54
75
  await api.head("/users/1");
55
- await api.request("/users", { method: "OPTIONS" });
76
+ await api.request("/users", { method: "OPTIONS" }); // any other verb
56
77
  ```
57
78
 
79
+ **Request options.** The last argument, `requestOptions`, takes everything `fetch` does (`signal`, `cache`, `headers`, …) plus:
80
+
81
+ - `queryParams` — an object whose `null`/`undefined` values are dropped and whose arrays become repeated keys (`{ ids: [1, 2] }` → `ids=1&ids=2`), or a `URLSearchParams`. Other object values (a `Date`, a nested object) are rejected rather than sent as `[object Object]`.
82
+ - `pathParams` — values for `{name}` placeholders: `api.get("/users/{id}", { pathParams: { id } })`. Each is encoded into its own segment, so an id like `../admin` or `x?role=admin` cannot change which endpoint you call (`encodeURIComponent` alone lets `..` through); `""`, `.` and `..` are refused, as are missing or unused values. Use it instead of string interpolation. Without `pathParams`, braces are left alone.
83
+ - `meta` — settings for plugins on this call, never sent (see [Plugins](#plugins)).
84
+ - `throwOn` — which non-ok responses reject on this call (see [Errors](#errors)).
85
+
86
+ A `null` (or `undefined`) header value drops a client default for that call: `api.get("/public", { headers: { authorization: null } })`.
87
+
88
+ **URLs** are joined by string concatenation: `baseURL: "https://a/v1"` + `"/users"` → `https://a/v1/users`. The `/v1` survives, unlike with `new URL()`. A query is appended with `&` if the path already has one, and placed before any `#fragment`. Where absolute URLs may go is set on the client — see [Where requests and headers go](#where-requests-and-headers-go).
89
+
90
+ **Bodies.** `string`, `FormData`, `Blob`, `ArrayBuffer`/views, `URLSearchParams` and `ReadableStream` go through untouched — recognised by type tag, so polyfill and cross-realm instances count too. A `ReadableStream` is sent with `duplex: "half"`, which fetch requires. Anything else — including `null` — becomes JSON with `content-type: application/json`; a content type you set must be a JSON one. Bodies JSON would mangle — a `Map`, `Set`, generator, Node stream or function — are rejected with a `TypeError` that says what to pass instead. A `FormData` body is always sent without a `content-type` header, even with a JSON default, so the runtime can add the multipart boundary. `GET`/`HEAD` with a body is a `TypeError`.
91
+
92
+ **Methods.** `query` sends the [RFC 10008](https://www.rfc-editor.org/rfc/rfc10008) QUERY method: safe and idempotent like GET, so `retry()` repeats it by default, but the query travels in the body. Any `fetch` sends it; the server has to understand it (Node's own `http` server does from v20). `request` is the way to any other verb; its `method` autocompletes the standard ones, and those go out uppercased whatever case you write them in (fetch itself leaves `"patch"` lowercase, which servers answer with 405). Other method names are sent as written.
93
+
94
+ **Argument errors reject, they don't throw.** No `baseURL` with a relative path, a URL that does not parse (`baseURL: "api.example.com"` without `https://`), a `BigInt` in a JSON body, a missing `method` in `request` — all surface as a rejected `TypeError` before anything is sent, never as a retried `status: 0`.
95
+
96
+ ### Responses
97
+
58
98
  Each call returns `Promise<Response>` with body helpers attached:
59
99
 
60
100
  ```ts
61
- const res = await api.get("/me"); // Response — status, headers, body untouched
101
+ const response = await api.get("/me"); // Response — status, headers, body untouched
62
102
  const user = await api.get("/me").json<User>(); // body parsed; T is a cast, not validation
63
103
  const safe = await api.get("/me").json(UserSchema); // validated by any Standard Schema (Zod, Valibot, ArkType); typed from it
64
104
  const text = await api.get("/robots.txt").text();
65
105
  const blob = await api.get("/export").blob();
66
106
  const stream = (await api.post("/chat", prompt)).body; // ReadableStream
67
- for await (const e of api.post("/chat", prompt).sse()) {} // see "Stream responses"
68
107
  ```
69
108
 
70
- `init` accepts everything `fetch` does (`signal`, `cache`, `headers`, …) plus `params`: an object whose `null`/`undefined` values are dropped and whose arrays become repeated keys (`{ ids: [1, 2] }` → `ids=1&ids=2`), or a `URLSearchParams`. Other object values (a `Date`, a nested object) are rejected rather than sent as `[object Object]`. Put ids into the path with `path`, not string interpolation: `api.get("/users/{id}", { path: { id } })` encodes each value into its own segment, so an id like `../admin` or `x?role=admin` cannot change which endpoint you call (`encodeURIComponent` alone lets `..` through). A `null` (or `undefined`) header value drops a client default for that call: `api.get("/public", { headers: { authorization: null } })`.
71
-
72
- `query` sends the [RFC 10008](https://www.rfc-editor.org/rfc/rfc10008) QUERY method: safe and idempotent like GET, so `retry()` repeats it by default, but the query travels in the body. Any `fetch` sends it; the server has to understand it (Node's own `http` server does from v20).
109
+ **Validating with a schema.** `.json(schema)` takes any [Standard Schema](https://standardschema.dev) — Zod, Valibot, ArkType and others, passed as is — validates the body with it, and resolves with the schema's output (transforms included). Its type is `ArthronSchema`: `StandardSchemaV1` from `@standard-schema/spec` under arthron's name, with `ArthronSchema.Infer<S>` for the output type and `ArthronSchema.Issue` for one problem. A body that does not match rejects with a `SchemaMismatchError` whose `issues` list each problem and its path. It is not an `ArthronError`: the request worked, the data did not. arthron stays dependency-free; the schema library is yours.
73
110
 
74
- ### Stream responses
111
+ **Streams.**
75
112
 
76
113
  ```ts
77
114
  // Server-sent events — one event per `for await` step
78
- for await (const { event, data } of api.post("/v1/chat/completions", body).sse()) {
115
+ for await (const { type, data } of api.post("/v1/chat/completions", body).sse()) {
79
116
  if (data === "[DONE]") break; // provider sentinel; arthron does not interpret it
80
117
  process.stdout.write(JSON.parse(data).choices[0].delta.content ?? "");
81
118
  }
@@ -84,11 +121,11 @@ for await (const { event, data } of api.post("/v1/chat/completions", body).sse()
84
121
  for await (const row of api.get("/logs").ndjson<LogRow>()) console.log(row);
85
122
  ```
86
123
 
87
- `.sse()` parses `text/event-stream` the way the EventSource spec does: multi-line `data:` joined with `\n`, comments ignored, dispatch on a blank line, an event the stream ends inside dropped, `lastEventId` carried across events, `retry` accepted as digits only and carried on. It does not reconnect — to resume, send `headers: { "last-event-id": e.lastEventId }` on a new request. Both helpers honour `signal` and `timeout` while the body is being read, and leaving the loop early (`break`, `return`, a throw) closes the connection.
124
+ `.sse()` parses `text/event-stream` the way the EventSource spec does: multi-line `data:` joined with `\n`, comments ignored, dispatch on a blank line, an event the stream ends inside dropped, `lastEventId` carried across events, `retry:` accepted as digits only and carried on as `reconnectionTime` (ms). Each event has the names a browser `MessageEvent` uses: `type` (the `event:` field, `"message"` when there is none), `data`, `lastEventId`, plus `id`. It does not reconnect — to resume, send `headers: { "last-event-id": e.lastEventId }` on a new request. Both helpers honour `signal` and `timeout` while the body is being read, and leaving the loop early (`break`, `return`, a throw) closes the connection.
88
125
 
89
- A stream is untrusted input, so both helpers have limits: a line longer than `maxLineLength` (default 8 Mi characters), or an SSE event whose `data` outgrows `maxEventSize` (default 8 Mi), rejects the loop with a `RangeError` and closes the connection. Raise them per call if you expect bigger payloads: `.sse({ maxEventSize: 32 * 1024 * 1024 })`.
126
+ A stream is untrusted input, so both helpers have limits: a line longer than `maxLineLength` (default 8 Mi characters), or an SSE event whose `data` outgrows `maxDataLength` (default 8 Mi characters), rejects the loop with a `RangeError` and closes the connection. Raise them per call if you expect bigger payloads: `.sse({ maxDataLength: 32 * 1024 * 1024 })`.
90
127
 
91
- ### Handle errors
128
+ ### Errors
92
129
 
93
130
  ```ts
94
131
  import { isArthronError } from "arthron";
@@ -98,139 +135,202 @@ try {
98
135
  } catch (e) {
99
136
  if (isArthronError(e)) {
100
137
  e.status; // 404, or 0 when the network failed
101
- e.data; // parsed body: JSON, else text, else undefined
102
- e.detail; // the server's explanation (`message` → `error` → `error.message` → `detail`), or undefined — show this to users
138
+ e.payload; // the failed response's body, parsed: JSON, else text; undefined for a network failure
139
+ e.description; // the server's description of the failure, or undefined — for people
103
140
  e.message; // arthron's own: "GET https://api.example.com/users/404 failed with 404 Not Found" — for logs
104
141
  e.response; // the failed Response, still readable (up to 64 KiB of its body)
105
- e.request; // what was sent (url, init) — read it directly; it is left out of logs
142
+ e.requestContext; // the RequestContext that was sent — url, init (method, headers), meta; read it directly, it is left out of logs
106
143
  }
107
144
  }
108
145
  ```
109
146
 
110
- `isArthronError(e)` and `e instanceof ArthronError` work across copies of the package — the ESM and CJS builds loaded side by side, a CJS dependency's client caught by your ESM code. `request`, `response` and `headers` are not enumerable, and `toJSON()` returns only name, message, detail, status, method, a URL without query or userinfo, and `data`. So `JSON.stringify(e)`, `console.error(e)` and error trackers never print your tokens.
147
+ **`message` is for logs, `description` is for people.** `message` is written by arthron — method, URL without query, status — so a log line or an error tracker says which request failed, and never carries text the server chose. `description` is the server's account of the failure: by default the payload's `message`, else `error` (a string), else `error.message`, else `detail`. It is the right thing for a toast (`e.description ?? "Something went wrong"`), but untrusted — render it as text, never as HTML, and filter it if your gateway leaks internal routes in it. For an API that puts it elsewhere, pass `extractDescription: (payload) => (payload as { msg?: string }).msg` to the client; one that throws on an unexpected body leaves `description` undefined and the error intact.
148
+
149
+ **Nothing leaks through an error.** `requestContext`, `response` and `headers` (the response's) are not enumerable, and `toJSON()` returns only name, message, description, status, statusText, method, a URL without query or userinfo, and `payload`. So `JSON.stringify(e)`, `console.error(e)` and error trackers never print your tokens. Error bodies are read up to 64 KiB — enough for any error message, and a huge or endless error body cannot fill memory; past the limit `payload` is the raw text and the rest of the body is cancelled. `isArthronError(e)` and `e instanceof ArthronError` work across copies of the package — the ESM and CJS builds loaded side by side, a CJS dependency's client caught by your ESM code.
150
+
151
+ **Not every non-ok response is a failure.** An opaque response (`mode: "no-cors"`) and the 3xx of `redirect: "manual"` come back as a `Response`, since you asked for them. `throwOn` picks which other non-ok responses reject, for the client or one call: `api.get("/maybe", { throwOn: (response) => response.status !== 404 })` returns the 404 to inspect. An ok response never rejects.
152
+
153
+ **Cancellation is not an `ArthronError`.** A request that is aborted or times out rejects with the abort reason — `AbortError` / `TimeoutError`, or whatever you passed to `abort(reason)`. So does a schema mismatch (`SchemaMismatchError`, above) and anything a plugin throws.
154
+
155
+ ### Clients
156
+
157
+ ```ts
158
+ const api = arthron({
159
+ baseURL: "https://api.example.com", // prefix for relative paths
160
+ headers: { accept: "application/json" }, // defaults; per-call headers win
161
+ credentials: "include", // only if you need cookies cross-origin
162
+ cache: "no-store", // any fetch option works as a default; per-call values win
163
+ plugins: [retry()], // plugins, outermost first
164
+ });
165
+ ```
166
+
167
+ Every field is optional. **Fetch defaults are flat and only sent when set:** the client takes the same fetch options a call does (`credentials`, `cache`, `redirect`, …) as defaults; a per-call value wins and `extend` overrides. Nothing is sent that you did not set — cross-origin cookies need `credentials: "include"`. `signal`, `body` and `method` belong to one call: a client-wide signal would fail every call once aborted, so passing them to the client throws.
168
+
169
+ #### Where requests and headers go
170
+
171
+ | The client has | Absolute URLs may go to | Its `headers` go to |
172
+ |---|---|---|
173
+ | `baseURL` | `baseURL`'s origin, and any in `allowedOrigins` | `baseURL`'s origin only — calls to a listed origin carry per-call headers alone |
174
+ | `allowedOrigins`, no `baseURL` | the listed origins only | nowhere: client headers are a `TypeError` here |
175
+ | neither | any `http(s)` URL | every origin — keep credentials out of them |
111
176
 
112
- `message` and `detail` are kept apart on purpose. `message` is written by arthron — method, URL without query, status — so a log line or an error tracker says which request failed, and never carries text the server chose. `detail` is what the server said: the right thing for a toast (`e.detail ?? "Something went wrong"`), but untrusted — render it as text, never as HTML, and filter it if your gateway leaks internal routes in it.
177
+ `api.get("https://other.example/x")` on a client with a `baseURL` rejects with a `TypeError`, because a path built from user input or an API response must not carry your credentials elsewhere. Same-origin absolute URLs work. `allowedOrigins: ["https://cdn.example.com"]` lists more; only the origin of each URL counts. Only `http(s)` URLs are accepted.
113
178
 
114
- Not every non-ok response is a failure. An opaque response (`mode: "no-cors"`) and the 3xx of `redirect: "manual"` come back as a `Response`, since you asked for them. `throwOn` picks which other non-ok responses reject, for the client or one call: `api.get("/maybe", { throwOn: (res) => res.status !== 404 })` returns the 404 to inspect. An ok response never rejects.
179
+ **Redirects follow fetch's rules.** On a redirect to another origin, fetch drops `Authorization` but keeps other headers such as `x-api-key`. Pass `redirect: "error"` (or `"manual"`) for calls that carry such keys if the server could redirect off-origin.
115
180
 
116
- `.json(schema)` takes any [Standard Schema](https://standardschema.dev) — Zod, Valibot, ArkType and others — validates the body with it, and resolves with the schema's output (transforms included). A body that does not match rejects with a `SchemaError` whose `issues` list each problem and its path. It is not an `ArthronError`: the request worked, the data did not. arthron stays dependency-free; the schema library is yours.
181
+ **`fetchFn`** is the function requests are sent with: a fake in tests, a polyfill, a framework's `fetch`. By default it is the global `fetch`, read per request, so a global patched later (msw) is still used. A `fetchFn` you pass is the judge of its own URLs: arthron does not pre-check them, so one that accepts relative URLs (SvelteKit's server-side `fetch`) works with `baseURL: "/api"`.
117
182
 
118
- A request that is aborted or times out rejects with the abort reason — `AbortError` / `TimeoutError`, or whatever you passed to `abort(reason)` — not an `ArthronError`.
183
+ **Options are checked when the client is created.** An http(s) `baseURL` with no host (`"https://"` from an unset variable), client `headers` with `allowedOrigins` but no `baseURL`, an `allowedOrigins` entry that is not an http(s) URL, a `fetchFn` that is not a function, a 1.x option name — `arthron()` and `extend()` throw a `TypeError` right there, before any request.
119
184
 
120
- ### Derive clients
185
+ #### Derive clients
121
186
 
122
187
  Clients are immutable. `use`, `extend` and `without` return a new client and leave the original alone — so keep what they return. `api.use(plugin)` on its own line does nothing.
123
188
 
124
189
  ```ts
125
- const base = arthron({ baseURL: API, use: [logging(), retry()] });
126
- const authed = base.use(bearerAuth({ origin: API, token })); // base + auth
190
+ const base = arthron({ baseURL: API, plugins: [logging(), retry()] });
191
+ const authed = base.use(bearerAuth({ tokenOrigins: [API], accessTokenFn: () => token })); // base + auth
127
192
  const admin = authed.extend({ headers: { "x-role": "admin" } }); // authed + header
128
193
  ```
129
194
 
130
- `extend` shallow-merges the config, merges headers case-insensitively (child wins), and adds plugins the way `use` does: a plugin whose `name` is already there replaces it in place — so `streaming = base.use(timeout(120_000))` loosens the inherited `timeout`, and a second `retry()` does not multiply attempts — while unnamed plugins append. `without("retry")` returns a client without that plugin. The built-ins are named `bearerAuth`, `logging`, `retry`, `timeout` and `uploadProgress`.
195
+ `extend` shallow-merges the options, merges headers case-insensitively (child wins), and adds plugins the way `use` does: a plugin whose `pluginName` is already there replaces it in place — so `streaming = base.use(timeout(120_000))` loosens the inherited `timeout`, and a second `retry()` does not multiply attempts — while other names append. `without("retry")` returns a client without that plugin. The built-ins are named `bearerAuth`, `logging`, `retry`, `timeout` and `uploadProgress`.
196
+
197
+ ### In browsers
198
+
199
+ CORS and the engine have the last word. CI runs the built package in Chromium, Firefox and WebKit against a server on another origin.
200
+
201
+ - A JSON body, a custom header and `query()`'s QUERY method each make the browser send a preflight, so the API must allow them (`Access-Control-Allow-Methods: QUERY`, `Access-Control-Allow-Headers`).
202
+ - `retry()` reads `Retry-After` only if the API exposes it (`Access-Control-Expose-Headers: retry-after`); otherwise it waits its own delay.
203
+ - `redirect: "manual"` gives an opaque redirect (`status` 0, `type` `"opaqueredirect"`), returned rather than thrown.
204
+ - A `ReadableStream` body streams only over HTTP/2 (Chromium, WebKit), and the `ArthronError` for one sent to an HTTP/1.1 server says so. Where the browser cannot stream a request body at all (Firefox would send the text `"[object ReadableStream]"`), the call rejects with a `TypeError` before anything is sent. Either way, send a `Blob` or `ArrayBuffer` there.
205
+
206
+ ## How a call flows
207
+
208
+ ```text
209
+ api.get(path, requestOptions)
210
+ │ fill pathParams · join baseURL + path + queryParams
211
+ │ check the origin (baseURL / allowedOrigins) · merge headers · serialize the body
212
+ ▼
213
+ middleware[0] → middleware[1] → … once per call, outermost first
214
+ │ next(requestContext) a middleware may call it again (retry)
215
+ ▼
216
+ attempt ─ request interceptors → fetchFn(url, init)
217
+ ├─ ok, or let through by throwOn → response interceptors → Response
218
+ ├─ !ok, or the network failed → ArthronError → error interceptors → thrown
219
+ └─ cancelled, or a plugin threw → passed through untouched
220
+ ▲
221
+ middleware resumes after `await next(…)`, last one first
222
+ ▼
223
+ ResponsePromise ─ await → Response · .json() .text() .sse() .ndjson()
224
+ ```
225
+
226
+ 1. **Before the plugins.** The URL is built, its origin checked, the client's and the call's headers merged and the body serialized. A mistake here — no `baseURL`, a body JSON would mangle, an origin the client may not call — is a rejected `TypeError`, and no plugin runs.
227
+ 2. **Middleware** wraps the whole call, outermost first. It is called once; `await next(requestContext)` runs everything below it and resumes with the result, or the error to `catch`. A middleware may call `next` again — that is a retry — or not at all.
228
+ 3. **An attempt** is one trip to the network: request interceptors, then `fetchFn`, then response or error interceptors. A retry runs the whole attempt again, so a refreshed token or a new trace id is picked up without extra code, and each attempt starts from a fresh copy of the context.
229
+ 4. **Where errors come from.** Only a non-ok response (unless `throwOn` lets it through) and a failed `fetch` become an `ArthronError`, and only those reach error interceptors. Cancellation and exceptions thrown by plugins pass through untouched.
230
+ 5. **What you get back.** The middleware unwinds, last one first, and the call resolves with the `Response` — untouched unless a response interceptor returned a new one. The body helpers sit on top of that promise.
131
231
 
132
232
  ## Plugins
133
233
 
134
- A plugin is an object with any of four slots:
234
+ A plugin is an object with a required `pluginName` and at least one of four slots:
135
235
 
136
236
  ```ts
137
237
  type Plugin = {
138
- name?: string; // identity: use/extend replace the plugin of the same name, without(name) removes it
139
- middleware?: (ctx: RequestContext, next: (ctx: RequestContext) => Promise<Response>) => Promise<Response>;
140
- request?: (ctx: RequestContext) => RequestContext | Promise<RequestContext>;
141
- response?: (res: Response, ctx: RequestContext) => Response | Promise<Response>;
142
- error?: (err: ArthronError, ctx: RequestContext) => ArthronError | Promise<ArthronError>;
238
+ pluginName: string; // required — identity: use/extend replace the plugin of the same pluginName, without(pluginName) removes it. Not `name`: a function has one
239
+ middleware?: (requestContext: RequestContext, next: (requestContext: RequestContext) => Promise<Response>) => Promise<Response>;
240
+ requestInterceptor?: (requestContext: RequestContext) => RequestContext | Promise<RequestContext>;
241
+ responseInterceptor?: (response: Response, requestContext: RequestContext) => Response | Promise<Response>;
242
+ errorInterceptor?: (error: ArthronError, requestContext: RequestContext) => ArthronError | Promise<ArthronError>;
143
243
  };
144
244
 
145
245
  type RequestContext = {
146
246
  url: string;
147
247
  init: Omit<RequestInit, "headers"> & { headers: Record<string, string> }; // lowercase names
148
248
  meta: ArthronMeta; // per-call plugin settings, never sent
149
- fetch?: typeof fetch; // the fetch this attempt is sent with — replace it to change the transport, wrap it to observe
249
+ fetchFn?: typeof fetch; // the function this attempt is sent with (the client's fetchFn, else the global fetch) — replace it to send another way, wrap it to observe
150
250
  };
151
251
  ```
152
252
 
153
- How one request runs, with `use: [m1, m2]`:
154
-
155
- ```
156
- call → m1 (before next) → m2 (before next) ─┐
157
- │ attempt — runs again each time a middleware calls next again
158
- │ request interceptors → fetch → response interceptors
159
- │ ↘ error interceptors → throw
160
- call ← m1 (after next) ← m2 (after next) ←─┘
161
- ```
162
-
163
- A middleware is called once. `await next(ctx)` runs everything inside it and resumes the same call with the result — or the error, to `catch`. Request interceptors are not the outermost layer, as axios users may expect: they run inside every middleware, on every attempt. That is why a retry sends a refreshed token or a new trace id without extra code, and why a `timeout` also bounds a slow interceptor.
164
-
165
253
  | | Middleware | Interceptor |
166
254
  |---|---|---|
167
255
  | Runs | Once per call, wrapping everything below | Once per attempt |
168
- | Gets | `ctx` and `next` | A value: context, `Response`, or `ArthronError` |
256
+ | Gets | `requestContext` and `next` | A value: context, `Response`, or `ArthronError` |
169
257
  | Can | Call `next` again (retry), skip it, add a deadline | Return a changed value |
170
258
  | Cannot | — | Retry, skip, or stop the error from being thrown |
171
- | Examples | `retry`, `timeout`, `logging`, auth refresh, error pages | auth header, tracing id, per-attempt error details |
259
+ | Examples | `retry`, `timeout`, `logging`, auth refresh, error pages | auth header, tracing id, enriching one attempt's error |
172
260
 
173
- Every attempt starts from a fresh copy of the context, so a header set by an interceptor on attempt one does not leak into attempt two. `ctx.init.headers` is always a plain record with lowercased names, restored to that shape after every request interceptor, so `{ ...ctx.init.headers, name: value }` is safe.
261
+ Request interceptors are not the outermost layer, as axios users may expect: they run inside every middleware, on every attempt — which is also why a `timeout` bounds a slow interceptor. `requestContext.init.headers` is always a plain record with lowercased names, restored to that shape after every request interceptor, so `{ ...requestContext.init.headers, name: value }` is safe. Response bodies are single-use: a response interceptor that wants to inspect the body must `response.clone()`; one that changes it returns a new `Response`.
174
262
 
175
263
  ### Built in
176
264
 
177
- The built-in plugins come from `arthron/plugins`: `import { bearerAuth, logging, retry, timeout, uploadProgress } from "arthron/plugins"`. They are plain plugins — anything they do, yours can. `logging`, `retry` and `timeout` are also exported from `arthron` itself, as they were in 1.0; those exports are deprecated and go in the next major.
265
+ The built-in plugins come from `arthron/plugins`: `import { bearerAuth, logging, retry, timeout, uploadProgress } from "arthron/plugins"`. So do their types: `BearerAuthOptions`, `LoggingOptions`, `LogEvent`, `RetryOptions`, `TimeoutOptions`, `UploadProgressEvent`. They are plain plugins — anything they do, yours can.
178
266
 
179
- | plugin | slots | per call |
180
- |---|---|---|
181
- | `bearerAuth` | request interceptor (the header) + middleware (refresh on 401) | `meta: { bearerAuth: false }` |
182
- | `retry` | middleware | `meta: { retry: false \| options }` |
183
- | `timeout` | middleware | `meta: { timeout: ms }` |
184
- | `logging` | middleware | — |
185
- | `uploadProgress` | request interceptor (picks the transport) | `meta: { onUploadProgress }` — only calls that pass it |
267
+ | plugin | what it does | slots | per call |
268
+ |---|---|---|---|
269
+ | [`retry`](#retryoptions) | repeats a failed attempt | middleware | `meta: { retry: false \| options }` |
270
+ | [`timeout`](#timeoutms--timeout-total-idle-) | aborts a slow or silent call | middleware | `meta: { timeout: false \| ms \| { total?, idle? } }` |
271
+ | [`logging`](#loggingoptions) | one line per call start and end | middleware | — |
272
+ | [`bearerAuth`](#bearerauthoptions) | sends a token, refreshes on 401 | request interceptor (the header) + middleware (the refresh) | `meta: { bearerAuth: false }` |
273
+ | [`uploadProgress`](#uploadprogress) | reports upload progress | request interceptor (swaps `requestContext.fetchFn`) | `meta: { uploadProgress: { onProgress } }` — only calls that pass it |
186
274
 
187
275
  Order is meaning: the first plugin is outermost. A good default:
188
276
 
189
277
  ```ts
190
- use: [logging(), bearerAuth(…), timeout(10_000), retry()]
278
+ plugins: [logging(), bearerAuth(…), timeout(10_000), retry()]
191
279
  ```
192
280
 
193
281
  `logging` first logs each call once — its start, and its end with the total time, retries included. A `timeout` before `retry` bounds the whole call; after it, each attempt — and a timed-out attempt is not retried. `bearerAuth` sets its header on every attempt wherever it sits. Anything that should see only the final outcome — an error page, mapping to domain errors — goes before `retry`.
194
282
 
195
- Plugins with the same name replace each other, so a second `timeout()` takes the first one's place instead of adding a deadline. For both a total and a per-attempt deadline, rename one:
283
+ Plugins with the same `pluginName` replace each other, so a second `timeout()` takes the first one's place instead of adding a deadline. For both a total and a per-attempt deadline, rename one:
196
284
 
197
285
  ```ts
198
- use: [{ ...timeout(30_000), name: "callTimeout" }, retry(), timeout(10_000)] // 30 s in all, 10 s per attempt
286
+ plugins: [{ ...timeout(30_000), pluginName: "callTimeout" }, retry(), timeout(10_000)] // 30 s in all, 10 s per attempt
199
287
  ```
200
288
 
201
289
  #### `retry(options)`
202
290
 
203
- Re-runs the attempt on network failure or a retryable status. Because it re-runs the whole attempt, request interceptors run again — a refreshed token or a new trace id is picked up automatically.
291
+ Re-runs the attempt on a network failure or a retryable status. Because it re-runs the whole attempt, request interceptors run again — a refreshed token or a new trace id is picked up automatically.
292
+
293
+ ```ts
294
+ arthron({ baseURL: API, plugins: [retry({ retries: 3, statuses: [429, 503] })] });
295
+ ```
204
296
 
205
297
  | option | default | |
206
298
  |---|---|---|
207
299
  | `retries` | `2` | retries after the first attempt (3 requests total); a whole number ≥ 0 |
208
- | `retryOn` | `[408, 429, 500, 502, 503, 504]` | network failures (`status: 0`) are always retried |
300
+ | `statuses` | `[408, 429, 500, 502, 503, 504]` | network failures (`status: 0`) are always retried |
209
301
  | `methods` | `GET HEAD OPTIONS QUERY PUT DELETE` | case-insensitive |
210
- | `delay` | `(retry) => Math.random() * 300 * 2 ** retry` | ms; `retry` counts from 0. Full jitter, so clients that failed together don't retry together |
302
+ | `delayFn` | `(attempt) => Math.random() * 300 * 2 ** attempt` | the wait in ms; `attempt` counts from 0 (0 after the first request failed), here as in `shouldRetry` and `onRetry`. Full jitter, so clients that failed together don't retry together. Must return a finite number ≥ 0, else the call rejects with a `TypeError` |
211
303
  | `maxRetryAfter` | `60_000` | a `Retry-After` longer than this means "don't retry" |
212
- | `shouldRetry` | — | `(error, attempt, ctx) => boolean \| undefined`: `true` retries, `false` stops, `undefined` uses the rules above. Sees every error, an inner `TimeoutError` included |
213
- | `onRetry` | — | `(error, attempt, waitMs) => void`, before each wait |
304
+ | `shouldRetry` | — | `(error, attempt, requestContext) => boolean \| undefined`: `true` retries, `false` stops, `undefined` uses the rules above. Sees every error, an inner `TimeoutError` included |
305
+ | `onRetry` | — | `(error, attempt, delay) => void`, before each wait of `delay` ms |
214
306
 
215
- Honours `Retry-After` (seconds or HTTP date). Never retries a `ReadableStream` body or an aborted request, and never more than `retries` times — `shouldRetry` cannot lift those.
307
+ **Per call:** `meta: { retry: false }` turns it off, and `meta: { retry: { retries: 5 } }` overrides any option.
216
308
 
217
- Opting a non-idempotent method in (`methods: ["POST"]`) means a network failure is retried too, and a dropped connection may already have reached the server. Where a duplicate would hurt — a payment, an LLM completion you pay for — keep retries to statuses: `shouldRetry: (e) => (isArthronError(e) && e.status === 0 ? false : undefined)`. Per call, `meta: { retry: false }` turns it off and `meta: { retry: { retries: 5 } }` overrides any option but `sleep`.
309
+ Honours `Retry-After` (seconds or HTTP date). Never retries a `ReadableStream` body or an aborted request, never more than `retries` times, and never past a `Retry-After` longer than `maxRetryAfter` — `shouldRetry` cannot lift those. Opting a non-idempotent method in (`methods: ["POST"]`) means a network failure is retried too, and a dropped connection may already have reached the server. Where a duplicate would hurt — a payment, an LLM completion you pay for — keep retries to statuses: `shouldRetry: (error) => (isArthronError(error) && error.status === 0 ? false : undefined)`.
218
310
 
219
311
  #### `timeout(ms)` / `timeout({ total?, idle? })`
220
312
 
221
- Aborts the attempt after `ms`, counted until the body has been fully read — a server that sends headers and then stalls still trips it. Rejects with a `TimeoutError`, which `retry` and error interceptors ignore. Combines with a `signal` you pass yourself. `Infinity` means no deadline. The timer never keeps a Node process alive on its own. Per call, `meta: { timeout: 120_000 }` replaces the deadline (`Infinity` lifts it).
222
-
223
- A total deadline also cuts off a stream that is still flowing. For streams, bound the silence instead: `timeout({ idle: 30_000 })` aborts only after 30 s without data — waiting for headers or between chunks — however long the stream runs. Both can be combined: `timeout({ total: 600_000, idle: 30_000 })`; `timeout(ms)` is `timeout({ total: ms })`. Options with neither are a `TypeError`.
224
-
225
- `timeout(ms, { idle })` still works but is deprecated; it goes in the next major.
313
+ Aborts a call that takes too long, or goes quiet for too long. Rejects with a `TimeoutError`, which `retry` and error interceptors ignore.
226
314
 
227
315
  ```ts
228
- arthron({ use: [retry(), timeout(5_000)] }); // 5 s per attempt — a timed-out attempt is not retried
229
- arthron({ use: [timeout(5_000), retry()] }); // 5 s for all attempts together
316
+ arthron({ plugins: [retry(), timeout(5_000)] }); // 5 s per attempt — a timed-out attempt is not retried
317
+ arthron({ plugins: [timeout(5_000), retry()] }); // 5 s for all attempts together
318
+ arthron({ plugins: [timeout({ idle: 30_000 })] }); // for streams: 30 s without data
230
319
  ```
231
320
 
321
+ | option | default | |
322
+ |---|---|---|
323
+ | `total` | none | ms from the start of the attempt until the body has been fully read — a server that sends headers and then stalls still trips it. `timeout(ms)` is `timeout({ total: ms })` |
324
+ | `idle` | none | ms without data — while waiting for the headers and between chunks. A stream that keeps flowing is never cut off |
325
+
326
+ **Per call:** `meta: { timeout: false }` lifts the deadlines, `meta: { timeout: 120_000 }` replaces the total one, and `meta: { timeout: { idle: 60_000 } }` replaces the parts it sets — the same shape as `meta.retry`.
327
+
328
+ A total deadline also cuts off a stream that is still flowing; for streams, bound the silence with `idle` instead, or combine both: `timeout({ total: 600_000, idle: 30_000 })`, whichever trips first. Options with neither are a `TypeError`. `Infinity` means no deadline. It combines with a `signal` you pass yourself, and its timer never keeps a Node process alive on its own.
329
+
232
330
  #### `logging(options)`
233
331
 
332
+ One line when a call starts and one when it ends, with the status and the total time, retries included.
333
+
234
334
  ```
235
335
  [arthron] → GET https://api.example.com/users
236
336
  [arthron] ← GET https://api.example.com/users 200 42ms
@@ -241,9 +341,9 @@ arthron({ use: [timeout(5_000), retry()] }); // 5 s for all attempts together
241
341
  |---|---|---|
242
342
  | `enabled` | `true` | pass `process.env.NODE_ENV !== "production"` or similar — arthron never reads env |
243
343
  | `logger` | `console` | anything with `log` and `error` |
244
- | `onEvent` | — | receive `{ phase, method, path, status, ms, error }` instead of console lines |
344
+ | `onEvent` | — | receive `{ phase, method, url, status, duration, error }` instead of console lines: `url` without query or userinfo, `duration` in ms |
245
345
 
246
- Query strings and `user:pass@` are never printed. Cancellations are not logged.
346
+ **Per call:** —. Query strings and `user:pass@` are never printed. Cancellations are not logged.
247
347
 
248
348
  #### `bearerAuth(options)`
249
349
 
@@ -252,13 +352,15 @@ Bearer tokens, with a refresh on 401.
252
352
  ```ts
253
353
  const api = arthron({
254
354
  baseURL: API,
255
- use: [
355
+ plugins: [
256
356
  bearerAuth({
257
- origin: API,
258
- token: () => session.accessToken,
259
- refresh: async () => {
260
- const res = await api.post("/token/refresh", undefined, { meta: { bearerAuth: false } }).json<{ access_token: string }>();
261
- session.accessToken = res.access_token;
357
+ tokenOrigins: [API],
358
+ accessTokenFn: () => session.accessToken,
359
+ refreshFn: async () => {
360
+ const tokens = await api
361
+ .post("/token/refresh", undefined, { meta: { bearerAuth: false } })
362
+ .json<{ access_token: string }>();
363
+ session.accessToken = tokens.access_token;
262
364
  },
263
365
  }),
264
366
  ],
@@ -267,58 +369,73 @@ const api = arthron({
267
369
 
268
370
  | option | default | |
269
371
  |---|---|---|
270
- | `origin` | required | a URL or several; only their origins count, so `baseURL` works as is. Anything that is not an http(s) URL is a `TypeError` |
271
- | `token` | required | `() => string \| null \| undefined`, or a promise of one; read on every attempt. No token, no header |
272
- | `refresh` | — | `() => Promise<void>`, called after a 401; then the call is redone once. Without it, a 401 is thrown as is |
372
+ | `tokenOrigins` | required | a list of URLs; only their origins count, so `[baseURL]` works as is — a relative one (`"/api"`) is on the page's origin. An entry that is not an http(s) URL, an empty list or one string instead of a list is a `TypeError` |
373
+ | `accessTokenFn` | required | `() => string \| null \| undefined`, or a promise of one; read on every attempt. No token, no header |
374
+ | `refreshFn` | — | `() => Promise<void>`, called after a 401 to refresh the session, so the next `accessTokenFn()` returns the new token; then the call is redone once. Without it, a 401 is thrown as is |
273
375
 
274
- The token goes to `origin` only — never to a CDN or a URL taken from a response body. Concurrent 401s share one refresh: with refresh-token rotation, parallel refreshes would revoke each other and log the user out. A call whose token was already replaced by another call's refresh retries without refreshing again. A second 401 is thrown, and so is a failed refresh — every call waiting on it rejects with the error `refresh` threw, so that is the place to send the user to a login page. The 401 of a `ReadableStream` body is thrown without a refresh: the first try has read the stream, so there is nothing to resend.
376
+ **Per call:** `meta: { bearerAuth: false }` sends no token and never refreshes. Use it for the login and refresh endpoints. A request `refreshFn` starts through the same client before its first `await` is sent that way on its own; one it starts after an `await` needs the meta, or its 401 would wait for the refresh it is part of.
275
377
 
276
- Per call, `meta: { bearerAuth: false }` sends no token and never refreshes. Use it for the login and refresh endpoints: a refresh request that went through `bearerAuth()` would, on its own 401, wait for itself.
378
+ The token goes to `tokenOrigins` only — never to a CDN or a URL taken from a response body. Concurrent 401s share one refresh: with refresh-token rotation, parallel refreshes would revoke each other and log the user out. A call whose token was already replaced by another call's refresh retries without refreshing again. A second 401 is thrown, and so is a failed refresh — every call waiting on it rejects with the error `refreshFn` threw, so that is the place to send the user to a login page. The 401 of a `ReadableStream` body is thrown without a refresh: the first try has read the stream, so there is nothing to resend.
277
379
 
278
- The token is treated as opaque: to refresh a JWT ahead of its `exp`, do it inside `token()`. It is not for cookie sessions: it tells whether another call already refreshed by comparing the `authorization` header a call sent with the current one, and an httpOnly cookie cannot be read to compare.
380
+ The token is treated as opaque: to refresh a JWT ahead of its `exp`, do it inside `accessTokenFn`. It is not for cookie sessions: it tells whether another call already refreshed by comparing the `authorization` header a call sent with the current one, and an httpOnly cookie cannot be read to compare.
279
381
 
280
382
  #### `uploadProgress()`
281
383
 
282
- Does nothing by itself — only a call that passes `meta.onUploadProgress` reports progress. Every other call, on the same client too, goes out exactly as before.
384
+ Reports how much of a call's body has been sent. It does nothing by itself — only a call that passes `meta.uploadProgress` reports progress, and every other call, on the same client too, goes out exactly as before.
283
385
 
284
386
  ```ts
285
- const api = arthron({ baseURL: API, use: [retry(), uploadProgress()] }); // last in use
387
+ const api = arthron({ baseURL: API, plugins: [retry(), uploadProgress()] }); // last in plugins
286
388
 
287
389
  await api.get("/me"); // as usual
288
390
  await api.post("/files", file, {
289
- meta: { onUploadProgress: ({ loaded, total, progress }) => setBar(progress) }, // this call only
391
+ meta: { uploadProgress: { onProgress: ({ fraction }) => setBar(fraction) } }, // this call only
290
392
  });
291
393
  ```
292
394
 
293
- The event: `loaded` (bytes sent), `total` (bytes in the body, `undefined` when unknown — a stream without `content-length`), `progress` (`loaded / total` from 0 to 1, `undefined` with `total`). Progress counts what was handed to the network, so it reaches 1 a moment before the server has it all; the response arriving is the end. Each attempt counts from 0 again, so a retried upload starts the bar over.
395
+ | event field | |
396
+ |---|---|
397
+ | `loaded` | bytes sent |
398
+ | `total` | bytes in the body; `undefined` when unknown — a stream without `content-length` |
399
+ | `fraction` | `loaded / total`, from 0 to 1 — not a percentage; `undefined` with `total` |
400
+
401
+ **Per call:** `meta: { uploadProgress: { onProgress } }`, only on calls that want progress. Progress counts what was handed to the network, so it reaches 1 a moment before the server has it all; the response arriving is the end. Each attempt counts from 0 again, so a retried upload starts the bar over.
294
402
 
295
403
  How that call is sent depends on where it runs:
296
404
 
297
- - **Browser:** over `XMLHttpRequest`, the only browser API that reports upload progress. On another origin, the upload listener makes every such call send a CORS preflight — an S3 bucket's CORS rules must allow the method and headers. The response arrives whole: `.sse()` / `.ndjson()` are not live on that call. What XHR cannot honour and would change the call is refused with a `TypeError` before sending: `redirect: "manual" | "error"`, `mode: "no-cors"`, and `credentials: "omit"` to the page's own origin (XHR always sends its cookies there). What only tunes fetch — `cache`, `priority`, `referrerPolicy`, `integrity`, `keepalive` — is ignored on that call. A `ReadableStream` body is sent as usual, without progress.
405
+ - **Browser:** over `XMLHttpRequest`, the only browser API that reports upload progress. On another origin, the upload listener makes every such call send a CORS preflight — an S3 bucket's CORS rules must allow the method and headers. The response arrives whole: `.sse()` / `.ndjson()` are not live on that call. What XHR cannot honour and would change the call is refused with a `TypeError` before sending: `redirect: "manual" | "error"`, `mode: "no-cors"`, `credentials: "omit"` to the page's own origin (XHR always sends its cookies there), a `referrerPolicy` or `referrer` of its own (XHR sends the page's referrer) and `integrity` (XHR cannot check it). What only tunes fetch — `cache`, `priority`, `keepalive` — is ignored on that call. A `ReadableStream` body is sent as usual, without progress.
298
406
  - **Node:** with `fetch` as usual, the body counted as it is read, in steps of at most 64 KiB. `content-length` and the content type a `Blob` would set are kept, so an S3 presigned `PUT` accepts it. A `FormData` body is encoded with its length known up front, its files streamed rather than read into memory. jsdom on Node counts the same way.
299
- - **Anywhere else** — Deno, Bun, Cloudflare Workers, an Electron main process, a service worker — and on a client with an injected `fetch` or another plugin's transport: sent as usual, without progress.
407
+ - **Anywhere else** — Deno, Bun, Cloudflare Workers, an Electron main process, a service worker — and on a client with its own `fetchFn`, or with another plugin's: sent as usual, without progress.
300
408
 
301
- A callback that throws fails the call with its own error, untouched: not an `ArthronError`, not retried. Put `uploadProgress()` last in `use`: a request interceptor after it that replaces `init.signal` would cut that path, and the error would come back as a network failure.
409
+ An `onProgress` that throws fails the call with its own error, untouched: not an `ArthronError`, not retried. Put `uploadProgress()` last in `plugins`: a request interceptor after it that replaces `init.signal` would cut that path, and the error would come back as a network failure.
302
410
 
303
411
  ### Write your own
304
412
 
305
- `definePlugin`, `defineMiddleware` and `defineInterceptor.{request,response,error}` are identity functions that give you the parameter types without annotations.
413
+ `createPlugin` takes a plugin and returns it, typed without annotations and checked where you write it: a missing `pluginName`, an unknown key (a typo), a 1.x `name`, `request`, `response` or `error`, a slot that is not a function or a plugin with nothing to run is a `TypeError` that says what to write. `use` and `plugins` run the same check. A plugin is a plain object: to keep state, close over it in a function that returns `createPlugin({ … })`, as the built-ins do. For a piece written on its own, `createMiddleware`, `createRequestInterceptor`, `createResponseInterceptor` and `createErrorInterceptor` type it the same way — `create` plus the type's name.
306
414
 
307
- **`return await next(ctx)` inside `try`.** Without `await`, the promise is returned before it settles, and the `catch` never sees its error.
415
+ **`return await next(requestContext)` inside `try`.** Without `await`, the promise is returned before it settles, and the `catch` never sees its error.
308
416
 
309
417
  **Per attempt or per call.** A request interceptor runs on every attempt, so it fits a value that must be fresh each time: a token, a signature with a timestamp, a span id. A value that must be the same on every attempt — an idempotency key, so the server can tell a retry from a second payment — is set once, by a middleware placed before `retry()`:
310
418
 
311
419
  ```ts
312
- const idempotent = defineMiddleware((ctx, next) =>
313
- next({ ...ctx, init: { ...ctx.init, headers: { ...ctx.init.headers, "idempotency-key": crypto.randomUUID() } } }),
420
+ const idempotent = createMiddleware((requestContext, next) =>
421
+ next({
422
+ ...requestContext,
423
+ init: {
424
+ ...requestContext.init,
425
+ headers: { ...requestContext.init.headers, "idempotency-key": crypto.randomUUID() },
426
+ },
427
+ }),
314
428
  );
315
429
  // A client of its own, so the order is exactly this: the key first, then retry.
316
- const payments = arthron({ baseURL: API, use: [{ name: "idempotency", middleware: idempotent }, retry({ methods: ["POST"] })] });
430
+ const payments = arthron({
431
+ baseURL: API,
432
+ plugins: [{ pluginName: "idempotency", middleware: idempotent }, retry({ methods: ["POST"] })],
433
+ });
317
434
  ```
318
435
 
319
436
  (`api.use(…)` would not do here if `api` already has a `retry()`: the new one takes the old one's place, which may be before the key.)
320
437
 
321
- **Per-call settings.** `api.get(path, { meta })` hands `meta` to every plugin as `ctx.meta` and never sends it. Declare your own keys once and they are typed everywhere:
438
+ **Per-call settings.** `api.get(path, { meta })` hands `meta` to every plugin as `requestContext.meta` and never sends it. Declare your own keys once and they are typed everywhere:
322
439
 
323
440
  ```ts
324
441
  declare module "arthron" {
@@ -327,25 +444,44 @@ declare module "arthron" {
327
444
  }
328
445
  }
329
446
 
330
- const traced = api.use({ request: (ctx) => (ctx.meta.skipTrace ? ctx : withTraceId(ctx)) });
447
+ const traced = api.use({
448
+ pluginName: "trace",
449
+ requestInterceptor: (requestContext) =>
450
+ requestContext.meta.skipTrace ? requestContext : withTraceId(requestContext),
451
+ });
331
452
  traced.get("/health", { meta: { skipTrace: true } });
332
453
  ```
333
454
 
334
- **Refresh a session on 401, then redo the request.** That is the built-in [`bearerAuth()`](#bearerauthoptions), and a model for your own: the refresh is flow, so it is middleware; the header is a value, so it is a request interceptor — and because `next` re-runs the interceptors, the redone request carries the new token without extra code. Read [`src/plugins/bearer-auth/bearer-auth.ts`](src/plugins/bearer-auth/bearer-auth.ts) before writing a variant for another scheme.
455
+ #### Recipes
456
+
457
+ **Attach a trace id.** A request interceptor that returns a changed copy.
458
+
459
+ ```ts
460
+ const traced = api.use({
461
+ pluginName: "trace",
462
+ requestInterceptor: (requestContext) => ({
463
+ ...requestContext,
464
+ init: {
465
+ ...requestContext.init,
466
+ headers: { ...requestContext.init.headers, "x-trace-id": crypto.randomUUID() },
467
+ },
468
+ }),
469
+ });
470
+ ```
335
471
 
336
472
  **Send users to an error page.** This reacts to how the *call* ended, so it is middleware placed before `retry()`: it sees only the final result. An error interceptor would run on every failed attempt and navigate away even when the next retry succeeds. Error interceptors are for enriching the error of one attempt. Tested in [`examples/error-page.ts`](examples/error-page.ts).
337
473
 
338
474
  ```ts
339
- import { definePlugin, isArthronError } from "arthron";
475
+ import { createPlugin, isArthronError } from "arthron";
340
476
 
341
477
  export const errorRedirect = (navigate: (to: string) => void) =>
342
- definePlugin({
343
- name: "errorRedirect",
344
- middleware: async (ctx, next) => {
478
+ createPlugin({
479
+ pluginName: "errorRedirect",
480
+ middleware: async (requestContext, next) => {
345
481
  try {
346
- return await next(ctx);
482
+ return await next(requestContext);
347
483
  } catch (error) {
348
- if (isArthronError(error) && (ctx.init.method ?? "GET") === "GET") {
484
+ if (isArthronError(error) && (requestContext.init.method ?? "GET") === "GET") {
349
485
  if (error.status === 404) navigate("/not-found");
350
486
  else if (error.status >= 500) navigate("/error");
351
487
  }
@@ -354,30 +490,26 @@ export const errorRedirect = (navigate: (to: string) => void) =>
354
490
  },
355
491
  });
356
492
 
357
- const api = arthron({ baseURL: API, use: [errorRedirect(router.push), retry()] }); // first: wraps retry
493
+ const api = arthron({ baseURL: API, plugins: [errorRedirect(router.push), retry()] }); // first: wraps retry
358
494
  ```
359
495
 
360
- The same shape maps errors to your own types: `catch (e) { throw toDomainError(e); }` in an outermost middleware runs once per call, after retries.
361
-
362
- **Attach a trace id.** A one-line request interceptor.
496
+ The same shape maps errors to your own types: `catch (error) { throw toDomainError(error); }` in an outermost middleware runs once per call, after retries.
363
497
 
364
- ```ts
365
- const traced = api.use({ request: (ctx) => ({ ...ctx, init: { ...ctx.init, headers: { ...ctx.init.headers, "x-trace-id": crypto.randomUUID() } } }) });
366
- ```
498
+ **Refresh a session on 401, then redo the request.** That is the built-in [`bearerAuth()`](#bearerauthoptions), and a model for your own: the refresh is flow, so it is middleware; the header is a value, so it is a request interceptor — and because `next` re-runs the interceptors, the redone request carries the new token without extra code. Read [`src/plugins/bearer-auth/bearer-auth.ts`](src/plugins/bearer-auth/bearer-auth.ts) before writing a variant for another scheme.
367
499
 
368
500
  ## Reference
369
501
 
370
- ### `arthron(config)`
502
+ ### `arthron(clientOptions)`
371
503
 
372
504
  ```ts
373
- type ArthronConfig = {
505
+ type ArthronClientOptions = {
374
506
  baseURL?: string;
375
- allowAbsoluteUrls?: boolean; // let absolute URLs leave baseURL's origin (without config headers)
376
- headers?: HeadersOption; // anything fetch's headers take, or a record whose null drops a header (in extend)
377
- fetch?: typeof fetch; // inject for tests or a polyfill; resolved per request
378
- use?: Plugin[];
379
- errorDetail?: (data: unknown, res: Response) => string | undefined; // picks ArthronError.detail out of the body
380
- throwOn?: (res: Response) => boolean; // which non-ok responses reject (also per call)
507
+ allowedOrigins?: readonly string[]; // other origins absolute URLs may go to (only the origin of each URL counts); calls there get per-call headers only. Without baseURL it alone limits absolute URLs, and client headers are refused
508
+ headers?: HeadersInit | Record<string, string | null | undefined>; // sent to baseURL's origin only; a null value drops a header (in extend)
509
+ fetchFn?: typeof fetch; // the function requests are sent with: a fake in tests, a polyfill, a framework's fetch; default the global fetch, read per request
510
+ plugins?: readonly Plugin[]; // outermost first; one with the pluginName of an earlier one replaces it
511
+ extractDescription?: (payload: unknown, response: Response) => string | undefined; // extracts ArthronError.description from a failed response's payload; one that throws leaves it undefined
512
+ throwOn?: (response: Response) => boolean; // which non-ok responses reject (also per call)
381
513
  // …plus any fetch option as a default: credentials, cache, redirect, mode,
382
514
  // keepalive, referrerPolicy, priority, integrity. Not body, method or signal —
383
515
  // those belong to one request (passing them throws).
@@ -387,54 +519,46 @@ type ArthronConfig = {
387
519
  ### Client
388
520
 
389
521
  ```ts
390
- get(path, init) · delete(path, init) · head(path, init)
391
- post(path, body, init) · put(path, body, init) · patch(path, body, init)
392
- query(path, body, init) // RFC 10008 QUERY
393
- request(path, { method, body, ...init })
394
- use(...plugins) → new client (same-name plugins replaced in place)
395
- extend(config) → new client
396
- without(...names) → new client without those plugins
522
+ get(path, requestOptions) · delete(path, requestOptions) · head(path, requestOptions)
523
+ post(path, body, requestOptions) · put(path, body, requestOptions) · patch(path, body, requestOptions)
524
+ query(path, body, requestOptions) // RFC 10008 QUERY
525
+ request(path, { method, body, ...requestOptions })
526
+ use(...plugins) → new client (a plugin with the same pluginName is replaced in place)
527
+ extend(clientOptions) → new client
528
+ without(...pluginNames) → new client without those plugins
397
529
  ```
398
530
 
399
- All arguments after `path` are optional, except `method` in `request`. `request` is the way to any other verb; its `method` autocompletes the standard ones, and those go out uppercased whatever case you write them in (fetch itself leaves `"patch"` lowercase, which servers answer with 405). Other method names are sent as written.
400
-
401
- The public object types — `ArthronConfig`, `RequestOptions`, `RequestContext`, `ArthronMeta`, `Plugin`, `ArthronPromise`, `Arthron` — are interfaces, so a plugin package can add its own options with `declare module "arthron" { interface RequestOptions { … } }`.
531
+ All arguments after `path` are optional, except `method` in `request`. Every method returns a `ResponsePromise`: a `Promise<Response>` with `.json()`, `.text()`, `.blob()`, `.arrayBuffer()`, `.formData()`, `.sse()` and `.ndjson()`.
402
532
 
403
533
  ### `ArthronError`
404
534
 
405
535
  ```ts
406
536
  class ArthronError extends Error {
537
+ constructor(options: ArthronErrorOptions); // the fields below, for a test or a plugin that builds one
407
538
  status: number; // 0 for network failures
408
539
  statusText: string;
409
- headers: Headers; // not enumerable
410
- data: unknown;
411
- detail?: string; // the server's explanation, picked out of data; untrusted text
540
+ headers: Headers; // the response's headers; not enumerable
541
+ payload: unknown; // the failed response's body, parsed (JSON, else text); undefined for a network failure
542
+ description: string | undefined; // the server's description of the failure, extracted from payload; untrusted text
412
543
  response?: Response; // absent for network failures; not enumerable
413
- request: RequestContext; // not enumerable — holds credentials
414
- cause?: unknown; // the underlying error for network failures
415
- toJSON(): { name; message; detail; status; statusText; method; url; data }; // url without query/userinfo
544
+ requestContext: RequestContext; // what plugins get as requestContext, as sent (url, init.method, init.headers, meta) — not a fetch Request; not enumerable, holds credentials
545
+ cause?: unknown; // the underlying error for network failures (query and userinfo redacted)
546
+ toJSON(): { name; message; description; status; statusText; method; url; payload }; // url without query/userinfo
416
547
  }
417
548
  ```
418
549
 
419
- ### Rules worth knowing
420
-
421
- - **Fill path segments with `path`.** `{name}` placeholders are encoded per segment; `""`, `.` and `..` are refused, as are missing or unused values. Without `path`, braces are left alone.
422
- - **URLs are joined by string concatenation.** `baseURL: "https://a/v1"` + `"/users"` → `https://a/v1/users`. The `/v1` survives, unlike with `new URL()`.
423
- - **Absolute URLs stay on `baseURL`'s origin.** `api.get("https://other.example/x")` on a client with a `baseURL` rejects with a `TypeError`, because a path built from user input or an API response must not carry your credentials elsewhere. Same-origin absolute URLs work. `allowAbsoluteUrls: true` lets other origins through, with per-call headers only. Only `http(s)` URLs are accepted.
424
- - **Error bodies are read up to 64 KiB.** That is enough for any error message and keeps a huge or endless error body from filling memory. Past the limit `data` is the raw text and the rest of the body is cancelled.
425
- - **Redirects follow fetch's rules.** On a redirect to another origin, fetch drops `Authorization` but keeps other headers such as `x-api-key`. Pass `redirect: "error"` (or `"manual"`) for calls that carry such keys if the server could redirect off-origin.
426
- - **Bodies.** `string`, `FormData`, `Blob`, `ArrayBuffer`/views, `URLSearchParams` and `ReadableStream` go through untouched — recognised by type tag, so polyfill and cross-realm instances count too. A `ReadableStream` is sent with `duplex: "half"`, which fetch requires. Anything else — including `null` — becomes JSON with `content-type: application/json`; a content type you set must be a JSON one. Bodies JSON would mangle — a `Map`, `Set`, generator, Node stream or function — are rejected with a `TypeError` that says what to pass instead. A `FormData` body is always sent without a `content-type` header, even with a JSON default, so the runtime can add the multipart boundary. `GET`/`HEAD` with a body is rejected with a `TypeError`.
427
- - **Fetch defaults are flat and only sent when set.** Config takes the same fetch options per-call calls do (`credentials`, `cache`, `redirect`, …) as defaults; a per-call value wins and `extend` overrides. Nothing is sent that you did not set — cross-origin cookies need `credentials: "include"`. `signal`, `body` and `method` are per call: a client-wide signal would fail every call once aborted.
428
- - **In browsers, CORS and the engine have the last word.** CI runs the built package in Chromium, Firefox and WebKit against a server on another origin. A JSON body, a custom header and `query()`'s QUERY method each make the browser send a preflight, so the API must allow them (`Access-Control-Allow-Methods: QUERY`, `Access-Control-Allow-Headers`). `retry()` reads `Retry-After` only if the API exposes it (`Access-Control-Expose-Headers: retry-after`); otherwise it waits its own delay. `redirect: "manual"` gives an opaque redirect (`status` 0, `type` `"opaqueredirect"`), returned rather than thrown. A `ReadableStream` body streams only over HTTP/2 (Chromium, WebKit), and the `ArthronError` for one sent to an HTTP/1.1 server says so. Where the browser cannot stream a request body at all (Firefox would send the text `"[object ReadableStream]"`), the call rejects with a `TypeError` before anything is sent. Either way, send a `Blob` or `ArrayBuffer` there.
429
- - **Response bodies are single-use.** A response interceptor that wants to inspect the body must `res.clone()`; one that changes it returns a new `Response`.
430
- - **Argument errors reject, they don't throw.** No `baseURL` with a relative path, a URL the global fetch cannot parse (`baseURL: "api.example.com"` without `https://`), a `BigInt` in a JSON body, a missing `method` in `request` — all surface as a rejected `TypeError` before anything is sent, never as a retried `status: 0`. An injected `fetch` is the judge of its own URLs: arthron does not pre-check them, so one that accepts relative URLs (SvelteKit's server-side `fetch`) works with `baseURL: "/api"`.
431
-
432
- ## For tooling and agents
433
-
434
- - `CHANGELOG.md` lists every release and the semver rules arthron follows since 1.0.
435
- - `llms.txt` ships in the package — a compressed API reference meant to be read by LLM-based tools (`node_modules/arthron/llms.txt`).
436
- - `examples/` holds type-checked, runnable recipes: LLM streaming, auth refresh, an error page, upload/download. `examples/recipes.test.ts` keeps the error-page recipe honest.
437
- - Error messages state the fix (`… — pass baseURL in arthron({ baseURL }) or use an absolute URL`).
550
+ ### Types
551
+
552
+ From `arthron`: `ArthronClient`, `ArthronClientOptions`, `RequestOptions`, `RequestOptionsWithMethod`, `HttpMethod`, `QueryParams`, `RequestContext`, `ArthronMeta`, `Plugin`, `Middleware`, `RequestInterceptor`, `ResponseInterceptor`, `ErrorInterceptor`, `ResponsePromise`, `SseEvent`, `SseOptions`, `NdjsonOptions`, `ArthronSchema` (with `.Infer`, `.Issue`, `.ValidationResult`), `DescriptionExtractor`, `ArthronErrorOptions`. From `arthron/plugins`: the built-ins' option and event types listed under [Built in](#built-in).
553
+
554
+ The public object types — `ArthronClientOptions`, `RequestOptions`, `RequestOptionsWithMethod`, `RequestContext`, `ArthronMeta`, `Plugin`, `ResponsePromise`, `ArthronClient`, `ArthronSchema` — are interfaces, so a plugin package can add its own options with `declare module "arthron" { interface RequestOptions { … } }`.
555
+
556
+ ## For agents
557
+
558
+ - **`llms.txt`** ships in the package (`node_modules/arthron/llms.txt`): the whole API in one compressed file, meant to be read by LLM-based tools — including a line that maps every 1.x name to its 2.0 one.
559
+ - **Error messages state the fix** (`… — pass baseURL in arthron({ baseURL }) or use an absolute URL`), and most 1.x names throw a `TypeError` naming the new one, so an agent working from an old example is told what to change.
560
+ - **`examples/`** holds type-checked, runnable recipes: LLM streaming, auth refresh, an error page, upload/download. `examples/recipes.test.ts` keeps the error-page recipe honest.
561
+ - **`CHANGELOG.md`** lists every release, the semver rules arthron follows since 1.0, and [Moving from 1.x](CHANGELOG.md#moving-from-1x).
438
562
 
439
563
  ## Security
440
564