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/README.md CHANGED
@@ -1,10 +1,22 @@
1
1
  # arthron
2
2
 
3
- **Middleware owns the flow — it can call `next` again to retry the whole request. Interceptors only transform values and can never change the flow. arthron keeps the two apart at the type level.**
3
+ A `fetch` client you assemble from plugins.
4
4
 
5
- A composable `fetch` client. Create one from config, plug in interceptors and middleware, get a plain `Response` back. Zero dependencies, TypeScript first, Node 18+ and browsers.
5
+ ```ts
6
+ import { arthron, logging, retry, timeout } from "arthron";
7
+
8
+ const api = arthron({
9
+ baseURL: "https://api.example.com",
10
+ use: [logging(), timeout(10_000), retry()],
11
+ });
12
+
13
+ const user = await api.get("/me").json<User>();
14
+ ```
6
15
 
7
- > *arthron* (ἄρθρον) — Greek for "joint": the place where two parts meet and work as one.
16
+ - **Plain `Response` in, plain `Response` out.** No wrapper type to learn. `.json<T>()` and friends are there when you want the body.
17
+ - **Two kinds of plugin, kept apart by the types.** Middleware wraps the request and controls the flow — it can call `next` again to retry. Interceptors transform one value and return it; they cannot retry, skip, or swallow. Auth refresh, retry, timeouts, logging, 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.
18
+ - **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.
19
+ - Zero dependencies. ESM and CJS. TypeScript types included. Node 22+ and browsers.
8
20
 
9
21
  ## Install
10
22
 
@@ -12,231 +24,346 @@ A composable `fetch` client. Create one from config, plug in interceptors and mi
12
24
  npm i arthron
13
25
  ```
14
26
 
15
- ## Quick start
27
+ ## Usage
16
28
 
17
- ```ts
18
- import { arthron, logging, retry } from "arthron";
19
-
20
- const base = arthron({
21
- baseURL: "https://api.example.com",
22
- use: [logging(), retry({ retries: 3 })],
23
- });
29
+ ### Create a client
24
30
 
25
- const api = base.extend({
26
- use: [auth({ getToken, refresh })], // your own plugin — see "Writing your own plugin"
31
+ ```ts
32
+ const api = arthron({
33
+ baseURL: "https://api.example.com", // prefix for relative paths
34
+ headers: { accept: "application/json" }, // defaults; per-call headers win
35
+ credentials: "include", // only if you need cookies cross-origin
36
+ cache: "no-store", // any fetch option works as a default; per-call values win
37
+ use: [retry()], // plugins, outermost first
27
38
  });
28
-
29
- const res = await api.get("/me"); // Response
30
- const user = await api.get("/me").json<User>(); // parsed body
31
- const blob = await api.get("/export").blob();
32
- const stream = (await api.post("/chat", { prompt })).body; // ReadableStream
33
39
  ```
34
40
 
35
- ## Concepts
41
+ 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.
36
42
 
37
- One request runs like this:
43
+ ### Make requests
38
44
 
39
- ```
40
- middleware[0] (
41
- middleware[1] (
42
- ...
43
- attempt: request interceptors → fetch → ok ? response interceptors : error interceptors → throw
44
- )
45
- )
45
+ ```ts
46
+ await api.get("/users", { params: { page: 2 } }); // → Response
47
+ await api.post("/users", { name: "kim" }); // object body → JSON
48
+ await api.post("/upload", formData); // FormData/Blob/string/stream → sent as-is
49
+ await api.put("/users/1", { name: "lee" });
50
+ await api.patch("/users/1", { name: "lee" });
51
+ await api.query("/search", { filter: { tag: "x" } }); // RFC 10008 QUERY: GET semantics, body carries the query
52
+ await api.delete("/users/1");
53
+ await api.head("/users/1");
54
+ await api.request("/users", { method: "OPTIONS" });
46
55
  ```
47
56
 
48
- - **Middleware** wraps the whole call, outermost first in registration order. `next(ctx)` runs everything beneath it; calling `next` again re-runs the whole attempt — that is how retry, auth refresh and timeouts are built.
49
- - **Interceptors** run inside each attempt. They receive a value (`RequestContext`, `Response`, or `ArthronError`) and return one. They cannot skip, retry, or swallow — an error interceptor's return value is always thrown.
50
- - Every attempt starts from a **copy** of the context, so headers an interceptor set on attempt one do not leak into attempt two.
51
-
52
- A plugin is just an object bundling any of the four:
57
+ Each call returns `Promise<Response>` with body helpers attached:
53
58
 
54
59
  ```ts
55
- type Plugin = {
56
- name?: string;
57
- middleware?: (ctx, next) => Promise<Response>;
58
- request?: (ctx) => RequestContext | Promise<RequestContext>;
59
- response?: (res, ctx) => Response | Promise<Response>;
60
- error?: (err, ctx) => ArthronError | Promise<ArthronError>;
61
- };
60
+ const res = await api.get("/me"); // Response — status, headers, body untouched
61
+ const user = await api.get("/me").json<User>(); // body parsed; T is a cast, not validation
62
+ const safe = await api.get("/me").json(UserSchema); // validated by any Standard Schema (Zod, Valibot, ArkType); typed from it
63
+ const text = await api.get("/robots.txt").text();
64
+ const blob = await api.get("/export").blob();
65
+ const stream = (await api.post("/chat", prompt)).body; // ReadableStream
66
+ for await (const e of api.post("/chat", prompt).sse()) {} // see "Stream responses"
62
67
  ```
63
68
 
64
- ## API
69
+ `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 } })`.
70
+
71
+ `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).
65
72
 
