arthron 2.0.1 → 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 +45 -506
- package/dist/index.cjs +26 -16
- package/dist/index.mjs +27 -17
- package/dist/plugins.cjs +45 -25
- package/dist/plugins.d.cts +10 -1
- package/dist/plugins.d.mts +10 -1
- package/dist/plugins.mjs +46 -26
- package/dist/shared.cjs +138 -22
- package/dist/shared.mjs +127 -23
- package/llms.txt +8 -8
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -13,16 +13,15 @@
|
|
|
13
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
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
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="
|
|
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
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
18
|
</p>
|
|
19
19
|
|
|
20
20
|
<p align="center">
|
|
21
21
|
<a href="#quick-start">Quick start</a> ·
|
|
22
|
-
<a href="
|
|
23
|
-
<a href="
|
|
24
|
-
<a href="#
|
|
25
|
-
<a href="#reference">Reference</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> ·
|
|
26
25
|
<a href="https://github.com/lapidix/arthron/blob/main/CHANGELOG.md">Changelog</a>
|
|
27
26
|
</p>
|
|
28
27
|
|
|
@@ -30,6 +29,10 @@
|
|
|
30
29
|
|
|
31
30
|
```sh
|
|
32
31
|
npm i arthron
|
|
32
|
+
# or
|
|
33
|
+
pnpm add arthron
|
|
34
|
+
# or
|
|
35
|
+
yarn add arthron
|
|
33
36
|
```
|
|
34
37
|
|
|
35
38
|
```ts
|
|
@@ -41,11 +44,14 @@ const api = arthron({
|
|
|
41
44
|
plugins: [logging(), timeout(10_000), retry()], // 10 s for the whole call, retries included
|
|
42
45
|
});
|
|
43
46
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
|
|
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;
|
|
54
|
+
}
|
|
49
55
|
}
|
|
50
56
|
```
|
|
51
57
|
|
|
@@ -53,512 +59,45 @@ Coming from 1.x? Most old names throw a `TypeError` that names the new one; the
|
|
|
53
59
|
|
|
54
60
|
## Why arthron
|
|
55
61
|
|
|
56
|
-
- **Plain `Response` in, plain `Response` out.**
|
|
57
|
-
- **Everything beyond fetch is a plugin.** Retry, timeouts, auth refresh, logging and upload progress
|
|
58
|
-
- **One error type, two causes.** `ArthronError` means the server said no
|
|
59
|
-
- **Safe by default.** Requests stay on `baseURL`'s origin
|
|
60
|
-
- **Mistakes fail loudly.**
|
|
61
|
-
- **Small and portable.** Zero dependencies
|
|
62
|
-
|
|
63
|
-
## Guide
|
|
64
|
-
|
|
65
|
-
### Requests
|
|
66
|
-
|
|
67
|
-
```ts
|
|
68
|
-
await api.get("/users", { queryParams: { page: 2 } }); // → Response
|
|
69
|
-
await api.post("/users", { name: "kim" }); // object body → JSON
|
|
70
|
-
await api.post("/upload", formData); // FormData/Blob/string/stream → sent as-is
|
|
71
|
-
await api.put("/users/1", { name: "lee" });
|
|
72
|
-
await api.patch("/users/1", { name: "lee" });
|
|
73
|
-
await api.query("/search", { filter: { tag: "x" } }); // RFC 10008 QUERY: GET semantics, body carries the query
|
|
74
|
-
await api.delete("/users/1");
|
|
75
|
-
await api.head("/users/1");
|
|
76
|
-
await api.request("/users", { method: "OPTIONS" }); // any other verb
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
**Request options.** The last argument, `requestOptions`, takes everything `fetch` does (`signal`, `cache`, `headers`, …) plus:
|
|
80
|
-
|
|
81
|
-
- `queryParams` — 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]`.
|
|
82
|
-
- `pathParams` — values for `{name}` placeholders: `api.get("/users/{id}", { pathParams: { id } })`. Each is encoded into its own segment, so an id like `../admin` or `x?role=admin` cannot change which endpoint you call (`encodeURIComponent` alone lets `..` through); `""`, `.` and `..` are refused, as are missing or unused values. Use it instead of string interpolation. Without `pathParams`, braces are left alone.
|
|
83
|
-
- `meta` — settings for plugins on this call, never sent (see [Plugins](#plugins)).
|
|
84
|
-
- `throwOn` — which non-ok responses reject on this call (see [Errors](#errors)).
|
|
85
|
-
|
|
86
|
-
A `null` (or `undefined`) header value drops a client default for that call: `api.get("/public", { headers: { authorization: null } })`.
|
|
87
|
-
|
|
88
|
-
**URLs** are joined by string concatenation: `baseURL: "https://a/v1"` + `"/users"` → `https://a/v1/users`. The `/v1` survives, unlike with `new URL()`. A query is appended with `&` if the path already has one, and placed before any `#fragment`. Where absolute URLs may go is set on the client — see [Where requests and headers go](#where-requests-and-headers-go).
|
|
89
|
-
|
|
90
|
-
**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 a `TypeError`.
|
|
91
|
-
|
|
92
|
-
**Methods.** `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). `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.
|
|
93
|
-
|
|
94
|
-
**Argument errors reject, they don't throw.** No `baseURL` with a relative path, a URL that does not 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`.
|
|
95
|
-
|
|
96
|
-
### Responses
|
|
97
|
-
|
|
98
|
-
Each call returns `Promise<Response>` with body helpers attached:
|
|
99
|
-
|
|
100
|
-
```ts
|
|
101
|
-
const response = await api.get("/me"); // Response — status, headers, body untouched
|
|
102
|
-
const user = await api.get("/me").json<User>(); // body parsed; T is a cast, not validation
|
|
103
|
-
const safe = await api.get("/me").json(UserSchema); // validated by any Standard Schema (Zod, Valibot, ArkType); typed from it
|
|
104
|
-
const text = await api.get("/robots.txt").text();
|
|
105
|
-
const blob = await api.get("/export").blob();
|
|
106
|
-
const stream = (await api.post("/chat", prompt)).body; // ReadableStream
|
|
107
|
-
```
|
|
108
|
-
|
|
109
|
-
**Validating with a schema.** `.json(schema)` takes any [Standard Schema](https://standardschema.dev) — Zod, Valibot, ArkType and others, passed as is — validates the body with it, and resolves with the schema's output (transforms included). Its type is `ArthronSchema`: `StandardSchemaV1` from `@standard-schema/spec` under arthron's name, with `ArthronSchema.Infer<S>` for the output type and `ArthronSchema.Issue` for one problem. A body that does not match rejects with a `SchemaMismatchError` 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.
|
|
110
|
-
|
|
111
|
-
**Streams.**
|
|
112
|
-
|
|
113
|
-
```ts
|
|
114
|
-
// Server-sent events — one event per `for await` step
|
|
115
|
-
for await (const { type, data } of api.post("/v1/chat/completions", body).sse()) {
|
|
116
|
-
if (data === "[DONE]") break; // provider sentinel; arthron does not interpret it
|
|
117
|
-
process.stdout.write(JSON.parse(data).choices[0].delta.content ?? "");
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Newline-delimited JSON (Ollama, many log endpoints)
|
|
121
|
-
for await (const row of api.get("/logs").ndjson<LogRow>()) console.log(row);
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
`.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 as `reconnectionTime` (ms). Each event has the names a browser `MessageEvent` uses: `type` (the `event:` field, `"message"` when there is none), `data`, `lastEventId`, plus `id`. 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.
|
|
125
|
-
|
|
126
|
-
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 `maxDataLength` (default 8 Mi characters), rejects the loop with a `RangeError` and closes the connection. Raise them per call if you expect bigger payloads: `.sse({ maxDataLength: 32 * 1024 * 1024 })`.
|
|
127
|
-
|
|
128
|
-
### Errors
|
|
129
|
-
|
|
130
|
-
```ts
|
|
131
|
-
import { isArthronError } from "arthron";
|
|
132
|
-
|
|
133
|
-
try {
|
|
134
|
-
await api.get("/users/404");
|
|
135
|
-
} catch (e) {
|
|
136
|
-
if (isArthronError(e)) {
|
|
137
|
-
e.status; // 404, or 0 when the network failed
|
|
138
|
-
e.payload; // the failed response's body, parsed: JSON, else text; undefined for a network failure
|
|
139
|
-
e.description; // the server's description of the failure, or undefined — for people
|
|
140
|
-
e.message; // arthron's own: "GET https://api.example.com/users/404 failed with 404 Not Found" — for logs
|
|
141
|
-
e.response; // the failed Response, still readable (up to 64 KiB of its body)
|
|
142
|
-
e.requestContext; // the RequestContext that was sent — url, init (method, headers), meta; read it directly, it is left out of logs
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
**`message` is for logs, `description` is for people.** `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. `description` is the server's account of the failure: by default the payload's `message`, else `error` (a string), else `error.message`, else `detail`. It is the right thing for a toast (`e.description ?? "Something went wrong"`), but untrusted — render it as text, never as HTML, and filter it if your gateway leaks internal routes in it. For an API that puts it elsewhere, pass `extractDescription: (payload) => (payload as { msg?: string }).msg` to the client; one that throws on an unexpected body leaves `description` undefined and the error intact.
|
|
148
|
-
|
|
149
|
-
**Nothing leaks through an error.** `requestContext`, `response` and `headers` (the response's) are not enumerable, and `toJSON()` returns only name, message, description, status, statusText, method, a URL without query or userinfo, and `payload`. So `JSON.stringify(e)`, `console.error(e)` and error trackers never print your tokens. Error bodies are read up to 64 KiB — enough for any error message, and a huge or endless error body cannot fill memory; past the limit `payload` is the raw text and the rest of the body is cancelled. `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.
|
|
150
|
-
|
|
151
|
-
**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: (response) => response.status !== 404 })` returns the 404 to inspect. An ok response never rejects.
|
|
152
|
-
|
|
153
|
-
**Cancellation is not an `ArthronError`.** A request that is aborted or times out rejects with the abort reason — `AbortError` / `TimeoutError`, or whatever you passed to `abort(reason)`. So does a schema mismatch (`SchemaMismatchError`, above) and anything a plugin throws.
|
|
154
|
-
|
|
155
|
-
### Clients
|
|
156
|
-
|
|
157
|
-
```ts
|
|
158
|
-
const api = arthron({
|
|
159
|
-
baseURL: "https://api.example.com", // prefix for relative paths
|
|
160
|
-
headers: { accept: "application/json" }, // defaults; per-call headers win
|
|
161
|
-
credentials: "include", // only if you need cookies cross-origin
|
|
162
|
-
cache: "no-store", // any fetch option works as a default; per-call values win
|
|
163
|
-
plugins: [retry()], // plugins, outermost first
|
|
164
|
-
});
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
Every field is optional. **Fetch defaults are flat and only sent when set:** the client takes the same fetch options a call does (`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` belong to one call: a client-wide signal would fail every call once aborted, so passing them to the client throws.
|
|
168
|
-
|
|
169
|
-
#### Where requests and headers go
|
|
170
|
-
|
|
171
|
-
| The client has | Absolute URLs may go to | Its `headers` go to |
|
|
172
|
-
|---|---|---|
|
|
173
|
-
| `baseURL` | `baseURL`'s origin, and any in `allowedOrigins` | `baseURL`'s origin only — calls to a listed origin carry per-call headers alone |
|
|
174
|
-
| `allowedOrigins`, no `baseURL` | the listed origins only | nowhere: client headers are a `TypeError` here |
|
|
175
|
-
| neither | any `http(s)` URL | every origin — keep credentials out of them |
|
|
176
|
-
|
|
177
|
-
`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. `allowedOrigins: ["https://cdn.example.com"]` lists more; only the origin of each URL counts. Only `http(s)` URLs are accepted.
|
|
178
|
-
|
|
179
|
-
**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.
|
|
180
|
-
|
|
181
|
-
**`fetchFn`** is the function requests are sent with: a fake in tests, a polyfill, a framework's `fetch`. By default it is the global `fetch`, read per request, so a global patched later (msw) is still used. A `fetchFn` you pass 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"`.
|
|
182
|
-
|
|
183
|
-
**Options are checked when the client is created.** An http(s) `baseURL` with no host (`"https://"` from an unset variable), client `headers` with `allowedOrigins` but no `baseURL`, an `allowedOrigins` entry that is not an http(s) URL, a `fetchFn` that is not a function, a 1.x option name — `arthron()` and `extend()` throw a `TypeError` right there, before any request.
|
|
184
|
-
|
|
185
|
-
#### Derive clients
|
|
186
|
-
|
|
187
|
-
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.
|
|
188
|
-
|
|
189
|
-
```ts
|
|
190
|
-
const base = arthron({ baseURL: API, plugins: [logging(), retry()] });
|
|
191
|
-
const authed = base.use(bearerAuth({ tokenOrigins: [API], accessTokenFn: () => token })); // base + auth
|
|
192
|
-
const admin = authed.extend({ headers: { "x-role": "admin" } }); // authed + header
|
|
193
|
-
```
|
|
194
|
-
|
|
195
|
-
`extend` shallow-merges the options, merges headers case-insensitively (child wins), and adds plugins the way `use` does: a plugin whose `pluginName` 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 other names append. `without("retry")` returns a client without that plugin. The built-ins are named `bearerAuth`, `logging`, `retry`, `timeout` and `uploadProgress`.
|
|
196
|
-
|
|
197
|
-
### In browsers
|
|
198
|
-
|
|
199
|
-
CORS and the engine have the last word. CI runs the built package in Chromium, Firefox and WebKit against a server on another origin.
|
|
200
|
-
|
|
201
|
-
- 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`).
|
|
202
|
-
- `retry()` reads `Retry-After` only if the API exposes it (`Access-Control-Expose-Headers: retry-after`); otherwise it waits its own delay.
|
|
203
|
-
- `redirect: "manual"` gives an opaque redirect (`status` 0, `type` `"opaqueredirect"`), returned rather than thrown.
|
|
204
|
-
- 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.
|
|
205
|
-
|
|
206
|
-
## How a call flows
|
|
207
|
-
|
|
208
|
-
```text
|
|
209
|
-
api.get(path, requestOptions)
|
|
210
|
-
│ fill pathParams · join baseURL + path + queryParams
|
|
211
|
-
│ check the origin (baseURL / allowedOrigins) · merge headers · serialize the body
|
|
212
|
-
▼
|
|
213
|
-
middleware[0] → middleware[1] → … once per call, outermost first
|
|
214
|
-
│ next(requestContext) a middleware may call it again (retry)
|
|
215
|
-
▼
|
|
216
|
-
attempt ─ request interceptors → fetchFn(url, init)
|
|
217
|
-
├─ ok, or let through by throwOn → response interceptors → Response
|
|
218
|
-
├─ !ok, or the network failed → ArthronError → error interceptors → thrown
|
|
219
|
-
└─ cancelled, or a plugin threw → passed through untouched
|
|
220
|
-
▲
|
|
221
|
-
middleware resumes after `await next(…)`, last one first
|
|
222
|
-
▼
|
|
223
|
-
ResponsePromise ─ await → Response · .json() .text() .sse() .ndjson()
|
|
224
|
-
```
|
|
225
|
-
|
|
226
|
-
1. **Before the plugins.** The URL is built, its origin checked, the client's and the call's headers merged and the body serialized. A mistake here — no `baseURL`, a body JSON would mangle, an origin the client may not call — is a rejected `TypeError`, and no plugin runs.
|
|
227
|
-
2. **Middleware** wraps the whole call, outermost first. It is called once; `await next(requestContext)` runs everything below it and resumes with the result, or the error to `catch`. A middleware may call `next` again — that is a retry — or not at all.
|
|
228
|
-
3. **An attempt** is one trip to the network: request interceptors, then `fetchFn`, then response or error interceptors. A retry runs the whole attempt again, so a refreshed token or a new trace id is picked up without extra code, and each attempt starts from a fresh copy of the context.
|
|
229
|
-
4. **Where errors come from.** Only a non-ok response (unless `throwOn` lets it through) and a failed `fetch` become an `ArthronError`, and only those reach error interceptors. Cancellation and exceptions thrown by plugins pass through untouched.
|
|
230
|
-
5. **What you get back.** The middleware unwinds, last one first, and the call resolves with the `Response` — untouched unless a response interceptor returned a new one. The body helpers sit on top of that promise.
|
|
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.
|
|
231
68
|
|
|
232
69
|
## Plugins
|
|
233
70
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
```ts
|
|
237
|
-
type Plugin = {
|
|
238
|
-
pluginName: string; // required — identity: use/extend replace the plugin of the same pluginName, without(pluginName) removes it. Not `name`: a function has one
|
|
239
|
-
middleware?: (requestContext: RequestContext, next: (requestContext: RequestContext) => Promise<Response>) => Promise<Response>;
|
|
240
|
-
requestInterceptor?: (requestContext: RequestContext) => RequestContext | Promise<RequestContext>;
|
|
241
|
-
responseInterceptor?: (response: Response, requestContext: RequestContext) => Response | Promise<Response>;
|
|
242
|
-
errorInterceptor?: (error: ArthronError, requestContext: RequestContext) => ArthronError | Promise<ArthronError>;
|
|
243
|
-
};
|
|
244
|
-
|
|
245
|
-
type RequestContext = {
|
|
246
|
-
url: string;
|
|
247
|
-
init: Omit<RequestInit, "headers"> & { headers: Record<string, string> }; // lowercase names
|
|
248
|
-
meta: ArthronMeta; // per-call plugin settings, never sent
|
|
249
|
-
fetchFn?: typeof fetch; // the function this attempt is sent with (the client's fetchFn, else the global fetch) — replace it to send another way, wrap it to observe
|
|
250
|
-
};
|
|
251
|
-
```
|
|
252
|
-
|
|
253
|
-
| | Middleware | Interceptor |
|
|
254
|
-
|---|---|---|
|
|
255
|
-
| Runs | Once per call, wrapping everything below | Once per attempt |
|
|
256
|
-
| Gets | `requestContext` and `next` | A value: context, `Response`, or `ArthronError` |
|
|
257
|
-
| Can | Call `next` again (retry), skip it, add a deadline | Return a changed value |
|
|
258
|
-
| Cannot | — | Retry, skip, or stop the error from being thrown |
|
|
259
|
-
| Examples | `retry`, `timeout`, `logging`, auth refresh, error pages | auth header, tracing id, enriching one attempt's error |
|
|
260
|
-
|
|
261
|
-
Request interceptors are not the outermost layer, as axios users may expect: they run inside every middleware, on every attempt — which is also why a `timeout` bounds a slow interceptor. `requestContext.init.headers` is always a plain record with lowercased names, restored to that shape after every request interceptor, so `{ ...requestContext.init.headers, name: value }` is safe. Response bodies are single-use: a response interceptor that wants to inspect the body must `response.clone()`; one that changes it returns a new `Response`.
|
|
262
|
-
|
|
263
|
-
### Built in
|
|
264
|
-
|
|
265
|
-
The built-in plugins come from `arthron/plugins`: `import { bearerAuth, logging, retry, timeout, uploadProgress } from "arthron/plugins"`. So do their types: `BearerAuthOptions`, `LoggingOptions`, `LogEvent`, `RetryOptions`, `TimeoutOptions`, `UploadProgressEvent`. They are plain plugins — anything they do, yours can.
|
|
266
|
-
|
|
267
|
-
| plugin | what it does | slots | per call |
|
|
268
|
-
|---|---|---|---|
|
|
269
|
-
| [`retry`](#retryoptions) | repeats a failed attempt | middleware | `meta: { retry: false \| options }` |
|
|
270
|
-
| [`timeout`](#timeoutms--timeout-total-idle-) | aborts a slow or silent call | middleware | `meta: { timeout: false \| ms \| { total?, idle? } }` |
|
|
271
|
-
| [`logging`](#loggingoptions) | one line per call start and end | middleware | — |
|
|
272
|
-
| [`bearerAuth`](#bearerauthoptions) | sends a token, refreshes on 401 | request interceptor (the header) + middleware (the refresh) | `meta: { bearerAuth: false }` |
|
|
273
|
-
| [`uploadProgress`](#uploadprogress) | reports upload progress | request interceptor (swaps `requestContext.fetchFn`) | `meta: { uploadProgress: { onProgress } }` — only calls that pass it |
|
|
274
|
-
|
|
275
|
-
Order is meaning: the first plugin is outermost. A good default:
|
|
276
|
-
|
|
277
|
-
```ts
|
|
278
|
-
plugins: [logging(), bearerAuth(…), timeout(10_000), retry()]
|
|
279
|
-
```
|
|
280
|
-
|
|
281
|
-
`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`.
|
|
282
|
-
|
|
283
|
-
Plugins with the same `pluginName` 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:
|
|
284
|
-
|
|
285
|
-
```ts
|
|
286
|
-
plugins: [{ ...timeout(30_000), pluginName: "callTimeout" }, retry(), timeout(10_000)] // 30 s in all, 10 s per attempt
|
|
287
|
-
```
|
|
288
|
-
|
|
289
|
-
#### `retry(options)`
|
|
290
|
-
|
|
291
|
-
Re-runs the attempt on a 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.
|
|
292
|
-
|
|
293
|
-
```ts
|
|
294
|
-
arthron({ baseURL: API, plugins: [retry({ retries: 3, statuses: [429, 503] })] });
|
|
295
|
-
```
|
|
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).
|
|
296
72
|
|
|
297
|
-
|
|
|
298
|
-
|
|
299
|
-
| `
|
|
300
|
-
| `
|
|
301
|
-
| `
|
|
302
|
-
| `
|
|
303
|
-
| `
|
|
304
|
-
| `shouldRetry` | — | `(error, attempt, requestContext) => boolean \| undefined`: `true` retries, `false` stops, `undefined` uses the rules above. Sees every error, an inner `TimeoutError` included |
|
|
305
|
-
| `onRetry` | — | `(error, attempt, delay) => void`, before each wait of `delay` ms |
|
|
306
|
-
|
|
307
|
-
**Per call:** `meta: { retry: false }` turns it off, and `meta: { retry: { retries: 5 } }` overrides any option.
|
|
308
|
-
|
|
309
|
-
Honours `Retry-After` (seconds or HTTP date). Never retries a `ReadableStream` body or an aborted request, never more than `retries` times, and never past a `Retry-After` longer than `maxRetryAfter` — `shouldRetry` cannot lift those. 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: (error) => (isArthronError(error) && error.status === 0 ? false : undefined)`.
|
|
310
|
-
|
|
311
|
-
#### `timeout(ms)` / `timeout({ total?, idle? })`
|
|
312
|
-
|
|
313
|
-
Aborts a call that takes too long, or goes quiet for too long. Rejects with a `TimeoutError`, which `retry` and error interceptors ignore.
|
|
314
|
-
|
|
315
|
-
```ts
|
|
316
|
-
arthron({ plugins: [retry(), timeout(5_000)] }); // 5 s per attempt — a timed-out attempt is not retried
|
|
317
|
-
arthron({ plugins: [timeout(5_000), retry()] }); // 5 s for all attempts together
|
|
318
|
-
arthron({ plugins: [timeout({ idle: 30_000 })] }); // for streams: 30 s without data
|
|
319
|
-
```
|
|
320
|
-
|
|
321
|
-
| option | default | |
|
|
322
|
-
|---|---|---|
|
|
323
|
-
| `total` | none | ms from the start of the attempt until the body has been fully read — a server that sends headers and then stalls still trips it. `timeout(ms)` is `timeout({ total: ms })` |
|
|
324
|
-
| `idle` | none | ms without data — while waiting for the headers and between chunks. A stream that keeps flowing is never cut off |
|
|
325
|
-
|
|
326
|
-
**Per call:** `meta: { timeout: false }` lifts the deadlines, `meta: { timeout: 120_000 }` replaces the total one, and `meta: { timeout: { idle: 60_000 } }` replaces the parts it sets — the same shape as `meta.retry`.
|
|
327
|
-
|
|
328
|
-
A total deadline also cuts off a stream that is still flowing; for streams, bound the silence with `idle` instead, or combine both: `timeout({ total: 600_000, idle: 30_000 })`, whichever trips first. Options with neither are a `TypeError`. `Infinity` means no deadline. It combines with a `signal` you pass yourself, and its timer never keeps a Node process alive on its own.
|
|
329
|
-
|
|
330
|
-
#### `logging(options)`
|
|
331
|
-
|
|
332
|
-
One line when a call starts and one when it ends, with the status and the total time, retries included.
|
|
333
|
-
|
|
334
|
-
```
|
|
335
|
-
[arthron] → GET https://api.example.com/users
|
|
336
|
-
[arthron] ← GET https://api.example.com/users 200 42ms
|
|
337
|
-
[arthron] ✕ GET https://api.example.com/users 503 1203ms
|
|
338
|
-
```
|
|
339
|
-
|
|
340
|
-
| option | default | |
|
|
341
|
-
|---|---|---|
|
|
342
|
-
| `enabled` | `true` | pass `process.env.NODE_ENV !== "production"` or similar — arthron never reads env |
|
|
343
|
-
| `logger` | `console` | anything with `log` and `error` |
|
|
344
|
-
| `onEvent` | — | receive `{ phase, method, url, status, duration, error }` instead of console lines: `url` without query or userinfo, `duration` in ms |
|
|
345
|
-
|
|
346
|
-
**Per call:** —. Query strings and `user:pass@` are never printed. Cancellations are not logged.
|
|
347
|
-
|
|
348
|
-
#### `bearerAuth(options)`
|
|
349
|
-
|
|
350
|
-
Bearer tokens, with a refresh on 401.
|
|
351
|
-
|
|
352
|
-
```ts
|
|
353
|
-
const api = arthron({
|
|
354
|
-
baseURL: API,
|
|
355
|
-
plugins: [
|
|
356
|
-
bearerAuth({
|
|
357
|
-
tokenOrigins: [API],
|
|
358
|
-
accessTokenFn: () => session.accessToken,
|
|
359
|
-
refreshFn: async () => {
|
|
360
|
-
const tokens = await api
|
|
361
|
-
.post("/token/refresh", undefined, { meta: { bearerAuth: false } })
|
|
362
|
-
.json<{ access_token: string }>();
|
|
363
|
-
session.accessToken = tokens.access_token;
|
|
364
|
-
},
|
|
365
|
-
}),
|
|
366
|
-
],
|
|
367
|
-
});
|
|
368
|
-
```
|
|
369
|
-
|
|
370
|
-
| option | default | |
|
|
371
|
-
|---|---|---|
|
|
372
|
-
| `tokenOrigins` | required | a list of URLs; only their origins count, so `[baseURL]` works as is — a relative one (`"/api"`) is on the page's origin. An entry that is not an http(s) URL, an empty list or one string instead of a list is a `TypeError` |
|
|
373
|
-
| `accessTokenFn` | required | `() => string \| null \| undefined`, or a promise of one; read on every attempt. No token, no header |
|
|
374
|
-
| `refreshFn` | — | `() => Promise<void>`, called after a 401 to refresh the session, so the next `accessTokenFn()` returns the new token; then the call is redone once. Without it, a 401 is thrown as is |
|
|
375
|
-
|
|
376
|
-
**Per call:** `meta: { bearerAuth: false }` sends no token and never refreshes. Use it for the login and refresh endpoints. A request `refreshFn` starts through the same client before its first `await` is sent that way on its own; one it starts after an `await` needs the meta, or its 401 would wait for the refresh it is part of.
|
|
377
|
-
|
|
378
|
-
The token goes to `tokenOrigins` 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 `refreshFn` 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.
|
|
379
|
-
|
|
380
|
-
The token is treated as opaque: to refresh a JWT ahead of its `exp`, do it inside `accessTokenFn`. 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.
|
|
381
|
-
|
|
382
|
-
#### `uploadProgress()`
|
|
383
|
-
|
|
384
|
-
Reports how much of a call's body has been sent. It does nothing by itself — only a call that passes `meta.uploadProgress` reports progress, and every other call, on the same client too, goes out exactly as before.
|
|
385
|
-
|
|
386
|
-
```ts
|
|
387
|
-
const api = arthron({ baseURL: API, plugins: [retry(), uploadProgress()] }); // last in plugins
|
|
388
|
-
|
|
389
|
-
await api.get("/me"); // as usual
|
|
390
|
-
await api.post("/files", file, {
|
|
391
|
-
meta: { uploadProgress: { onProgress: ({ fraction }) => setBar(fraction) } }, // this call only
|
|
392
|
-
});
|
|
393
|
-
```
|
|
394
|
-
|
|
395
|
-
| event field | |
|
|
396
|
-
|---|---|
|
|
397
|
-
| `loaded` | bytes sent |
|
|
398
|
-
| `total` | bytes in the body; `undefined` when unknown — a stream without `content-length` |
|
|
399
|
-
| `fraction` | `loaded / total`, from 0 to 1 — not a percentage; `undefined` with `total` |
|
|
400
|
-
|
|
401
|
-
**Per call:** `meta: { uploadProgress: { onProgress } }`, only on calls that want progress. 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.
|
|
402
|
-
|
|
403
|
-
How that call is sent depends on where it runs:
|
|
404
|
-
|
|
405
|
-
- **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"`, `credentials: "omit"` to the page's own origin (XHR always sends its cookies there), a `referrerPolicy` or `referrer` of its own (XHR sends the page's referrer) and `integrity` (XHR cannot check it). What only tunes fetch — `cache`, `priority`, `keepalive` — is ignored on that call. A `ReadableStream` body is sent as usual, without progress.
|
|
406
|
-
- **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.
|
|
407
|
-
- **Anywhere else** — Deno, Bun, Cloudflare Workers, an Electron main process, a service worker — and on a client with its own `fetchFn`, or with another plugin's: sent as usual, without progress.
|
|
408
|
-
|
|
409
|
-
An `onProgress` that throws fails the call with its own error, untouched: not an `ArthronError`, not retried. Put `uploadProgress()` last in `plugins`: a request interceptor after it that replaces `init.signal` would cut that path, and the error would come back as a network failure.
|
|
410
|
-
|
|
411
|
-
### Write your own
|
|
412
|
-
|
|
413
|
-
`createPlugin` takes a plugin and returns it, typed without annotations and checked where you write it: a missing `pluginName`, an unknown key (a typo), a 1.x `name`, `request`, `response` or `error`, a slot that is not a function or a plugin with nothing to run is a `TypeError` that says what to write. `use` and `plugins` run the same check. A plugin is a plain object: to keep state, close over it in a function that returns `createPlugin({ … })`, as the built-ins do. For a piece written on its own, `createMiddleware`, `createRequestInterceptor`, `createResponseInterceptor` and `createErrorInterceptor` type it the same way — `create` plus the type's name.
|
|
414
|
-
|
|
415
|
-
**`return await next(requestContext)` inside `try`.** Without `await`, the promise is returned before it settles, and the `catch` never sees its error.
|
|
416
|
-
|
|
417
|
-
**Per attempt or per call.** A request interceptor runs on every attempt, so it fits a value that must be fresh each time: a token, a signature with a timestamp, a span id. A value that must be the same on every attempt — an idempotency key, so the server can tell a retry from a second payment — is set once, by a middleware placed before `retry()`:
|
|
418
|
-
|
|
419
|
-
```ts
|
|
420
|
-
const idempotent = createMiddleware((requestContext, next) =>
|
|
421
|
-
next({
|
|
422
|
-
...requestContext,
|
|
423
|
-
init: {
|
|
424
|
-
...requestContext.init,
|
|
425
|
-
headers: { ...requestContext.init.headers, "idempotency-key": crypto.randomUUID() },
|
|
426
|
-
},
|
|
427
|
-
}),
|
|
428
|
-
);
|
|
429
|
-
// A client of its own, so the order is exactly this: the key first, then retry.
|
|
430
|
-
const payments = arthron({
|
|
431
|
-
baseURL: API,
|
|
432
|
-
plugins: [{ pluginName: "idempotency", middleware: idempotent }, retry({ methods: ["POST"] })],
|
|
433
|
-
});
|
|
434
|
-
```
|
|
435
|
-
|
|
436
|
-
(`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.)
|
|
437
|
-
|
|
438
|
-
**Per-call settings.** `api.get(path, { meta })` hands `meta` to every plugin as `requestContext.meta` and never sends it. Declare your own keys once and they are typed everywhere:
|
|
439
|
-
|
|
440
|
-
```ts
|
|
441
|
-
declare module "arthron" {
|
|
442
|
-
interface ArthronMeta {
|
|
443
|
-
skipTrace?: boolean;
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
const traced = api.use({
|
|
448
|
-
pluginName: "trace",
|
|
449
|
-
requestInterceptor: (requestContext) =>
|
|
450
|
-
requestContext.meta.skipTrace ? requestContext : withTraceId(requestContext),
|
|
451
|
-
});
|
|
452
|
-
traced.get("/health", { meta: { skipTrace: true } });
|
|
453
|
-
```
|
|
454
|
-
|
|
455
|
-
#### Recipes
|
|
456
|
-
|
|
457
|
-
**Attach a trace id.** A request interceptor that returns a changed copy.
|
|
458
|
-
|
|
459
|
-
```ts
|
|
460
|
-
const traced = api.use({
|
|
461
|
-
pluginName: "trace",
|
|
462
|
-
requestInterceptor: (requestContext) => ({
|
|
463
|
-
...requestContext,
|
|
464
|
-
init: {
|
|
465
|
-
...requestContext.init,
|
|
466
|
-
headers: { ...requestContext.init.headers, "x-trace-id": crypto.randomUUID() },
|
|
467
|
-
},
|
|
468
|
-
}),
|
|
469
|
-
});
|
|
470
|
-
```
|
|
471
|
-
|
|
472
|
-
**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).
|
|
473
|
-
|
|
474
|
-
```ts
|
|
475
|
-
import { createPlugin, isArthronError } from "arthron";
|
|
476
|
-
|
|
477
|
-
export const errorRedirect = (navigate: (to: string) => void) =>
|
|
478
|
-
createPlugin({
|
|
479
|
-
pluginName: "errorRedirect",
|
|
480
|
-
middleware: async (requestContext, next) => {
|
|
481
|
-
try {
|
|
482
|
-
return await next(requestContext);
|
|
483
|
-
} catch (error) {
|
|
484
|
-
if (isArthronError(error) && (requestContext.init.method ?? "GET") === "GET") {
|
|
485
|
-
if (error.status === 404) navigate("/not-found");
|
|
486
|
-
else if (error.status >= 500) navigate("/error");
|
|
487
|
-
}
|
|
488
|
-
throw error;
|
|
489
|
-
}
|
|
490
|
-
},
|
|
491
|
-
});
|
|
492
|
-
|
|
493
|
-
const api = arthron({ baseURL: API, plugins: [errorRedirect(router.push), retry()] }); // first: wraps retry
|
|
494
|
-
```
|
|
495
|
-
|
|
496
|
-
The same shape maps errors to your own types: `catch (error) { throw toDomainError(error); }` in an outermost middleware runs once per call, after retries.
|
|
497
|
-
|
|
498
|
-
**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.
|
|
499
|
-
|
|
500
|
-
## Reference
|
|
501
|
-
|
|
502
|
-
### `arthron(clientOptions)`
|
|
503
|
-
|
|
504
|
-
```ts
|
|
505
|
-
type ArthronClientOptions = {
|
|
506
|
-
baseURL?: string;
|
|
507
|
-
allowedOrigins?: readonly string[]; // other origins absolute URLs may go to (only the origin of each URL counts); calls there get per-call headers only. Without baseURL it alone limits absolute URLs, and client headers are refused
|
|
508
|
-
headers?: HeadersInit | Record<string, string | null | undefined>; // sent to baseURL's origin only; a null value drops a header (in extend)
|
|
509
|
-
fetchFn?: typeof fetch; // the function requests are sent with: a fake in tests, a polyfill, a framework's fetch; default the global fetch, read per request
|
|
510
|
-
plugins?: readonly Plugin[]; // outermost first; one with the pluginName of an earlier one replaces it
|
|
511
|
-
extractDescription?: (payload: unknown, response: Response) => string | undefined; // extracts ArthronError.description from a failed response's payload; one that throws leaves it undefined
|
|
512
|
-
throwOn?: (response: Response) => boolean; // which non-ok responses reject (also per call)
|
|
513
|
-
// …plus any fetch option as a default: credentials, cache, redirect, mode,
|
|
514
|
-
// keepalive, referrerPolicy, priority, integrity. Not body, method or signal —
|
|
515
|
-
// those belong to one request (passing them throws).
|
|
516
|
-
};
|
|
517
|
-
```
|
|
518
|
-
|
|
519
|
-
### Client
|
|
520
|
-
|
|
521
|
-
```ts
|
|
522
|
-
get(path, requestOptions) · delete(path, requestOptions) · head(path, requestOptions)
|
|
523
|
-
post(path, body, requestOptions) · put(path, body, requestOptions) · patch(path, body, requestOptions)
|
|
524
|
-
query(path, body, requestOptions) // RFC 10008 QUERY
|
|
525
|
-
request(path, { method, body, ...requestOptions })
|
|
526
|
-
use(...plugins) → new client (a plugin with the same pluginName is replaced in place)
|
|
527
|
-
extend(clientOptions) → new client
|
|
528
|
-
without(...pluginNames) → new client without those plugins
|
|
529
|
-
```
|
|
530
|
-
|
|
531
|
-
All arguments after `path` are optional, except `method` in `request`. Every method returns a `ResponsePromise`: a `Promise<Response>` with `.json()`, `.text()`, `.blob()`, `.arrayBuffer()`, `.formData()`, `.sse()` and `.ndjson()`.
|
|
532
|
-
|
|
533
|
-
### `ArthronError`
|
|
534
|
-
|
|
535
|
-
```ts
|
|
536
|
-
class ArthronError extends Error {
|
|
537
|
-
constructor(options: ArthronErrorOptions); // the fields below, for a test or a plugin that builds one
|
|
538
|
-
status: number; // 0 for network failures
|
|
539
|
-
statusText: string;
|
|
540
|
-
headers: Headers; // the response's headers; not enumerable
|
|
541
|
-
payload: unknown; // the failed response's body, parsed (JSON, else text); undefined for a network failure
|
|
542
|
-
description: string | undefined; // the server's description of the failure, extracted from payload; untrusted text
|
|
543
|
-
response?: Response; // absent for network failures; not enumerable
|
|
544
|
-
requestContext: RequestContext; // what plugins get as requestContext, as sent (url, init.method, init.headers, meta) — not a fetch Request; not enumerable, holds credentials
|
|
545
|
-
cause?: unknown; // the underlying error for network failures (query and userinfo redacted)
|
|
546
|
-
toJSON(): { name; message; description; status; statusText; method; url; payload }; // url without query/userinfo
|
|
547
|
-
}
|
|
548
|
-
```
|
|
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 |
|
|
549
80
|
|
|
550
|
-
|
|
81
|
+
## Documentation
|
|
551
82
|
|
|
552
|
-
|
|
83
|
+
Everything else is on the [documentation site](https://lapidix.github.io/arthron/):
|
|
553
84
|
|
|
554
|
-
|
|
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 |
|
|
555
95
|
|
|
556
96
|
## For agents
|
|
557
97
|
|
|
558
|
-
- **`llms.txt`**
|
|
559
|
-
- **Error messages state the fix
|
|
560
|
-
-
|
|
561
|
-
- **`CHANGELOG.md`** lists every release, the semver rules arthron follows since 1.0, and [Moving from 1.x](CHANGELOG.md#moving-from-1x).
|
|
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.
|
|
562
101
|
|
|
563
102
|
## Security
|
|
564
103
|
|