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 +264 -137
- package/dist/index.cjs +842 -199
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +343 -60
- package/dist/index.d.mts +343 -60
- package/dist/index.mjs +841 -200
- package/dist/index.mjs.map +1 -1
- package/llms.txt +115 -0
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
# arthron
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A `fetch` client you assemble from plugins.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
27
|
+
## Usage
|
|
16
28
|
|
|
17
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
+
### Make requests
|
|
38
44
|
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
73
|
+
### Stream responses
|
|
67
74
|
|
|
68
75
|
```ts
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
api.
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
104
|
-
const
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
+
A plugin is an object with any of four slots:
|
|
111
134
|
|
|
112
135
|
```ts
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
151
|
+
How one request runs:
|
|
125
152
|
|
|
126
|
-
|
|
153
|
+
```
|
|
154
|
+
middleware 1
|
|
155
|
+
middleware 2
|
|
156
|
+
attempt ─ request interceptors → fetch → response interceptors
|
|
157
|
+
↘ error interceptors → throw
|
|
158
|
+
```
|
|
127
159
|
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
170
|
+
### Built in
|
|
136
171
|
|
|
137
|
-
|
|
172
|
+
#### `retry(options)`
|
|
138
173
|
|
|
139
|
-
|
|
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
|
-
| `
|
|
144
|
-
| `
|
|
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
|
-
|
|
148
|
-
[
|
|
149
|
-
|
|
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
|
-
|
|
201
|
+
#### `logging(options)`
|
|
153
202
|
|
|
154
|
-
|
|
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
|
-
| `
|
|
159
|
-
| `
|
|
160
|
-
| `
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
217
|
+
### Write your own
|
|
165
218
|
|
|
166
|
-
|
|
219
|
+
`definePlugin`, `defineMiddleware` and `defineInterceptor.{request,response,error}` are identity functions that give you the parameter types without annotations.
|
|
167
220
|
|
|
168
|
-
|
|
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
|
-
|
|
230
|
+
api.use({ request: (ctx) => (ctx.meta.skipAuth ? ctx : withToken(ctx)) });
|
|
231
|
+
api.get("/health", { meta: { skipAuth: true } });
|
|
232
|
+
```
|
|
171
233
|
|
|
172
|
-
|
|
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 = ({
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
185
|
-
if (!
|
|
186
|
-
return { ...ctx, init: { ...ctx.init, headers: { ...ctx.init.headers, authorization:
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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 {
|
|
269
|
+
import { definePlugin, isArthronError } from "arthron";
|
|
206
270
|
|
|
207
271
|
export const errorRedirect = (navigate: (to: string) => void) =>
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
317
|
+
### Client
|
|
221
318
|
|
|
222
319
|
```ts
|
|
223
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
361
|
+
## For tooling and agents
|
|
234
362
|
|
|
235
|
-
-
|
|
236
|
-
-
|
|
237
|
-
-
|
|
238
|
-
-
|
|
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
|
|