66
- ### `arthron(config?)`
73
+ ### Stream responses
67
74
 
68
75
  ```ts
69
- type ArthronConfig = {
70
- baseURL?: string; // prefix for relative paths; without it only absolute URLs are accepted
71
- headers?: HeadersInit; // defaults; per-call headers win, compared case-insensitively
72
- credentials?: RequestCredentials; // forwarded as-is; NOT set unless you set it
73
- fetch?: typeof fetch; // injection point for tests / polyfills
74
- use?: Plugin[];
75
- errorMessage?: (data: unknown, res: Response) => string | undefined;
76
- };
76
+ // Server-sent events — one event per `for await` step
77
+ for await (const { event, data } of api.post("/v1/chat/completions", body).sse()) {
78
+ if (data === "[DONE]") break; // provider sentinel; arthron does not interpret it
79
+ process.stdout.write(JSON.parse(data).choices[0].delta.content ?? "");
80
+ }
81
+
82
+ // Newline-delimited JSON (Ollama, many log endpoints)
83
+ for await (const row of api.get("/logs").ndjson<LogRow>()) console.log(row);
77
84
  ```
78
85
 
79
- ### Instance
86
+ `.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.
87
+
88
+ 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 })`.
89
+
90
+ ### Handle errors
80
91
 
81
92
  ```ts
82
- api.get(path, init?) api.delete(path, init?) api.head(path, init?)
83
- api.post(path, body?, init?) api.put(path, body?, init?) api.patch(path, body?, init?)
84
- api.request(path, { method, body?, ...init })
93
+ import { isArthronError } from "arthron";
85
94
 
86
- api.use(...plugins) // new client with plugins appended
87
- api.extend(config) // new client: config shallow-merged, headers merged, plugins appended
95
+ try {
96
+ await api.get("/users/404");
97
+ } catch (e) {
98
+ if (isArthronError(e)) {
99
+ e.status; // 404, or 0 when the network failed
100
+ e.data; // parsed body: JSON, else text, else undefined
101
+ e.detail; // the server's explanation (`message` → `error` → `error.message` → `detail`), or undefined — show this to users
102
+ e.message; // arthron's own: "GET https://api.example.com/users/404 failed with 404 Not Found" — for logs
103
+ e.response; // the failed Response, still readable (up to 64 KiB of its body)
104
+ e.request; // what was sent (url, init) — read it directly; it is left out of logs
105
+ }
106
+ }
88
107
  ```
89
108
 
90
- `init` is `RequestInit` minus `method`/`body`, plus `params` (query object; `null`/`undefined` skipped, appended with `&` if the path already has a query).
109
+ `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.
91
110
 
92
- Clients are **immutable**. `use` and `extend` return new clients; the receiver — and anything previously derived from it — is untouched.
111
+ `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.
93
112
 
94
- **Body rules.** `string`, `FormData`, `Blob`, `ArrayBuffer`/views, `URLSearchParams`, `ReadableStream` are sent as-is with no `Content-Type` added. Anything else is `JSON.stringify`-ed with `content-type: application/json`, unless you already set a content type.
113
+ 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.
95
114
 
96
- **URL rules.** `baseURL` and `path` are joined by string concatenation — `"https://a/v1" + "/users"` → `https://a/v1/users`. The prefix survives a leading slash (unlike `new URL()`). Absolute paths skip `baseURL`.
115
+ `.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.
97
116
 
98
- ### Return value — `ArthronPromise`
117
+ 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`.
99
118
 
