arthron 0.0.1 → 0.1.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 +190 -135
- package/dist/index.cjs +243 -18
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +63 -3
- package/dist/index.d.mts +63 -3
- package/dist/index.mjs +243 -19
- package/dist/index.mjs.map +1 -1
- package/llms.txt +99 -0
- package/package.json +5 -2
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 and timeouts are middleware. Headers, logging and error mapping 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 18+ and browsers.
|
|
8
20
|
|
|
9
21
|
## Install
|
|
10
22
|
|
|
@@ -12,176 +24,183 @@ 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";
|
|
29
|
+
### Create a client
|
|
19
30
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
|
|
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
|
+
use: [retry()], // plugins, outermost first
|
|
27
37
|
});
|
|
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
38
|
```
|
|
34
39
|
|
|
35
|
-
|
|
40
|
+
Every field is optional. Without `baseURL`, only absolute URLs are accepted.
|
|
36
41
|
|
|
37
|
-
|
|
42
|
+
### Make requests
|
|
38
43
|
|
|
44
|
+
```ts
|
|
45
|
+
await api.get("/users", { params: { page: 2 } }); // → Response
|
|
46
|
+
await api.post("/users", { name: "kim" }); // object body → JSON
|
|
47
|
+
await api.post("/upload", formData); // FormData/Blob/string/stream → sent as-is
|
|
48
|
+
await api.put("/users/1", { name: "lee" });
|
|
49
|
+
await api.patch("/users/1", { name: "lee" });
|
|
50
|
+
await api.delete("/users/1");
|
|
51
|
+
await api.head("/users/1");
|
|
52
|
+
await api.request("/users", { method: "OPTIONS" });
|
|
39
53
|
```
|
|
40
|
-
middleware[0] (
|
|
41
|
-
middleware[1] (
|
|
42
|
-
...
|
|
43
|
-
attempt: request interceptors → fetch → ok ? response interceptors : error interceptors → throw
|
|
44
|
-
)
|
|
45
|
-
)
|
|
46
|
-
```
|
|
47
|
-
|
|
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
54
|
|
|
52
|
-
|
|
55
|
+
Each call returns `Promise<Response>` with body helpers attached:
|
|
53
56
|
|
|
54
57
|
```ts
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
};
|
|
58
|
+
const res = await api.get("/me"); // Response — status, headers, body untouched
|
|
59
|
+
const user = await api.get("/me").json<User>(); // body parsed; T is a cast, not validation
|
|
60
|
+
const text = await api.get("/robots.txt").text();
|
|
61
|
+
const blob = await api.get("/export").blob();
|
|
62
|
+
const stream = (await api.post("/chat", prompt)).body; // ReadableStream
|
|
63
|
+
for await (const e of api.post("/chat", prompt).sse()) {} // see "Stream responses"
|
|
62
64
|
```
|
|
63
65
|
|
|
64
|
-
|
|
66
|
+
`init` accepts everything `fetch` does (`signal`, `cache`, `headers`, …) plus `params`, a query object whose `null`/`undefined` values are dropped.
|
|
65
67
|
|
|
66
|
-
###
|
|
68
|
+
### Stream responses
|
|
67
69
|
|
|
68
70
|
```ts
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
71
|
+
// Server-sent events — one event per `for await` step
|
|
72
|
+
for await (const { event, data } of api.post("/v1/chat/completions", body).sse()) {
|
|
73
|
+
if (data === "[DONE]") break; // provider sentinel; arthron does not interpret it
|
|
74
|
+
process.stdout.write(JSON.parse(data).choices[0].delta.content ?? "");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Newline-delimited JSON (Ollama, many log endpoints)
|
|
78
|
+
for await (const row of api.get("/logs").ndjson<LogRow>()) console.log(row);
|
|
77
79
|
```
|
|
78
80
|
|
|
79
|
-
|
|
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.
|
|
82
|
+
|
|
83
|
+
### Handle errors
|
|
80
84
|
|
|
81
85
|
```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 })
|
|
86
|
+
import { isArthronError } from "arthron";
|
|
85
87
|
|
|
86
|
-
|
|
87
|
-
api.
|
|
88
|
+
try {
|
|
89
|
+
await api.get("/users/404");
|
|
90
|
+
} catch (e) {
|
|
91
|
+
if (isArthronError(e)) {
|
|
92
|
+
e.status; // 404, or 0 when the network failed
|
|
93
|
+
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
|
|
96
|
+
}
|
|
97
|
+
}
|
|
88
98
|
```
|
|
89
99
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
Clients are **immutable**. `use` and `extend` return new clients; the receiver — and anything previously derived from it — is untouched.
|
|
93
|
-
|
|
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.
|
|
100
|
+
A request that is aborted or times out rejects with the browser's `AbortError` / `TimeoutError`, not an `ArthronError`.
|
|
95
101
|
|
|
96
|
-
|
|
102
|
+
### Derive clients
|
|
97
103
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
Every method returns `Promise<Response>` with convenience helpers:
|
|
104
|
+
Clients are immutable. `use` and `extend` return a new client and leave the original alone.
|
|
101
105
|
|
|
102
106
|
```ts
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
|
|
107
|
+
const base = arthron({ baseURL: API, use: [logging(), retry()] });
|
|
108
|
+
const authed = base.use(auth()); // base + auth
|
|
109
|
+
const admin = authed.extend({ headers: { "x-role": "admin" } }); // authed + header
|
|
106
110
|
```
|
|
107
111
|
|
|
108
|
-
|
|
112
|
+
`extend` shallow-merges the config, merges headers case-insensitively (child wins), and appends plugins.
|
|
113
|
+
|
|
114
|
+
## Plugins
|
|
109
115
|
|
|
110
|
-
|
|
116
|
+
A plugin is an object with any of four slots:
|
|
111
117
|
|
|
112
118
|
```ts
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
119
|
+
type Plugin = {
|
|
120
|
+
name?: string;
|
|
121
|
+
middleware?: (ctx: RequestContext, next: (ctx: RequestContext) => Promise<Response>) => Promise<Response>;
|
|
122
|
+
request?: (ctx: RequestContext) => RequestContext | Promise<RequestContext>;
|
|
123
|
+
response?: (res: Response, ctx: RequestContext) => Response | Promise<Response>;
|
|
124
|
+
error?: (err: ArthronError, ctx: RequestContext) => ArthronError | Promise<ArthronError>;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
type RequestContext = { url: string; init: RequestInit };
|
|
122
128
|
```
|
|
123
129
|
|
|
124
|
-
|
|
130
|
+
How one request runs:
|
|
125
131
|
|
|
126
|
-
|
|
132
|
+
```
|
|
133
|
+
middleware 1
|
|
134
|
+
middleware 2
|
|
135
|
+
attempt ─ request interceptors → fetch → response interceptors
|
|
136
|
+
↘ error interceptors → throw
|
|
137
|
+
```
|
|
127
138
|
|
|
128
|
-
|
|
129
|
-
|
|
139
|
+
| | Middleware | Interceptor |
|
|
140
|
+
|---|---|---|
|
|
141
|
+
| Runs | Once per call, wrapping everything below | Once per attempt |
|
|
142
|
+
| Gets | `ctx` and `next` | A value: context, `Response`, or `ArthronError` |
|
|
143
|
+
| Can | Call `next` again (retry), skip it, add a deadline | Return a changed value |
|
|
144
|
+
| Cannot | — | Retry, skip, or stop the error from being thrown |
|
|
145
|
+
| Examples | `retry`, `timeout`, auth refresh | auth header, tracing id, error mapping |
|
|
130
146
|
|
|
131
|
-
|
|
132
|
-
catch (e) { if (isArthronError(e) && e.status === 404) { /* … */ } else throw e; }
|
|
133
|
-
```
|
|
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.
|
|
134
148
|
|
|
135
|
-
|
|
149
|
+
### Built in
|
|
136
150
|
|
|
137
|
-
|
|
151
|
+
#### `retry(options)`
|
|
138
152
|
|
|
139
|
-
|
|
153
|
+
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
154
|
|
|
141
155
|
| option | default | |
|
|
142
156
|
|---|---|---|
|
|
143
|
-
| `
|
|
144
|
-
| `
|
|
157
|
+
| `retries` | `2` | retries after the first attempt (3 requests total) |
|
|
158
|
+
| `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 |
|
|
161
|
+
| `maxRetryAfter` | `60_000` | a `Retry-After` longer than this means "don't retry" |
|
|
145
162
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
163
|
+
Honours `Retry-After` (seconds or HTTP date). Never retries a `ReadableStream` body or an aborted request.
|
|
164
|
+
|
|
165
|
+
#### `timeout(ms)`
|
|
166
|
+
|
|
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.
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
arthron({ use: [retry(), timeout(5_000)] }); // 5 s per attempt
|
|
171
|
+
arthron({ use: [timeout(5_000), retry()] }); // 5 s for all attempts together
|
|
150
172
|
```
|
|
151
173
|
|
|
152
|
-
|
|
174
|
+
#### `logging(options)`
|
|
153
175
|
|
|
154
|
-
|
|
176
|
+
```
|
|
177
|
+
[arthron] → GET https://api.example.com/users
|
|
178
|
+
[arthron] ← GET https://api.example.com/users 200 42ms
|
|
179
|
+
[arthron] ✕ GET https://api.example.com/users 503 1203ms
|
|
180
|
+
```
|
|
155
181
|
|
|
156
182
|
| option | default | |
|
|
157
183
|
|---|---|---|
|
|
158
|
-
| `
|
|
159
|
-
| `
|
|
160
|
-
| `
|
|
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" |
|
|
163
|
-
|
|
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.
|
|
184
|
+
| `enabled` | `true` | pass `process.env.NODE_ENV !== "production"` or similar — arthron never reads env |
|
|
185
|
+
| `logger` | `console` | anything with `log` and `error` |
|
|
186
|
+
| `onEvent` | — | receive `{ phase, method, path, status, ms, error }` instead of console lines |
|
|
165
187
|
|
|
166
|
-
|
|
188
|
+
Query strings and `user:pass@` are never printed. Cancellations are not logged.
|
|
167
189
|
|
|
168
|
-
|
|
190
|
+
### Write your own
|
|
169
191
|
|
|
170
|
-
|
|
192
|
+
`definePlugin`, `defineMiddleware` and `defineInterceptor.{request,response,error}` are identity functions that give you the parameter types without annotations.
|
|
171
193
|
|
|
172
|
-
|
|
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.
|
|
173
195
|
|
|
174
196
|
```ts
|
|
175
197
|
import { definePlugin, isArthronError } from "arthron";
|
|
176
198
|
|
|
177
|
-
export const auth = ({
|
|
178
|
-
getToken: () => string | null;
|
|
179
|
-
refresh: () => Promise<void>;
|
|
180
|
-
}) =>
|
|
199
|
+
export const auth = (store: { token: () => string | null; refresh: () => Promise<void> }) =>
|
|
181
200
|
definePlugin({
|
|
182
201
|
name: "auth",
|
|
183
202
|
request: (ctx) => {
|
|
184
|
-
const token =
|
|
203
|
+
const token = store.token();
|
|
185
204
|
if (!token) return ctx;
|
|
186
205
|
return { ...ctx, init: { ...ctx.init, headers: { ...ctx.init.headers, authorization: `Bearer ${token}` } } };
|
|
187
206
|
},
|
|
@@ -190,53 +209,89 @@ export const auth = ({ getToken, refresh }: {
|
|
|
190
209
|
return await next(ctx);
|
|
191
210
|
} catch (e) {
|
|
192
211
|
if (!isArthronError(e) || e.status !== 401) throw e;
|
|
193
|
-
await refresh();
|
|
194
|
-
return next(ctx);
|
|
212
|
+
await store.refresh(); // if this throws, the caller sees it
|
|
213
|
+
return next(ctx);
|
|
195
214
|
}
|
|
196
215
|
},
|
|
197
216
|
});
|
|
198
217
|
```
|
|
199
218
|
|
|
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.
|
|
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.
|
|
203
220
|
|
|
204
221
|
```ts
|
|
205
222
|
import { defineInterceptor } from "arthron";
|
|
206
223
|
|
|
207
224
|
export const errorRedirect = (navigate: (to: string) => void) =>
|
|
208
225
|
defineInterceptor.error((err, ctx) => {
|
|
209
|
-
|
|
210
|
-
if (
|
|
211
|
-
if (
|
|
212
|
-
return err;
|
|
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;
|
|
213
230
|
});
|
|
214
231
|
|
|
215
232
|
api.use({ error: errorRedirect(router.push) });
|
|
216
233
|
```
|
|
217
234
|
|
|
218
|
-
|
|
235
|
+
**Attach a trace id.** A one-line request interceptor.
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
api.use({ request: (ctx) => ({ ...ctx, init: { ...ctx.init, headers: { ...ctx.init.headers, "x-trace-id": crypto.randomUUID() } } }) });
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Reference
|
|
242
|
+
|
|
243
|
+
### `arthron(config)`
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
type ArthronConfig = {
|
|
247
|
+
baseURL?: string;
|
|
248
|
+
headers?: HeadersInit;
|
|
249
|
+
credentials?: RequestCredentials;
|
|
250
|
+
fetch?: typeof fetch; // inject for tests or a polyfill; resolved per request
|
|
251
|
+
use?: Plugin[];
|
|
252
|
+
errorMessage?: (data: unknown, res: Response) => string | undefined;
|
|
253
|
+
};
|
|
254
|
+
```
|
|
219
255
|
|
|
220
|
-
|
|
256
|
+
### Client
|
|
221
257
|
|
|
222
258
|
```ts
|
|
223
|
-
|
|
259
|
+
get(path, init) · delete(path, init) · head(path, init)
|
|
260
|
+
post(path, body, init) · put(path, body, init) · patch(path, body, init)
|
|
261
|
+
request(path, { method, body, ...init })
|
|
262
|
+
use(...plugins) → new client
|
|
263
|
+
extend(config) → new client
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
All arguments after `path` are optional, except `method` in `request`.
|
|
267
|
+
|
|
268
|
+
### `ArthronError`
|
|
224
269
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
270
|
+
```ts
|
|
271
|
+
class ArthronError extends Error {
|
|
272
|
+
status: number; // 0 for network failures
|
|
273
|
+
statusText: string;
|
|
274
|
+
headers: Headers;
|
|
275
|
+
data: unknown;
|
|
276
|
+
response?: Response; // absent for network failures
|
|
277
|
+
request: RequestContext;
|
|
278
|
+
cause?: unknown; // the underlying error for network failures
|
|
279
|
+
}
|
|
229
280
|
```
|
|
230
281
|
|
|
231
|
-
|
|
282
|
+
### Rules worth knowing
|
|
283
|
+
|
|
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"`.
|
|
287
|
+
- **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.
|
|
232
289
|
|
|
233
|
-
##
|
|
290
|
+
## For tooling and agents
|
|
234
291
|
|
|
235
|
-
-
|
|
236
|
-
-
|
|
237
|
-
-
|
|
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.
|
|
292
|
+
- `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.
|
|
294
|
+
- Error messages state the fix (`… — pass baseURL in arthron({ baseURL }) or use an absolute URL`).
|
|
240
295
|
|
|
241
296
|
## License
|
|
242
297
|
|