arthron 0.1.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,9 +14,9 @@ const user = await api.get("/me").json<User>();
14
14
  ```
15
15
 
16
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 and timeouts are middleware. Headers, logging and error mapping are interceptors.
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
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 18+ and browsers.
19
+ - Zero dependencies. ESM and CJS. TypeScript types included, for TypeScript 5.0+. Node 22+ and browsers.
20
20
 
21
21
  ## Install
22
22
 
@@ -33,11 +33,12 @@ const api = arthron({
33
33
  baseURL: "https://api.example.com", // prefix for relative paths
34
34
  headers: { accept: "application/json" }, // defaults; per-call headers win
35
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
36
37
  use: [retry()], // plugins, outermost first
37
38
  });
38
39
  ```
39
40
 
40
- Every field is optional. Without `baseURL`, only absolute URLs are accepted.
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.
41
42
 
42
43
  ### Make requests
43
44
 
@@ -47,6 +48,7 @@ await api.post("/users", { name: "kim" }); // object body → JSON
47
48
  await api.post("/upload", formData); // FormData/Blob/string/stream → sent as-is
48
49
  await api.put("/users/1", { name: "lee" });
49
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
50
52
  await api.delete("/users/1");
51
53
  await api.head("/users/1");
52
54
  await api.request("/users", { method: "OPTIONS" });
@@ -57,13 +59,16 @@ Each call returns `Promise<Response>` with body helpers attached:
57
59
  ```ts
58
60
  const res = await api.get("/me"); // Response — status, headers, body untouched
59
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
60
63
  const text = await api.get("/robots.txt").text();
61
64
  const blob = await api.get("/export").blob();
62
65
  const stream = (await api.post("/chat", prompt)).body; // ReadableStream
63
66
  for await (const e of api.post("/chat", prompt).sse()) {} // see "Stream responses"
64
67
  ```
65
68
 
66
- `init` accepts everything `fetch` does (`signal`, `cache`, `headers`, …) plus `params`, a query object whose `null`/`undefined` values are dropped.
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).
67
72
 
68
73
  ### Stream responses
69
74
 