100
- Every method returns `Promise<Response>` with convenience helpers:
119
+ ### Derive clients
120
+
121
+ Clients are immutable. `use` and `extend` return a new client and leave the original alone.
101
122
 
102
123
  ```ts
103
- const res = await api.get("/me"); // Response, untouched
104
- const body = await api.get("/me").json<User>(); // (await res).json() — T is a cast, not validation
105
- .text() .blob() .arrayBuffer() .formData()
124
+ const base = arthron({ baseURL: API, use: [logging(), retry()] });
125
+ const authed = base.use(auth()); // base + auth
126
+ const admin = authed.extend({ headers: { "x-role": "admin" } }); // authed + header
106
127
  ```
107
128
 
108
- A `204`/empty body makes `.json()` reject, exactly like `Response.json()` would.
129
+ `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 `retry`, `timeout` and `logging`.
130
+
131
+ ## Plugins
109
132
 
110
- ### Errors — `ArthronError`
133
+ A plugin is an object with any of four slots:
111
134
 
112
135
  ```ts
113
- class ArthronError extends Error {
114
- status: number; // HTTP status; 0 for network failures
115
- statusText: string;
116
- headers: Headers;
117
- data: unknown; // parsed body: JSON if it parses, else text, else undefined
118
- response?: Response; // the original; its body is already consumed into `data`. Absent for network failures
119
- request: RequestContext;
120
- cause?: unknown; // the original error for network failures
121
- }
136
+ type Plugin = {
137
+ name?: string; // identity: use/extend replace the plugin of the same name, without(name) removes it
138
+ middleware?: (ctx: RequestContext, next: (ctx: RequestContext) => Promise<Response>) => Promise<Response>;
139
+ request?: (ctx: RequestContext) => RequestContext | Promise<RequestContext>;
140
+ response?: (res: Response, ctx: RequestContext) => Response | Promise<Response>;
141
+ error?: (err: ArthronError, ctx: RequestContext) => ArthronError | Promise<ArthronError>;
142
+ };
143
+
144
+ type RequestContext = {
145
+ url: string;
146
+ init: Omit<RequestInit, "headers"> & { headers: Record<string, string> }; // lowercase names
147
+ meta: ArthronMeta; // per-call plugin settings, never sent
148
+ };
122
149
  ```
123
150
 
124
- Only two things become an `ArthronError`: a response with `ok === false`, and `fetch` itself rejecting (network failure, `status: 0`). **Everything else is rethrown untouched and skips the error interceptors** — `AbortError`, `TimeoutError`, and anything a plugin throws. A bug in an interceptor is a bug, not a 500.
151
+ How one request runs:
125
152
 
126
- The message is picked from the body by `message` → `error` (string) → `error.message` → `detail`, falling back to `Request failed with status N`. Override with `errorMessage` in the config.
153
+ ```
154
+ middleware 1
155
+ middleware 2
156
+ attempt ─ request interceptors → fetch → response interceptors
157
+ ↘ error interceptors → throw
158
+ ```
127
159
 
128
- ```ts
129
- import { isArthronError } from "arthron";
160
+ | | Middleware | Interceptor |
161
+ |---|---|---|
162
+ | Runs | Once per call, wrapping everything below | Once per attempt |
163
+ | Gets | `ctx` and `next` | A value: context, `Response`, or `ArthronError` |
164
+ | Can | Call `next` again (retry), skip it, add a deadline | Return a changed value |
165
+ | Cannot | — | Retry, skip, or stop the error from being thrown |
166
+ | Examples | `retry`, `timeout`, `logging`, auth refresh, error pages | auth header, tracing id, per-attempt error details |
130
167
 
