arthron 1.2.0 → 2.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 +76 -413
- package/dist/index.cjs +287 -170
- package/dist/index.d.cts +158 -115
- package/dist/index.d.mts +158 -115
- package/dist/index.mjs +269 -151
- package/dist/plugins.cjs +866 -6
- package/dist/plugins.d.cts +138 -2
- package/dist/plugins.d.mts +138 -2
- package/dist/plugins.mjs +862 -1
- package/dist/shared.cjs +751 -0
- package/dist/shared.d.cts +239 -0
- package/dist/shared.d.mts +239 -0
- package/dist/shared.mjs +602 -0
- package/llms.txt +48 -45
- package/package.json +9 -3
- package/dist/index-C3lylSNd.d.cts +0 -333
- package/dist/index-C3lylSNd.d.mts +0 -333
- package/dist/index.cjs.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/plugins-CHCMxtAE.mjs +0 -1166
- package/dist/plugins-CHCMxtAE.mjs.map +0 -1
- package/dist/plugins-Cigqz2SS.cjs +0 -1279
- package/dist/plugins-Cigqz2SS.cjs.map +0 -1
package/README.md
CHANGED
|
@@ -1,440 +1,103 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
1
|
+
<h1 align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset=".github/assets/logo/arthron-lockup-dark.svg">
|
|
4
|
+
<img alt="arthron" src=".github/assets/logo/arthron-lockup.svg" width="300">
|
|
5
|
+
</picture>
|
|
6
|
+
</h1>
|
|
7
|
+
|
|
8
|
+
<p align="center">A lightweight <code>fetch</code> client you assemble from plugins.</p>
|
|
9
|
+
|
|
10
|
+
<p align="center">
|
|
11
|
+
<a href="https://www.npmjs.com/package/arthron"><img alt="npm version" src="https://img.shields.io/npm/v/arthron?color=14b8a6"></a>
|
|
12
|
+
<a href="https://github.com/lapidix/arthron/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/lapidix/arthron/actions/workflows/ci.yml/badge.svg?branch=main"></a>
|
|
13
|
+
<a href="https://bundlejs.com/?q=arthron"><img alt="minified and gzipped size" src="https://img.shields.io/bundlejs/size/arthron?color=14b8a6"></a>
|
|
14
|
+
<a href="https://www.npmjs.com/package/arthron?activeTab=dependencies"><img alt="zero dependencies" src="https://img.shields.io/badge/dependencies-0-14b8a6"></a>
|
|
15
|
+
<a href="#quick-start"><img alt="Node 22 or later" src="https://img.shields.io/node/v/arthron?color=14b8a6"></a>
|
|
16
|
+
<a href="https://lapidix.github.io/arthron/"><img alt="TypeScript types included" src="https://img.shields.io/npm/types/arthron?color=14b8a6"></a>
|
|
17
|
+
<a href="https://github.com/lapidix/arthron/blob/main/LICENSE"><img alt="MIT license" src="https://img.shields.io/npm/l/arthron?color=14b8a6"></a>
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
<p align="center">
|
|
21
|
+
<a href="#quick-start">Quick start</a> ·
|
|
22
|
+
<a href="https://lapidix.github.io/arthron/">Documentation</a> ·
|
|
23
|
+
<a href="https://lapidix.github.io/arthron/plugins/">Plugins</a> ·
|
|
24
|
+
<a href="#for-agents">For agents</a> ·
|
|
25
|
+
<a href="https://github.com/lapidix/arthron/blob/main/CHANGELOG.md">Changelog</a>
|
|
26
|
+
</p>
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
23
29
|
|
|
24
30
|
```sh
|
|
25
31
|
npm i arthron
|
|
32
|
+
# or
|
|
33
|
+
pnpm add arthron
|
|
34
|
+
# or
|
|
35
|
+
yarn add arthron
|
|
26
36
|
```
|
|
27
37
|
|
|
28
|
-
## Usage
|
|
29
|
-
|
|
30
|
-
### Create a client
|
|
31
|
-
|
|
32
|
-
```ts
|
|
33
|
-
const api = arthron({
|
|
34
|
-
baseURL: "https://api.example.com", // prefix for relative paths
|
|
35
|
-
headers: { accept: "application/json" }, // defaults; per-call headers win
|
|
36
|
-
credentials: "include", // only if you need cookies cross-origin
|
|
37
|
-
cache: "no-store", // any fetch option works as a default; per-call values win
|
|
38
|
-
use: [retry()], // plugins, outermost first
|
|
39
|
-
});
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
Every field is optional. Without `baseURL`, only absolute URLs are accepted. With one, an absolute URL must stay on `baseURL`'s origin unless you set `allowAbsoluteUrls: true` — and even then config `headers` are only sent to that origin.
|
|
43
|
-
|
|
44
|
-
### Make requests
|
|
45
|
-
|
|
46
|
-
```ts
|
|
47
|
-
await api.get("/users", { params: { page: 2 } }); // → Response
|
|
48
|
-
await api.post("/users", { name: "kim" }); // object body → JSON
|
|
49
|
-
await api.post("/upload", formData); // FormData/Blob/string/stream → sent as-is
|
|
50
|
-
await api.put("/users/1", { name: "lee" });
|
|
51
|
-
await api.patch("/users/1", { name: "lee" });
|
|
52
|
-
await api.query("/search", { filter: { tag: "x" } }); // RFC 10008 QUERY: GET semantics, body carries the query
|
|
53
|
-
await api.delete("/users/1");
|
|
54
|
-
await api.head("/users/1");
|
|
55
|
-
await api.request("/users", { method: "OPTIONS" });
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
Each call returns `Promise<Response>` with body helpers attached:
|
|
59
|
-
|
|
60
|
-
```ts
|
|
61
|
-
const res = await api.get("/me"); // Response — status, headers, body untouched
|
|
62
|
-
const user = await api.get("/me").json<User>(); // body parsed; T is a cast, not validation
|
|
63
|
-
const safe = await api.get("/me").json(UserSchema); // validated by any Standard Schema (Zod, Valibot, ArkType); typed from it
|
|
64
|
-
const text = await api.get("/robots.txt").text();
|
|
65
|
-
const blob = await api.get("/export").blob();
|
|
66
|
-
const stream = (await api.post("/chat", prompt)).body; // ReadableStream
|
|
67
|
-
for await (const e of api.post("/chat", prompt).sse()) {} // see "Stream responses"
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
`init` accepts everything `fetch` does (`signal`, `cache`, `headers`, …) plus `params`: an object whose `null`/`undefined` values are dropped and whose arrays become repeated keys (`{ ids: [1, 2] }` → `ids=1&ids=2`), or a `URLSearchParams`. Other object values (a `Date`, a nested object) are rejected rather than sent as `[object Object]`. Put ids into the path with `path`, not string interpolation: `api.get("/users/{id}", { path: { id } })` encodes each value into its own segment, so an id like `../admin` or `x?role=admin` cannot change which endpoint you call (`encodeURIComponent` alone lets `..` through). A `null` (or `undefined`) header value drops a client default for that call: `api.get("/public", { headers: { authorization: null } })`.
|
|
71
|
-
|
|
72
|
-
`query` sends the [RFC 10008](https://www.rfc-editor.org/rfc/rfc10008) QUERY method: safe and idempotent like GET, so `retry()` repeats it by default, but the query travels in the body. Any `fetch` sends it; the server has to understand it (Node's own `http` server does from v20).
|
|
73
|
-
|
|
74
|
-
### Stream responses
|
|
75
|
-
|
|
76
|
-
```ts
|
|
77
|
-
// Server-sent events — one event per `for await` step
|
|
78
|
-
for await (const { event, data } of api.post("/v1/chat/completions", body).sse()) {
|
|
79
|
-
if (data === "[DONE]") break; // provider sentinel; arthron does not interpret it
|
|
80
|
-
process.stdout.write(JSON.parse(data).choices[0].delta.content ?? "");
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// Newline-delimited JSON (Ollama, many log endpoints)
|
|
84
|
-
for await (const row of api.get("/logs").ndjson<LogRow>()) console.log(row);
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
`.sse()` parses `text/event-stream` the way the EventSource spec does: multi-line `data:` joined with `\n`, comments ignored, dispatch on a blank line, an event the stream ends inside dropped, `lastEventId` carried across events, `retry` accepted as digits only and carried on. It does not reconnect — to resume, send `headers: { "last-event-id": e.lastEventId }` on a new request. Both helpers honour `signal` and `timeout` while the body is being read, and leaving the loop early (`break`, `return`, a throw) closes the connection.
|
|
88
|
-
|
|
89
|
-
A stream is untrusted input, so both helpers have limits: a line longer than `maxLineLength` (default 8 Mi characters), or an SSE event whose `data` outgrows `maxEventSize` (default 8 Mi), rejects the loop with a `RangeError` and closes the connection. Raise them per call if you expect bigger payloads: `.sse({ maxEventSize: 32 * 1024 * 1024 })`.
|
|
90
|
-
|
|
91
|
-
### Handle errors
|
|
92
|
-
|
|
93
|
-
```ts
|
|
94
|
-
import { isArthronError } from "arthron";
|
|
95
|
-
|
|
96
|
-
try {
|
|
97
|
-
await api.get("/users/404");
|
|
98
|
-
} catch (e) {
|
|
99
|
-
if (isArthronError(e)) {
|
|
100
|
-
e.status; // 404, or 0 when the network failed
|
|
101
|
-
e.data; // parsed body: JSON, else text, else undefined
|
|
102
|
-
e.detail; // the server's explanation (`message` → `error` → `error.message` → `detail`), or undefined — show this to users
|
|
103
|
-
e.message; // arthron's own: "GET https://api.example.com/users/404 failed with 404 Not Found" — for logs
|
|
104
|
-
e.response; // the failed Response, still readable (up to 64 KiB of its body)
|
|
105
|
-
e.request; // what was sent (url, init) — read it directly; it is left out of logs
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
`isArthronError(e)` and `e instanceof ArthronError` work across copies of the package — the ESM and CJS builds loaded side by side, a CJS dependency's client caught by your ESM code. `request`, `response` and `headers` are not enumerable, and `toJSON()` returns only name, message, detail, status, method, a URL without query or userinfo, and `data`. So `JSON.stringify(e)`, `console.error(e)` and error trackers never print your tokens.
|
|
111
|
-
|
|
112
|
-
`message` and `detail` are kept apart on purpose. `message` is written by arthron — method, URL without query, status — so a log line or an error tracker says which request failed, and never carries text the server chose. `detail` is what the server said: the right thing for a toast (`e.detail ?? "Something went wrong"`), but untrusted — render it as text, never as HTML, and filter it if your gateway leaks internal routes in it.
|
|
113
|
-
|
|
114
|
-
Not every non-ok response is a failure. An opaque response (`mode: "no-cors"`) and the 3xx of `redirect: "manual"` come back as a `Response`, since you asked for them. `throwOn` picks which other non-ok responses reject, for the client or one call: `api.get("/maybe", { throwOn: (res) => res.status !== 404 })` returns the 404 to inspect. An ok response never rejects.
|
|
115
|
-
|
|
116
|
-
`.json(schema)` takes any [Standard Schema](https://standardschema.dev) — Zod, Valibot, ArkType and others — validates the body with it, and resolves with the schema's output (transforms included). A body that does not match rejects with a `SchemaError` whose `issues` list each problem and its path. It is not an `ArthronError`: the request worked, the data did not. arthron stays dependency-free; the schema library is yours.
|
|
117
|
-
|
|
118
|
-
A request that is aborted or times out rejects with the abort reason — `AbortError` / `TimeoutError`, or whatever you passed to `abort(reason)` — not an `ArthronError`.
|
|
119
|
-
|
|
120
|
-
### Derive clients
|
|
121
|
-
|
|
122
|
-
Clients are immutable. `use`, `extend` and `without` return a new client and leave the original alone — so keep what they return. `api.use(plugin)` on its own line does nothing.
|
|
123
|
-
|
|
124
|
-
```ts
|
|
125
|
-
const base = arthron({ baseURL: API, use: [logging(), retry()] });
|
|
126
|
-
const authed = base.use(bearerAuth({ origin: API, token })); // base + auth
|
|
127
|
-
const admin = authed.extend({ headers: { "x-role": "admin" } }); // authed + header
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
`extend` shallow-merges the config, merges headers case-insensitively (child wins), and adds plugins the way `use` does: a plugin whose `name` is already there replaces it in place — so `streaming = base.use(timeout(120_000))` loosens the inherited `timeout`, and a second `retry()` does not multiply attempts — while unnamed plugins append. `without("retry")` returns a client without that plugin. The built-ins are named `bearerAuth`, `logging`, `retry`, `timeout` and `uploadProgress`.
|
|
131
|
-
|
|
132
|
-
## Plugins
|
|
133
|
-
|
|
134
|
-
A plugin is an object with any of four slots:
|
|
135
|
-
|
|
136
|
-
```ts
|
|
137
|
-
type Plugin = {
|
|
138
|
-
name?: string; // identity: use/extend replace the plugin of the same name, without(name) removes it
|
|
139
|
-
middleware?: (ctx: RequestContext, next: (ctx: RequestContext) => Promise<Response>) => Promise<Response>;
|
|
140
|
-
request?: (ctx: RequestContext) => RequestContext | Promise<RequestContext>;
|
|
141
|
-
response?: (res: Response, ctx: RequestContext) => Response | Promise<Response>;
|
|
142
|
-
error?: (err: ArthronError, ctx: RequestContext) => ArthronError | Promise<ArthronError>;
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
type RequestContext = {
|
|
146
|
-
url: string;
|
|
147
|
-
init: Omit<RequestInit, "headers"> & { headers: Record<string, string> }; // lowercase names
|
|
148
|
-
meta: ArthronMeta; // per-call plugin settings, never sent
|
|
149
|
-
fetch?: typeof fetch; // the fetch this attempt is sent with — replace it to change the transport, wrap it to observe
|
|
150
|
-
};
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
How one request runs, with `use: [m1, m2]`:
|
|
154
|
-
|
|
155
|
-
```
|
|
156
|
-
call → m1 (before next) → m2 (before next) ─┐
|
|
157
|
-
│ attempt — runs again each time a middleware calls next again
|
|
158
|
-
│ request interceptors → fetch → response interceptors
|
|
159
|
-
│ ↘ error interceptors → throw
|
|
160
|
-
call ← m1 (after next) ← m2 (after next) ←─┘
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
A middleware is called once. `await next(ctx)` runs everything inside it and resumes the same call with the result — or the error, to `catch`. Request interceptors are not the outermost layer, as axios users may expect: they run inside every middleware, on every attempt. That is why a retry sends a refreshed token or a new trace id without extra code, and why a `timeout` also bounds a slow interceptor.
|
|
164
|
-
|
|
165
|
-
| | Middleware | Interceptor |
|
|
166
|
-
|---|---|---|
|
|
167
|
-
| Runs | Once per call, wrapping everything below | Once per attempt |
|
|
168
|
-
| Gets | `ctx` and `next` | A value: context, `Response`, or `ArthronError` |
|
|
169
|
-
| Can | Call `next` again (retry), skip it, add a deadline | Return a changed value |
|
|
170
|
-
| Cannot | — | Retry, skip, or stop the error from being thrown |
|
|
171
|
-
| Examples | `retry`, `timeout`, `logging`, auth refresh, error pages | auth header, tracing id, per-attempt error details |
|
|
172
|
-
|
|
173
|
-
Every attempt starts from a fresh copy of the context, so a header set by an interceptor on attempt one does not leak into attempt two. `ctx.init.headers` is always a plain record with lowercased names, restored to that shape after every request interceptor, so `{ ...ctx.init.headers, name: value }` is safe.
|
|
174
|
-
|
|
175
|
-
### Built in
|
|
176
|
-
|
|
177
|
-
The built-in plugins come from `arthron/plugins`: `import { bearerAuth, logging, retry, timeout, uploadProgress } from "arthron/plugins"`. They are plain plugins — anything they do, yours can. `logging`, `retry` and `timeout` are also exported from `arthron` itself, as they were in 1.0; those exports are deprecated and go in the next major.
|
|
178
|
-
|
|
179
|
-
| plugin | slots | per call |
|
|
180
|
-
|---|---|---|
|
|
181
|
-
| `bearerAuth` | request interceptor (the header) + middleware (refresh on 401) | `meta: { bearerAuth: false }` |
|
|
182
|
-
| `retry` | middleware | `meta: { retry: false \| options }` |
|
|
183
|
-
| `timeout` | middleware | `meta: { timeout: ms }` |
|
|
184
|
-
| `logging` | middleware | — |
|
|
185
|
-
| `uploadProgress` | request interceptor (picks the transport) | `meta: { onUploadProgress }` — only calls that pass it |
|
|
186
|
-
|
|
187
|
-
Order is meaning: the first plugin is outermost. A good default:
|
|
188
|
-
|
|
189
|
-
```ts
|
|
190
|
-
use: [logging(), bearerAuth(…), timeout(10_000), retry()]
|
|
191
|
-
```
|
|
192
|
-
|
|
193
|
-
`logging` first logs each call once — its start, and its end with the total time, retries included. A `timeout` before `retry` bounds the whole call; after it, each attempt — and a timed-out attempt is not retried. `bearerAuth` sets its header on every attempt wherever it sits. Anything that should see only the final outcome — an error page, mapping to domain errors — goes before `retry`.
|
|
194
|
-
|
|
195
|
-
Plugins with the same name replace each other, so a second `timeout()` takes the first one's place instead of adding a deadline. For both a total and a per-attempt deadline, rename one:
|
|
196
|
-
|
|
197
38
|
```ts
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
#### `retry(options)`
|
|
202
|
-
|
|
203
|
-
Re-runs the attempt on network failure or a retryable status. Because it re-runs the whole attempt, request interceptors run again — a refreshed token or a new trace id is picked up automatically.
|
|
204
|
-
|
|
205
|
-
| option | default | |
|
|
206
|
-
|---|---|---|
|
|
207
|
-
| `retries` | `2` | retries after the first attempt (3 requests total); a whole number ≥ 0 |
|
|
208
|
-
| `retryOn` | `[408, 429, 500, 502, 503, 504]` | network failures (`status: 0`) are always retried |
|
|
209
|
-
| `methods` | `GET HEAD OPTIONS QUERY PUT DELETE` | case-insensitive |
|
|
210
|
-
| `delay` | `(retry) => Math.random() * 300 * 2 ** retry` | ms; `retry` counts from 0. Full jitter, so clients that failed together don't retry together |
|
|
211
|
-
| `maxRetryAfter` | `60_000` | a `Retry-After` longer than this means "don't retry" |
|
|
212
|
-
| `shouldRetry` | — | `(error, attempt, ctx) => boolean \| undefined`: `true` retries, `false` stops, `undefined` uses the rules above. Sees every error, an inner `TimeoutError` included |
|
|
213
|
-
| `onRetry` | — | `(error, attempt, waitMs) => void`, before each wait |
|
|
214
|
-
|
|
215
|
-
Honours `Retry-After` (seconds or HTTP date). Never retries a `ReadableStream` body or an aborted request, and never more than `retries` times — `shouldRetry` cannot lift those.
|
|
216
|
-
|
|
217
|
-
Opting a non-idempotent method in (`methods: ["POST"]`) means a network failure is retried too, and a dropped connection may already have reached the server. Where a duplicate would hurt — a payment, an LLM completion you pay for — keep retries to statuses: `shouldRetry: (e) => (isArthronError(e) && e.status === 0 ? false : undefined)`. Per call, `meta: { retry: false }` turns it off and `meta: { retry: { retries: 5 } }` overrides any option but `sleep`.
|
|
218
|
-
|
|
219
|
-
#### `timeout(ms)` / `timeout({ total?, idle? })`
|
|
220
|
-
|
|
221
|
-
Aborts the attempt after `ms`, counted until the body has been fully read — a server that sends headers and then stalls still trips it. Rejects with a `TimeoutError`, which `retry` and error interceptors ignore. Combines with a `signal` you pass yourself. `Infinity` means no deadline. The timer never keeps a Node process alive on its own. Per call, `meta: { timeout: 120_000 }` replaces the deadline (`Infinity` lifts it).
|
|
222
|
-
|
|
223
|
-
A total deadline also cuts off a stream that is still flowing. For streams, bound the silence instead: `timeout({ idle: 30_000 })` aborts only after 30 s without data — waiting for headers or between chunks — however long the stream runs. Both can be combined: `timeout({ total: 600_000, idle: 30_000 })`; `timeout(ms)` is `timeout({ total: ms })`. Options with neither are a `TypeError`.
|
|
224
|
-
|
|
225
|
-
`timeout(ms, { idle })` still works but is deprecated; it goes in the next major.
|
|
226
|
-
|
|
227
|
-
```ts
|
|
228
|
-
arthron({ use: [retry(), timeout(5_000)] }); // 5 s per attempt — a timed-out attempt is not retried
|
|
229
|
-
arthron({ use: [timeout(5_000), retry()] }); // 5 s for all attempts together
|
|
230
|
-
```
|
|
231
|
-
|
|
232
|
-
#### `logging(options)`
|
|
233
|
-
|
|
234
|
-
```
|
|
235
|
-
[arthron] → GET https://api.example.com/users
|
|
236
|
-
[arthron] ← GET https://api.example.com/users 200 42ms
|
|
237
|
-
[arthron] ✕ GET https://api.example.com/users 503 1203ms
|
|
238
|
-
```
|
|
239
|
-
|
|
240
|
-
| option | default | |
|
|
241
|
-
|---|---|---|
|
|
242
|
-
| `enabled` | `true` | pass `process.env.NODE_ENV !== "production"` or similar — arthron never reads env |
|
|
243
|
-
| `logger` | `console` | anything with `log` and `error` |
|
|
244
|
-
| `onEvent` | — | receive `{ phase, method, path, status, ms, error }` instead of console lines |
|
|
245
|
-
|
|
246
|
-
Query strings and `user:pass@` are never printed. Cancellations are not logged.
|
|
247
|
-
|
|
248
|
-
#### `bearerAuth(options)`
|
|
249
|
-
|
|
250
|
-
Bearer tokens, with a refresh on 401.
|
|
39
|
+
import { arthron, isArthronError } from "arthron";
|
|
40
|
+
import { logging, retry, timeout } from "arthron/plugins";
|
|
251
41
|
|
|
252
|
-
```ts
|
|
253
42
|
const api = arthron({
|
|
254
|
-
baseURL:
|
|
255
|
-
|
|
256
|
-
bearerAuth({
|
|
257
|
-
origin: API,
|
|
258
|
-
token: () => session.accessToken,
|
|
259
|
-
refresh: async () => {
|
|
260
|
-
const res = await api.post("/token/refresh", undefined, { meta: { bearerAuth: false } }).json<{ access_token: string }>();
|
|
261
|
-
session.accessToken = res.access_token;
|
|
262
|
-
},
|
|
263
|
-
}),
|
|
264
|
-
],
|
|
265
|
-
});
|
|
266
|
-
```
|
|
267
|
-
|
|
268
|
-
| option | default | |
|
|
269
|
-
|---|---|---|
|
|
270
|
-
| `origin` | required | a URL or several; only their origins count, so `baseURL` works as is. Anything that is not an http(s) URL is a `TypeError` |
|
|
271
|
-
| `token` | required | `() => string \| null \| undefined`, or a promise of one; read on every attempt. No token, no header |
|
|
272
|
-
| `refresh` | — | `() => Promise<void>`, called after a 401; then the call is redone once. Without it, a 401 is thrown as is |
|
|
273
|
-
|
|
274
|
-
The token goes to `origin` only — never to a CDN or a URL taken from a response body. Concurrent 401s share one refresh: with refresh-token rotation, parallel refreshes would revoke each other and log the user out. A call whose token was already replaced by another call's refresh retries without refreshing again. A second 401 is thrown, and so is a failed refresh — every call waiting on it rejects with the error `refresh` threw, so that is the place to send the user to a login page. The 401 of a `ReadableStream` body is thrown without a refresh: the first try has read the stream, so there is nothing to resend.
|
|
275
|
-
|
|
276
|
-
Per call, `meta: { bearerAuth: false }` sends no token and never refreshes. Use it for the login and refresh endpoints: a refresh request that went through `bearerAuth()` would, on its own 401, wait for itself.
|
|
277
|
-
|
|
278
|
-
The token is treated as opaque: to refresh a JWT ahead of its `exp`, do it inside `token()`. It is not for cookie sessions: it tells whether another call already refreshed by comparing the `authorization` header a call sent with the current one, and an httpOnly cookie cannot be read to compare.
|
|
279
|
-
|
|
280
|
-
#### `uploadProgress()`
|
|
281
|
-
|
|
282
|
-
Does nothing by itself — only a call that passes `meta.onUploadProgress` reports progress. Every other call, on the same client too, goes out exactly as before.
|
|
283
|
-
|
|
284
|
-
```ts
|
|
285
|
-
const api = arthron({ baseURL: API, use: [retry(), uploadProgress()] }); // last in use
|
|
286
|
-
|
|
287
|
-
await api.get("/me"); // as usual
|
|
288
|
-
await api.post("/files", file, {
|
|
289
|
-
meta: { onUploadProgress: ({ loaded, total, progress }) => setBar(progress) }, // this call only
|
|
43
|
+
baseURL: "https://api.example.com",
|
|
44
|
+
plugins: [logging(), timeout(10_000), retry()], // 10 s for the whole call, retries included
|
|
290
45
|
});
|
|
291
|
-
```
|
|
292
|
-
|
|
293
|
-
The event: `loaded` (bytes sent), `total` (bytes in the body, `undefined` when unknown — a stream without `content-length`), `progress` (`loaded / total` from 0 to 1, `undefined` with `total`). Progress counts what was handed to the network, so it reaches 1 a moment before the server has it all; the response arriving is the end. Each attempt counts from 0 again, so a retried upload starts the bar over.
|
|
294
|
-
|
|
295
|
-
How that call is sent depends on where it runs:
|
|
296
|
-
|
|
297
|
-
- **Browser:** over `XMLHttpRequest`, the only browser API that reports upload progress. On another origin, the upload listener makes every such call send a CORS preflight — an S3 bucket's CORS rules must allow the method and headers. The response arrives whole: `.sse()` / `.ndjson()` are not live on that call. What XHR cannot honour and would change the call is refused with a `TypeError` before sending: `redirect: "manual" | "error"`, `mode: "no-cors"`, and `credentials: "omit"` to the page's own origin (XHR always sends its cookies there). What only tunes fetch — `cache`, `priority`, `referrerPolicy`, `integrity`, `keepalive` — is ignored on that call. A `ReadableStream` body is sent as usual, without progress.
|
|
298
|
-
- **Node:** with `fetch` as usual, the body counted as it is read, in steps of at most 64 KiB. `content-length` and the content type a `Blob` would set are kept, so an S3 presigned `PUT` accepts it. A `FormData` body is encoded with its length known up front, its files streamed rather than read into memory. jsdom on Node counts the same way.
|
|
299
|
-
- **Anywhere else** — Deno, Bun, Cloudflare Workers, an Electron main process, a service worker — and on a client with an injected `fetch` or another plugin's transport: sent as usual, without progress.
|
|
300
|
-
|
|
301
|
-
A callback that throws fails the call with its own error, untouched: not an `ArthronError`, not retried. Put `uploadProgress()` last in `use`: a request interceptor after it that replaces `init.signal` would cut that path, and the error would come back as a network failure.
|
|
302
46
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
```ts
|
|
312
|
-
const idempotent = defineMiddleware((ctx, next) =>
|
|
313
|
-
next({ ...ctx, init: { ...ctx.init, headers: { ...ctx.init.headers, "idempotency-key": crypto.randomUUID() } } }),
|
|
314
|
-
);
|
|
315
|
-
// A client of its own, so the order is exactly this: the key first, then retry.
|
|
316
|
-
const payments = arthron({ baseURL: API, use: [{ name: "idempotency", middleware: idempotent }, retry({ methods: ["POST"] })] });
|
|
317
|
-
```
|
|
318
|
-
|
|
319
|
-
(`api.use(…)` would not do here if `api` already has a `retry()`: the new one takes the old one's place, which may be before the key.)
|
|
320
|
-
|
|
321
|
-
**Per-call settings.** `api.get(path, { meta })` hands `meta` to every plugin as `ctx.meta` and never sends it. Declare your own keys once and they are typed everywhere:
|
|
322
|
-
|
|
323
|
-
```ts
|
|
324
|
-
declare module "arthron" {
|
|
325
|
-
interface ArthronMeta {
|
|
326
|
-
skipTrace?: boolean;
|
|
47
|
+
async function getUser(id: string) {
|
|
48
|
+
try {
|
|
49
|
+
return await api.get("/users/{id}", { pathParams: { id } }).json<User>();
|
|
50
|
+
} catch (e) {
|
|
51
|
+
// ArthronError: a 4xx/5xx or a network failure. Cancellation and plugin bugs are not.
|
|
52
|
+
if (isArthronError(e) && e.status === 404) return null;
|
|
53
|
+
throw e;
|
|
327
54
|
}
|
|
328
55
|
}
|
|
329
|
-
|
|
330
|
-
const traced = api.use({ request: (ctx) => (ctx.meta.skipTrace ? ctx : withTraceId(ctx)) });
|
|
331
|
-
traced.get("/health", { meta: { skipTrace: true } });
|
|
332
|
-
```
|
|
333
|
-
|
|
334
|
-
**Refresh a session on 401, then redo the request.** That is the built-in [`bearerAuth()`](#bearerauthoptions), and a model for your own: the refresh is flow, so it is middleware; the header is a value, so it is a request interceptor — and because `next` re-runs the interceptors, the redone request carries the new token without extra code. Read [`src/plugins/bearer-auth/bearer-auth.ts`](src/plugins/bearer-auth/bearer-auth.ts) before writing a variant for another scheme.
|
|
335
|
-
|
|
336
|
-
**Send users to an error page.** This reacts to how the *call* ended, so it is middleware placed before `retry()`: it sees only the final result. An error interceptor would run on every failed attempt and navigate away even when the next retry succeeds. Error interceptors are for enriching the error of one attempt. Tested in [`examples/error-page.ts`](examples/error-page.ts).
|
|
337
|
-
|
|
338
|
-
```ts
|
|
339
|
-
import { definePlugin, isArthronError } from "arthron";
|
|
340
|
-
|
|
341
|
-
export const errorRedirect = (navigate: (to: string) => void) =>
|
|
342
|
-
definePlugin({
|
|
343
|
-
name: "errorRedirect",
|
|
344
|
-
middleware: async (ctx, next) => {
|
|
345
|
-
try {
|
|
346
|
-
return await next(ctx);
|
|
347
|
-
} catch (error) {
|
|
348
|
-
if (isArthronError(error) && (ctx.init.method ?? "GET") === "GET") {
|
|
349
|
-
if (error.status === 404) navigate("/not-found");
|
|
350
|
-
else if (error.status >= 500) navigate("/error");
|
|
351
|
-
}
|
|
352
|
-
throw error;
|
|
353
|
-
}
|
|
354
|
-
},
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
const api = arthron({ baseURL: API, use: [errorRedirect(router.push), retry()] }); // first: wraps retry
|
|
358
56
|
```
|
|
359
57
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
**Attach a trace id.** A one-line request interceptor.
|
|
58
|
+
Coming from 1.x? Most old names throw a `TypeError` that names the new one; the full list is in [Moving from 1.x](CHANGELOG.md#moving-from-1x). Writing code with an AI agent? Point it at `llms.txt`, which ships in the package — see [For agents](#for-agents).
|
|
363
59
|
|
|
364
|
-
|
|
365
|
-
const traced = api.use({ request: (ctx) => ({ ...ctx, init: { ...ctx.init, headers: { ...ctx.init.headers, "x-trace-id": crypto.randomUUID() } } }) });
|
|
366
|
-
```
|
|
367
|
-
|
|
368
|
-
## Reference
|
|
60
|
+
## Why arthron
|
|
369
61
|
|
|
370
|
-
|
|
62
|
+
- **Plain `Response` in, plain `Response` out.** `await api.get(…)` is the `Response` fetch gave; `.json()`, `.sse()` and friends read the body when you want it.
|
|
63
|
+
- **Everything beyond fetch is a plugin.** Retry, timeouts, auth refresh, logging and upload progress use the same API yours do.
|
|
64
|
+
- **One error type, two causes.** `ArthronError` means the server said no or the network failed; anything else is rethrown as it is.
|
|
65
|
+
- **Safe by default.** Requests and the client's headers stay on `baseURL`'s origin, path values cannot escape their segment, and error messages omit URL query strings and userinfo. Error serialization excludes request headers and bodies; server-provided error payloads may still contain secrets.
|
|
66
|
+
- **Mistakes fail loudly.** Common configuration mistakes, bodies that cannot be safely serialized as JSON, and most 1.x names produce a `TypeError` that says what to change.
|
|
67
|
+
- **Small and portable.** Zero dependencies, ESM and CJS, TypeScript 5.0+, Node 22+ and browsers.
|
|
371
68
|
|
|
372
|
-
|
|
373
|
-
type ArthronConfig = {
|
|
374
|
-
baseURL?: string;
|
|
375
|
-
allowAbsoluteUrls?: boolean; // let absolute URLs leave baseURL's origin (without config headers)
|
|
376
|
-
headers?: HeadersOption; // anything fetch's headers take, or a record whose null drops a header (in extend)
|
|
377
|
-
fetch?: typeof fetch; // inject for tests or a polyfill; resolved per request
|
|
378
|
-
use?: Plugin[];
|
|
379
|
-
errorDetail?: (data: unknown, res: Response) => string | undefined; // picks ArthronError.detail out of the body
|
|
380
|
-
throwOn?: (res: Response) => boolean; // which non-ok responses reject (also per call)
|
|
381
|
-
// …plus any fetch option as a default: credentials, cache, redirect, mode,
|
|
382
|
-
// keepalive, referrerPolicy, priority, integrity. Not body, method or signal —
|
|
383
|
-
// those belong to one request (passing them throws).
|
|
384
|
-
};
|
|
385
|
-
```
|
|
386
|
-
|
|
387
|
-
### Client
|
|
388
|
-
|
|
389
|
-
```ts
|
|
390
|
-
get(path, init) · delete(path, init) · head(path, init)
|
|
391
|
-
post(path, body, init) · put(path, body, init) · patch(path, body, init)
|
|
392
|
-
query(path, body, init) // RFC 10008 QUERY
|
|
393
|
-
request(path, { method, body, ...init })
|
|
394
|
-
use(...plugins) → new client (same-name plugins replaced in place)
|
|
395
|
-
extend(config) → new client
|
|
396
|
-
without(...names) → new client without those plugins
|
|
397
|
-
```
|
|
398
|
-
|
|
399
|
-
All arguments after `path` are optional, except `method` in `request`. `request` is the way to any other verb; its `method` autocompletes the standard ones, and those go out uppercased whatever case you write them in (fetch itself leaves `"patch"` lowercase, which servers answer with 405). Other method names are sent as written.
|
|
69
|
+
## Plugins
|
|
400
70
|
|
|
401
|
-
The
|
|
71
|
+
The built-ins come from `arthron/plugins`. They are plain plugins: anything they do, [yours can](https://lapidix.github.io/arthron/plugins/write-your-own).
|
|
402
72
|
|
|
403
|
-
|
|
73
|
+
| Plugin | What it does |
|
|
74
|
+
| --- | --- |
|
|
75
|
+
| [`retry`](https://lapidix.github.io/arthron/plugins/retry) | Repeats a failed attempt |
|
|
76
|
+
| [`timeout`](https://lapidix.github.io/arthron/plugins/timeout) | Aborts a slow or silent call |
|
|
77
|
+
| [`logging`](https://lapidix.github.io/arthron/plugins/logging) | Logs the start and, unless cancelled, the outcome of a call |
|
|
78
|
+
| [`bearerAuth`](https://lapidix.github.io/arthron/plugins/bearer-auth) | Sends a token, refreshes on 401 |
|
|
79
|
+
| [`uploadProgress`](https://lapidix.github.io/arthron/plugins/upload-progress) | Reports upload progress |
|
|
404
80
|
|
|
405
|
-
|
|
406
|
-
class ArthronError extends Error {
|
|
407
|
-
status: number; // 0 for network failures
|
|
408
|
-
statusText: string;
|
|
409
|
-
headers: Headers; // not enumerable
|
|
410
|
-
data: unknown;
|
|
411
|
-
detail?: string; // the server's explanation, picked out of data; untrusted text
|
|
412
|
-
response?: Response; // absent for network failures; not enumerable
|
|
413
|
-
request: RequestContext; // not enumerable — holds credentials
|
|
414
|
-
cause?: unknown; // the underlying error for network failures
|
|
415
|
-
toJSON(): { name; message; detail; status; statusText; method; url; data }; // url without query/userinfo
|
|
416
|
-
}
|
|
417
|
-
```
|
|
81
|
+
## Documentation
|
|
418
82
|
|
|
419
|
-
|
|
83
|
+
Everything else is on the [documentation site](https://lapidix.github.io/arthron/):
|
|
420
84
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
85
|
+
| Page | What it covers |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| [Requests](https://lapidix.github.io/arthron/guide/requests) | Methods, request options, URLs and bodies |
|
|
88
|
+
| [Responses](https://lapidix.github.io/arthron/guide/responses) | Body helpers, schema validation and streams |
|
|
89
|
+
| [Errors](https://lapidix.github.io/arthron/guide/errors) | `ArthronError`, what to show and log, and `throwOn` |
|
|
90
|
+
| [Clients](https://lapidix.github.io/arthron/guide/clients) | Client options, where requests and headers go, and deriving clients |
|
|
91
|
+
| [In browsers](https://lapidix.github.io/arthron/guide/browsers) | CORS, redirects and streaming bodies |
|
|
92
|
+
| [How a call flows](https://lapidix.github.io/arthron/guide/call-flow) | What happens between `api.get(…)` and the `Response` |
|
|
93
|
+
| [Middleware and interceptors](https://lapidix.github.io/arthron/plugins/middleware-and-interceptors) | The two kinds of plugin slots, and which to use |
|
|
94
|
+
| [Moving from 1.x](CHANGELOG.md#moving-from-1x) | Every 1.x name and its 2.0 one |
|
|
431
95
|
|
|
432
|
-
## For
|
|
96
|
+
## For agents
|
|
433
97
|
|
|
434
|
-
-
|
|
435
|
-
-
|
|
436
|
-
- `examples/` holds type-checked, runnable recipes: LLM streaming, auth refresh, an error page, upload
|
|
437
|
-
- Error messages state the fix (`… — pass baseURL in arthron({ baseURL }) or use an absolute URL`).
|
|
98
|
+
- **`llms.txt`** is the whole API in one compressed file, for LLM-based tools. It ships in the package (`node_modules/arthron/llms.txt`) and is served at [https://lapidix.github.io/arthron/llms.txt](https://lapidix.github.io/arthron/llms.txt).
|
|
99
|
+
- **Error messages state the fix**, and most 1.x names throw a `TypeError` naming the new one, so an agent working from an old example is told what to change.
|
|
100
|
+
- **[`examples/`](examples)** holds type-checked, runnable recipes: LLM streaming, auth refresh, an error page, upload and download.
|
|
438
101
|
|
|
439
102
|
## Security
|
|
440
103
|
|