@@ -78,7 +83,9 @@ for await (const { event, data } of api.post("/v1/chat/completions", body).sse()
78
83
  for await (const row of api.get("/logs").ndjson<LogRow>()) console.log(row);
79
84
  ```
80
85
 
81
- `.sse()` follows the EventSource spec (multi-line `data:` joined with `\n`, comments ignored, dispatch on blank line). Both helpers honour `signal` and `timeout` while the body is being read.
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 })`.
82
89
 
83
90
  ### Handle errors
84
91
 
@@ -91,13 +98,23 @@ try {
91
98
  if (isArthronError(e)) {
92
99
  e.status; // 404, or 0 when the network failed
93
100
  e.data; // parsed body: JSON, else text, else undefined
94
- e.message; // from the body (`message` → `error` → `error.message` → `detail`), else "Request failed with status 404"
95
- e.response; // the original Response, still readable
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
96
105
  }
97
106
  }
98
107
  ```
99
108
 
100
- A request that is aborted or times out rejects with the browser's `AbortError` / `TimeoutError`, not an `ArthronError`.
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.
110
+
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.
112
+
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.
114
+
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.
116
+
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`.
101
118
 
102
119
  ### Derive clients
103
120
 
@@ -109,7 +126,7 @@ const authed = base.use(auth()); // base + auth
109
126
  const admin = authed.extend({ headers: { "x-role": "admin" } }); // authed + header
110
127
  ```
111
128
 
112
- `extend` shallow-merges the config, merges headers case-insensitively (child wins), and appends plugins.
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`.
113
130
 
114
131
  ## Plugins
115
132
 
@@ -117,14 +134,18 @@ A plugin is an object with any of four slots:
117
134
 
118
135
  ```ts
119
136
  type Plugin = {
120
- name?: string;
137
+ name?: string; // identity: use/extend replace the plugin of the same name, without(name) removes it
121
138
  middleware?: (ctx: RequestContext, next: (ctx: RequestContext) => Promise<Response>) => Promise<Response>;
122
139
  request?: (ctx: RequestContext) => RequestContext | Promise<RequestContext>;
123
140
  response?: (res: Response, ctx: RequestContext) => Response | Promise<Response>;
124
141
  error?: (err: ArthronError, ctx: RequestContext) => ArthronError | Promise<ArthronError>;
125
142
  };
126
143
 
127
- type RequestContext = { url: string; init: RequestInit };
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
+ };
128
149
  ```
129
150
 
130
151
  How one request runs:
@@ -142,9 +163,9 @@ middleware 1
142
163
  | Gets | `ctx` and `next` | A value: context, `Response`, or `ArthronError` |
143
164
  | Can | Call `next` again (retry), skip it, add a deadline | Return a changed value |
144
165
  | Cannot | — | Retry, skip, or stop the error from being thrown |
145
- | Examples | `retry`, `timeout`, auth refresh | auth header, tracing id, error mapping |
166
+ | Examples | `retry`, `timeout`, `logging`, auth refresh, error pages | auth header, tracing id, per-attempt error details |
146
167
 
147
- 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.
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.
148
169
 
149
170
  ### Built in
150
171
 
@@ -154,20 +175,26 @@ Re-runs the attempt on network failure or a retryable status. Because it re-runs
154
175
 
155
176
  | option | default | |
156
177
  |---|---|---|
157
- | `retries` | `2` | retries after the first attempt (3 requests total) |
178
+ | `retries` | `2` | retries after the first attempt (3 requests total); a whole number ≥ 0 |
158
179
  | `retryOn` | `[408, 429, 500, 502, 503, 504]` | network failures (`status: 0`) are always retried |
159
- | `methods` | `GET HEAD OPTIONS PUT DELETE` | case-insensitive |
160
- | `delay` | `(retry) => 300 * 2 ** retry` | ms; `retry` counts from 0 |
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 |
161
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 |
185
+
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.
162
187
 
163
- Honours `Retry-After` (seconds or HTTP date). Never retries a `ReadableStream` body or an aborted request.
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`.
164
189
 
165
- #### `timeout(ms)`
190
+ #### `timeout(ms, { idle? })`
166
191
 
167
- 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.
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 })`.
168
195
 
169
196
  ```ts
170
- arthron({ use: [retry(), timeout(5_000)] }); // 5 s per attempt
197
+ arthron({ use: [retry(), timeout(5_000)] }); // 5 s per attempt — a timed-out attempt is not retried
171
198
  arthron({ use: [timeout(5_000), retry()] }); // 5 s for all attempts together
172
199
  ```
173
200
 
@@ -191,47 +218,77 @@ Query strings and `user:pass@` are never printed. Cancellations are not logged.
191
218
 
192
219
  `definePlugin`, `defineMiddleware` and `defineInterceptor.{request,response,error}` are identity functions that give you the parameter types without annotations.
193
220
 
194
- **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.
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
+ }
229
+
230
+ api.use({ request: (ctx) => (ctx.meta.skipAuth ? ctx : withToken(ctx)) });
231
+ api.get("/health", { meta: { skipAuth: true } });
232
+ ```
233
+
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).
195
235
 
196
236
  ```ts
197
237
  import { definePlugin, isArthronError } from "arthron";
198
238
 
199
- export const auth = (store: { token: () => string | null; refresh: () => Promise<void> }) =>
200
- 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({
201
243
  name: "auth",
202
244
  request: (ctx) => {
203
- const token = store.token();
204
- if (!token) return ctx;
205
- 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 } } };
206
248
  },
207
249
  middleware: async (ctx, next) => {
208
250
  try {
209
251
  return await next(ctx);
210
252
  } catch (e) {
211
253
  if (!isArthronError(e) || e.status !== 401) throw e;
212
- await store.refresh(); // if this throws, the caller sees it
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
+ }
213
259
  return next(ctx);
214
260
  }
215
261
  },
216
262
  });
263
+ };
217
264
  ```
218
265
 
219
- **Send users to an error page.** Error interceptors can look and act, but the error is thrown regardless — which is what you want for a side effect 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).
220
267
 
221
268
  ```ts
222
- import { defineInterceptor } from "arthron";
269
+ import { definePlugin, isArthronError } from "arthron";
223
270
 
224
271
  export const errorRedirect = (navigate: (to: string) => void) =>