131
- try { await api.get("/me"); }
132
- catch (e) { if (isArthronError(e) && e.status === 404) { /* … */ } else throw e; }
133
- ```
168
+ 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.
134
169
 
135
- ## Built-in plugins
170
+ ### Built in
136
171
 
137
- ### `logging(options?)`
172
+ #### `retry(options)`
138
173
 
139
- One line when a request starts, one when it settles. The query string is dropped (it tends to carry tokens). Cancellations are not logged.
174
+ 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.
140
175
 
141
176
  | option | default | |
142
177
  |---|---|---|
143
- | `enabled` | `true` | arthron never reads `NODE_ENV` — pass `isDev` yourself |
144
- | `logger` | `console` | needs `log` and `error` |
178
+ | `retries` | `2` | retries after the first attempt (3 requests total); a whole number ≥ 0 |
179
+ | `retryOn` | `[408, 429, 500, 502, 503, 504]` | network failures (`status: 0`) are always retried |
180
+ | `methods` | `GET HEAD OPTIONS QUERY PUT DELETE` | case-insensitive |
181
+ | `delay` | `(retry) => Math.random() * 300 * 2 ** retry` | ms; `retry` counts from 0. Full jitter, so clients that failed together don't retry together |
182
+ | `maxRetryAfter` | `60_000` | a `Retry-After` longer than this means "don't retry" |
183
+ | `shouldRetry` | — | `(error, attempt, ctx) => boolean \| undefined`: `true` retries, `false` stops, `undefined` uses the rules above. Sees every error, an inner `TimeoutError` included |
184
+ | `onRetry` | — | `(error, attempt, waitMs) => void`, before each wait |
145
185
 
146
- ```
147
- [arthron] → GET /users
148
- [arthron] ← GET /users 200 42ms
149
- [arthron] ✕ GET /users 503 1203ms
186
+ 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.
187
+
188
+ 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`.
189
+
190
+ #### `timeout(ms, { idle? })`
191
+
192
+ 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).
193
+
194
+ A total deadline also cuts off a stream that is still flowing. For streams, bound the silence instead: `timeout(Infinity, { 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(600_000, { idle: 30_000 })`.
195
+
196
+ ```ts
197
+ arthron({ use: [retry(), timeout(5_000)] }); // 5 s per attempt — a timed-out attempt is not retried
198
+ arthron({ use: [timeout(5_000), retry()] }); // 5 s for all attempts together
150
199
  ```
151
200
 
152
- ### `retry(options?)`
201
+ #### `logging(options)`
153
202
 
154
- Retries by calling `next` again, so request interceptors (fresh auth header, fresh trace id) run on every attempt.
203
+ ```
204
+ [arthron] → GET https://api.example.com/users
205
+ [arthron] ← GET https://api.example.com/users 200 42ms
206
+ [arthron] ✕ GET https://api.example.com/users 503 1203ms
207
+ ```
155
208
 
156
209
  | option | default | |
157
210
  |---|---|---|
158
- | `retries` | `2` | retries *after* the first attempt — 3 requests total |
159
- | `retryOn` | `[408, 429, 500, 502, 503, 504]` | network failures (`status 0`) are always retried |
160
- | `methods` | `GET HEAD OPTIONS PUT DELETE` | case-insensitive |
161
- | `delay` | `retry => 300 * 2 ** retry` | ms; `retry` is 0-based |
162
- | `maxRetryAfter` | `60_000` | a `Retry-After` longer than this (ms) means "don't retry" |
211
+ | `enabled` | `true` | pass `process.env.NODE_ENV !== "production"` or similar — arthron never reads env |
212
+ | `logger` | `console` | anything with `log` and `error` |
213
+ | `onEvent` | — | receive `{ phase, method, path, status, ms, error }` instead of console lines |
214
+
215
+ Query strings and `user:pass@` are never printed. Cancellations are not logged.
163
216
 
164
- `Retry-After` (seconds or HTTP-date) wins over `delay` when present and within `maxRetryAfter`; beyond it the error is thrown without retrying. `ReadableStream` bodies are never retried (they can only be read once). Respects `signal` — an aborted request stops retrying immediately.
217
+ ### Write your own
165
218
 
166
- ## Writing your own plugin
219
+ `definePlugin`, `defineMiddleware` and `defineInterceptor.{request,response,error}` are identity functions that give you the parameter types without annotations.
167
220
 
168
- `defineMiddleware`, `defineInterceptor.{request,response,error}` and `definePlugin` are identity functions that give you contextual typing. Use them or annotate — same result.
221
+ **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:
222
+
223
+ ```ts
224
+ declare module "arthron" {
225
+ interface ArthronMeta {
226
+ skipAuth?: boolean;
227
+ }
228
+ }
169
229
 
170
- ### `auth` — refresh on 401, then re-run the request
230
+ api.use({ request: (ctx) => (ctx.meta.skipAuth ? ctx : withToken(ctx)) });
231
+ api.get("/health", { meta: { skipAuth: true } });
232
+ ```
171
233
 
172
- This is the case the middleware/interceptor split exists for. The refresh and the retry are *flow*, so they live in middleware. Attaching the header is a *value change*, so it is a request interceptor — and because every `next` re-runs the interceptors, the retried request automatically picks up the new token.
234
+ **Refresh a session on 401, then redo the request.** 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 retried request carries the new token without extra code. Two details matter in production: concurrent 401s must share one refresh (with refresh-token rotation, parallel refreshes revoke each other), and the token must only go to your API's origin. Tested in [`examples/auth-refresh.ts`](examples/auth-refresh.ts).
173
235
 
174
236
  ```ts
175
237
  import { definePlugin, isArthronError } from "arthron";
176
238
 
177
- export const auth = ({ getToken, refresh }: {
178
- getToken: () => string | null;
179
- refresh: () => Promise<void>;
180
- }) =>
181
- definePlugin({
239
+ export const auth = (store: { token: () => string | null; refresh: () => Promise<void> }, origin: string) => {
240
+ let refreshing: Promise<void> | undefined;
241
+ const bearer = () => (store.token() ? `Bearer ${store.token()}` : undefined);
242
+ return definePlugin({
182
243
  name: "auth",
183
244
  request: (ctx) => {
184
- const token = getToken();
185
- if (!token) return ctx;
186
- return { ...ctx, init: { ...ctx.init, headers: { ...ctx.init.headers, authorization: `Bearer ${token}` } } };
245
+ const value = bearer();
246
+ if (!value || new URL(ctx.url).origin !== origin) return ctx;
247
+ return { ...ctx, init: { ...ctx.init, headers: { ...ctx.init.headers, authorization: value } } };
187
248
  },
188
249
  middleware: async (ctx, next) => {
189
250
  try {
190
251
  return await next(ctx);
191
252
  } catch (e) {
192
253
  if (!isArthronError(e) || e.status !== 401) throw e;
193
- await refresh(); // throws → propagates; caller decides to log out
194
- return next(ctx); // whole attempt again, request interceptor adds the new token
254
+ // Refresh only if the failed token is still current — else another call already did.
255
+ if (e.request.init.headers.authorization === bearer()) {
256
+ refreshing ??= store.refresh().finally(() => (refreshing = undefined));
257
+ await refreshing; // a failed refresh rejects here: the caller sees it
258
+ }
259
+ return next(ctx);
195
260
  }
196
261
  },
197
262
  });
263
+ };
198
264
  ```
199
265
 
200
- ### `errorRedirect` — react to errors without changing them
201
-
202
- An error interceptor can only *look* and *return*; it cannot swallow. That makes it the right home for side effects like navigation.
266
+ **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).
203
267
 
204
268
  ```ts
205
- import { defineInterceptor } from "arthron";
269
+ import { definePlugin, isArthronError } from "arthron";
206
270
 
207
271
  export const errorRedirect = (navigate: (to: string) => void) =>
208
- defineInterceptor.error((err, ctx) => {
209
- const isGet = (ctx.init.method ?? "GET") === "GET";
210
- if (isGet && err.status === 404) navigate("/not-found");
211
- if (isGet && err.status >= 500) navigate("/error");
212
- return err; // still thrown
272
+ definePlugin({
273
+ name: "errorRedirect",
274
+ middleware: async (ctx, next) => {
275
+ try {
276
+ return await next(ctx);
277
+ } catch (error) {
278
+ if (isArthronError(error) && (ctx.init.method ?? "GET") === "GET") {
279
+ if (error.status === 404) navigate("/not-found");
280
+ else if (error.status >= 500) navigate("/error");
281
+ }
282
+ throw error;
283
+ }
284
+ },
213
285
  });
214
286
 
215
- api.use({ error: errorRedirect(router.push) });
287
+ const api = arthron({ baseURL: API, use: [errorRedirect(router.push), retry()] }); // first: wraps retry
288
+ ```
289
+
290
+ The same shape maps errors to your own types: `catch (e) { throw toDomainError(e); }` in an outermost middleware runs once per call, after retries.
291
+
292
+ **Attach a trace id.** A one-line request interceptor.
293
+
294
+ ```ts
295
+ api.use({ request: (ctx) => ({ ...ctx, init: { ...ctx.init, headers: { ...ctx.init.headers, "x-trace-id": crypto.randomUUID() } } }) });
216
296
  ```
217
297
 
218
- ### `timeout` — a four-line middleware
298
+ ## Reference
299
+
300
+ ### `arthron(config)`
301
+
302
+ ```ts
303
+ type ArthronConfig = {
304
+ baseURL?: string;
305
+ allowAbsoluteUrls?: boolean; // let absolute URLs leave baseURL's origin (without config headers)
306
+ headers?: HeadersInit | Record<string, string | null>; // null drops a header (in extend)
307
+ fetch?: typeof fetch; // inject for tests or a polyfill; resolved per request
308
+ use?: Plugin[];
309
+ errorDetail?: (data: unknown, res: Response) => string | undefined; // picks ArthronError.detail out of the body
310
+ throwOn?: (res: Response) => boolean; // which non-ok responses reject (also per call)
311
+ // …plus any fetch option as a default: credentials, cache, redirect, mode,
312
+ // keepalive, referrerPolicy, priority, integrity. Not body, method or signal —
313
+ // those belong to one request (passing them throws).
314
+ };
315
+ ```
219
316
 
220
- `AbortSignal.any` needs Node 20.3+ or a current browser; on Node 18 combine the signals with an `AbortController` by hand.
317
+ ### Client
221
318
 
222
319
  ```ts
223
- import { defineMiddleware } from "arthron";
320
+ get(path, init) · delete(path, init) · head(path, init)
321
+ post(path, body, init) · put(path, body, init) · patch(path, body, init)
322
+ query(path, body, init) // RFC 10008 QUERY
323
+ request(path, { method, body, ...init })
324
+ use(...plugins) → new client (same-name plugins replaced in place)
325
+ extend(config) → new client
326
+ without(...names) → new client without those plugins
327
+ ```
328
+
329
+ 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.
330
+
331
+ 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 { … } }`.
332
+
333
+ ### `ArthronError`
224
334
 
225
- export const timeout = (ms: number) =>
226
- defineMiddleware((ctx, next) =>
227
- next({ ...ctx, init: { ...ctx.init, signal: AbortSignal.any([ctx.init.signal, AbortSignal.timeout(ms)].filter(Boolean) as AbortSignal[]) } }),
228
- );
335
+ ```ts
336
+ class ArthronError extends Error {
337
+ status: number; // 0 for network failures
338
+ statusText: string;
339
+ headers: Headers; // not enumerable
340
+ data: unknown;
341
+ detail?: string; // the server's explanation, picked out of data; untrusted text
342
+ response?: Response; // absent for network failures; not enumerable
343
+ request: RequestContext; // not enumerable — holds credentials
344
+ cause?: unknown; // the underlying error for network failures
345
+ toJSON(): { name; message; detail; status; statusText; method; url; data }; // url without query/userinfo
346
+ }
229
347
  ```
230
348
 
231
- The resulting `TimeoutError` is rethrown untouched — it is not an `ArthronError`, so `retry` will not retry it.
349
+ ### Rules worth knowing
350
+
351
+ - **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.
352
+ - **URLs are joined by string concatenation.** `baseURL: "https://a/v1"` + `"/users"` → `https://a/v1/users`. The `/v1` survives, unlike with `new URL()`.
353
+ - **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.
354
+ - **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.
355
+ - **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.
356
+ - **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`.
357
+ - **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.
358
+ - **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`.
359
+ - **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"`.
232
360
 
233
- ## Gotchas
361
+ ## For tooling and agents
234
362
 
235
- - **`Response` bodies are single-use.** A response interceptor that wants to peek must `res.clone()` first; one that wants to change the body returns a new `Response`.
236
- - **`credentials` is not set by default.** Cookie-based sessions need `credentials: "include"` in the config (cross-origin) — arthron will not guess.
237
- - **`baseURL` joining is string concatenation.** `"…/v1"` + `"/users"` keeps `/v1`. Pass an absolute URL to escape the base.
238
- - **Only `!ok` and network failures become `ArthronError`.** Cancellations and plugin exceptions propagate as-is and do not reach error interceptors or `retry`.
239
- - **`.json<T>()` is a cast.** Validate with your schema library if you need to.
363
+ - `CHANGELOG.md` lists every release and the semver rules arthron follows since 1.0.
364
+ - `llms.txt` ships in the package — a compressed API reference meant to be read by LLM-based tools (`node_modules/arthron/llms.txt`).
365
+ - `examples/` holds type-checked, runnable recipes: LLM streaming, auth refresh, an error page, upload/download. `examples/recipes.test.ts` keeps the auth and error-page recipes honest.
366
+ - Error messages state the fix (`… — pass baseURL in arthron({ baseURL }) or use an absolute URL`).
240
367
 
241
368
  ## License
242
369