225
- defineInterceptor.error((err, ctx) => {
226
- if ((ctx.init.method ?? "GET") !== "GET") return err;
227
- if (err.status === 404) navigate("/not-found");
228
- if (err.status >= 500) navigate("/error");
229
- return err;
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
+ },
230
285
  });
231
286
 
232
- api.use({ error: errorRedirect(router.push) });
287
+ const api = arthron({ baseURL: API, use: [errorRedirect(router.push), retry()] }); // first: wraps retry
233
288
  ```
234
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
+
235
292
  **Attach a trace id.** A one-line request interceptor.
236
293
 
237
294
  ```ts
@@ -245,11 +302,15 @@ api.use({ request: (ctx) => ({ ...ctx, init: { ...ctx.init, headers: { ...ctx.in
245
302
  ```ts
246
303
  type ArthronConfig = {
247
304
  baseURL?: string;
248
- headers?: HeadersInit;
249
- credentials?: RequestCredentials;
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)
250
307
  fetch?: typeof fetch; // inject for tests or a polyfill; resolved per request
251
308
  use?: Plugin[];
252
- errorMessage?: (data: unknown, res: Response) => string | undefined;
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).
253
314
  };
254
315
  ```
255
316
 
@@ -258,12 +319,16 @@ type ArthronConfig = {
258
319
  ```ts
259
320
  get(path, init) · delete(path, init) · head(path, init)
260
321
  post(path, body, init) · put(path, body, init) · patch(path, body, init)
322
+ query(path, body, init) // RFC 10008 QUERY
261
323
  request(path, { method, body, ...init })
262
- use(...plugins) → new client
324
+ use(...plugins) → new client (same-name plugins replaced in place)
263
325
  extend(config) → new client
326
+ without(...names) → new client without those plugins
264
327
  ```
265
328
 
266
- All arguments after `path` are optional, except `method` in `request`.
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 { … } }`.
267
332
 
268
333
  ### `ArthronError`
269
334
 
@@ -271,28 +336,40 @@ All arguments after `path` are optional, except `method` in `request`.
271
336
  class ArthronError extends Error {
272
337
  status: number; // 0 for network failures
273
338
  statusText: string;
274
- headers: Headers;
339
+ headers: Headers; // not enumerable
275
340
  data: unknown;
276
- response?: Response; // absent for network failures
277
- request: RequestContext;
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
278
344
  cause?: unknown; // the underlying error for network failures
345
+ toJSON(): { name; message; detail; status; statusText; method; url; data }; // url without query/userinfo
279
346
  }
280
347
  ```
281
348
 
282
349
  ### Rules worth knowing
283
350
 
284
- - **URLs are joined by string concatenation.** `baseURL: "https://a/v1"` + `"/users"` → `https://a/v1/users`. The `/v1` survives, unlike with `new URL()`. Absolute URLs bypass `baseURL`.
285
- - **Bodies.** `string`, `FormData`, `Blob`, `ArrayBuffer`/views, `URLSearchParams` and `ReadableStream` go through untouched. Anything else — including `null` — becomes JSON with `content-type: application/json` unless you set a content type. `GET`/`HEAD` with a body is rejected with a `TypeError`.
286
- - **`credentials` is not set unless you set it.** Cross-origin cookies need `credentials: "include"`.
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
+ - **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.
287
359
  - **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`.
288
- - **Argument errors reject, they don't throw.** No `baseURL` with a relative path, a `BigInt` in a JSON body, a missing `method` in `request` — all surface as a rejected promise.
360
+ - **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"`.
289
361
 
290
362
  ## For tooling and agents
291
363
 
364
+ - `CHANGELOG.md` lists every release and the semver rules arthron follows since 1.0.
292
365
  - `llms.txt` ships in the package — a compressed API reference meant to be read by LLM-based tools (`node_modules/arthron/llms.txt`).
293
- - `examples/` holds type-checked, runnable recipes: LLM streaming, auth refresh, upload/download.
366
+ - `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.
294
367
  - Error messages state the fix (`… — pass baseURL in arthron({ baseURL }) or use an absolute URL`).
295
368
 
369
+ ## Security
370
+
371
+ Report vulnerabilities privately through GitHub — see [SECURITY.md](SECURITY.md) for supported versions, response times and disclosure.
372
+
296
373
  ## License
297
374
 
298
375
  MIT