arthron 1.0.0 → 1.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 +101 -51
- package/dist/index-Bz1P80JN.d.cts +293 -0
- package/dist/index-Bz1P80JN.d.mts +293 -0
- package/dist/index.cjs +78 -653
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +16 -238
- package/dist/index.d.mts +16 -238
- package/dist/index.mjs +70 -645
- package/dist/index.mjs.map +1 -1
- package/dist/plugins-CVrzb8x6.cjs +864 -0
- package/dist/plugins-CVrzb8x6.cjs.map +1 -0
- package/dist/plugins-ChNZ2Q8t.mjs +757 -0
- package/dist/plugins-ChNZ2Q8t.mjs.map +1 -0
- package/dist/plugins.cjs +6 -0
- package/dist/plugins.d.cts +2 -0
- package/dist/plugins.d.mts +2 -0
- package/dist/plugins.mjs +1 -0
- package/llms.txt +13 -14
- package/package.json +14 -1
- package/plugins/package.json +6 -0
package/README.md
CHANGED
|
@@ -3,20 +3,21 @@
|
|
|
3
3
|
A `fetch` client you assemble from plugins.
|
|
4
4
|
|
|
5
5
|
```ts
|
|
6
|
-
import { arthron
|
|
6
|
+
import { arthron } from "arthron";
|
|
7
|
+
import { logging, retry, timeout } from "arthron/plugins";
|
|
7
8
|
|
|
8
9
|
const api = arthron({
|
|
9
10
|
baseURL: "https://api.example.com",
|
|
10
|
-
use: [logging(), timeout(10_000), retry()],
|
|
11
|
+
use: [logging(), timeout(10_000), retry()], // 10 s for the whole call, retries included
|
|
11
12
|
});
|
|
12
13
|
|
|
13
14
|
const user = await api.get("/me").json<User>();
|
|
14
15
|
```
|
|
15
16
|
|
|
16
17
|
- **Plain `Response` in, plain `Response` out.** No wrapper type to learn. `.json<T>()` and friends are there when you want the body.
|
|
17
|
-
- **
|
|
18
|
+
- **A plugin holds middleware and interceptors, kept apart by the types.** Middleware wraps the call and controls the flow — it can call `next` again to retry, or not at all. Interceptors transform one value and return it; they cannot retry, skip, or swallow. Retry, timeouts, logging, auth refresh, and anything that reacts to how a call ended (error pages, domain errors) are middleware. Headers, trace ids and per-attempt error details are interceptors. One plugin can use both: `bearerAuth` sets the header in an interceptor and refreshes in middleware.
|
|
18
19
|
- **One error type, two causes.** `ArthronError` means the server said no (`!response.ok`) or the network failed (`status: 0`). Everything else — cancellation, a bug in a plugin — is rethrown as-is.
|
|
19
|
-
- Zero dependencies. ESM and CJS. TypeScript types included. Node 22+ and browsers.
|
|
20
|
+
- Zero dependencies. ESM and CJS. TypeScript types included, for TypeScript 5.0+. Node 22+ and browsers.
|
|
20
21
|
|
|
21
22
|
## Install
|
|
22
23
|
|
|
@@ -118,15 +119,15 @@ A request that is aborted or times out rejects with the abort reason — `AbortE
|
|
|
118
119
|
|
|
119
120
|
### Derive clients
|
|
120
121
|
|
|
121
|
-
Clients are immutable. `use` and `
|
|
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.
|
|
122
123
|
|
|
123
124
|
```ts
|
|
124
125
|
const base = arthron({ baseURL: API, use: [logging(), retry()] });
|
|
125
|
-
const authed = base.use(
|
|
126
|
+
const authed = base.use(bearerAuth({ origin: API, token })); // base + auth
|
|
126
127
|
const admin = authed.extend({ headers: { "x-role": "admin" } }); // authed + header
|
|
127
128
|
```
|
|
128
129
|
|
|
129
|
-
`extend` shallow-merges the config, merges headers case-insensitively (child wins), and adds plugins the way `use` does: a plugin whose `name` is already there replaces it in place — so `streaming = base.use(timeout(120_000))` loosens the inherited `timeout`, and a second `retry()` does not multiply attempts — while unnamed plugins append. `without("retry")` returns a client without that plugin. The built-ins are named `retry`, `timeout` and `logging`.
|
|
130
|
+
`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`, `retry`, `timeout` and `logging`.
|
|
130
131
|
|
|
131
132
|
## Plugins
|
|
132
133
|
|
|
@@ -148,15 +149,18 @@ type RequestContext = {
|
|
|
148
149
|
};
|
|
149
150
|
```
|
|
150
151
|
|
|
151
|
-
How one request runs:
|
|
152
|
+
How one request runs, with `use: [m1, m2]`:
|
|
152
153
|
|
|
153
154
|
```
|
|
154
|
-
|
|
155
|
-
middleware
|
|
156
|
-
|
|
157
|
-
|
|
155
|
+
call → m1 (before next) → m2 (before next) ─┐
|
|
156
|
+
│ attempt — runs again each time a middleware calls next again
|
|
157
|
+
│ request interceptors → fetch → response interceptors
|
|
158
|
+
│ ↘ error interceptors → throw
|
|
159
|
+
call ← m1 (after next) ← m2 (after next) ←─┘
|
|
158
160
|
```
|
|
159
161
|
|
|
162
|
+
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.
|
|
163
|
+
|
|
160
164
|
| | Middleware | Interceptor |
|
|
161
165
|
|---|---|---|
|
|
162
166
|
| Runs | Once per call, wrapping everything below | Once per attempt |
|
|
@@ -169,6 +173,29 @@ Every attempt starts from a fresh copy of the context, so a header set by an int
|
|
|
169
173
|
|
|
170
174
|
### Built in
|
|
171
175
|
|
|
176
|
+
The built-in plugins come from `arthron/plugins`: `import { bearerAuth, logging, retry, timeout } 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.
|
|
177
|
+
|
|
178
|
+
| plugin | slots | per call |
|
|
179
|
+
|---|---|---|
|
|
180
|
+
| `bearerAuth` | request interceptor (the header) + middleware (refresh on 401) | `meta: { bearerAuth: false }` |
|
|
181
|
+
| `retry` | middleware | `meta: { retry: false \| options }` |
|
|
182
|
+
| `timeout` | middleware | `meta: { timeout: ms }` |
|
|
183
|
+
| `logging` | middleware | — |
|
|
184
|
+
|
|
185
|
+
Order is meaning: the first plugin is outermost. A good default:
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
use: [logging(), bearerAuth(…), timeout(10_000), retry()]
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`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`.
|
|
192
|
+
|
|
193
|
+
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:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
use: [{ ...timeout(30_000), name: "callTimeout" }, retry(), timeout(10_000)] // 30 s in all, 10 s per attempt
|
|
197
|
+
```
|
|
198
|
+
|
|
172
199
|
#### `retry(options)`
|
|
173
200
|
|
|
174
201
|
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.
|
|
@@ -187,11 +214,13 @@ Honours `Retry-After` (seconds or HTTP date). Never retries a `ReadableStream` b
|
|
|
187
214
|
|
|
188
215
|
Opting a non-idempotent method in (`methods: ["POST"]`) means a network failure is retried too, and a dropped connection may already have reached the server. Where a duplicate would hurt — a payment, an LLM completion you pay for — keep retries to statuses: `shouldRetry: (e) => (isArthronError(e) && e.status === 0 ? false : undefined)`. Per call, `meta: { retry: false }` turns it off and `meta: { retry: { retries: 5 } }` overrides any option but `sleep`.
|
|
189
216
|
|
|
190
|
-
#### `timeout(ms
|
|
217
|
+
#### `timeout(ms)` / `timeout({ total?, idle? })`
|
|
191
218
|
|
|
192
219
|
Aborts the attempt after `ms`, counted until the body has been fully read — a server that sends headers and then stalls still trips it. Rejects with a `TimeoutError`, which `retry` and error interceptors ignore. Combines with a `signal` you pass yourself. `Infinity` means no deadline. The timer never keeps a Node process alive on its own. Per call, `meta: { timeout: 120_000 }` replaces the deadline (`Infinity` lifts it).
|
|
193
220
|
|
|
194
|
-
A total deadline also cuts off a stream that is still flowing. For streams, bound the silence instead: `timeout(
|
|
221
|
+
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`.
|
|
222
|
+
|
|
223
|
+
`timeout(ms, { idle })` still works but is deprecated; it goes in the next major.
|
|
195
224
|
|
|
196
225
|
```ts
|
|
197
226
|
arthron({ use: [retry(), timeout(5_000)] }); // 5 s per attempt — a timed-out attempt is not retried
|
|
@@ -214,54 +243,70 @@ arthron({ use: [timeout(5_000), retry()] }); // 5 s for all attempts together
|
|
|
214
243
|
|
|
215
244
|
Query strings and `user:pass@` are never printed. Cancellations are not logged.
|
|
216
245
|
|
|
246
|
+
#### `bearerAuth(options)`
|
|
247
|
+
|
|
248
|
+
Bearer tokens, with a refresh on 401.
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
const api = arthron({
|
|
252
|
+
baseURL: API,
|
|
253
|
+
use: [
|
|
254
|
+
bearerAuth({
|
|
255
|
+
origin: API,
|
|
256
|
+
token: () => session.accessToken,
|
|
257
|
+
refresh: async () => {
|
|
258
|
+
const res = await api.post("/token/refresh", undefined, { meta: { bearerAuth: false } }).json<{ access_token: string }>();
|
|
259
|
+
session.accessToken = res.access_token;
|
|
260
|
+
},
|
|
261
|
+
}),
|
|
262
|
+
],
|
|
263
|
+
});
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
| option | default | |
|
|
267
|
+
|---|---|---|
|
|
268
|
+
| `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` |
|
|
269
|
+
| `token` | required | `() => string \| null \| undefined`, or a promise of one; read on every attempt. No token, no header |
|
|
270
|
+
| `refresh` | — | `() => Promise<void>`, called after a 401; then the call is redone once. Without it, a 401 is thrown as is |
|
|
271
|
+
|
|
272
|
+
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.
|
|
273
|
+
|
|
274
|
+
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.
|
|
275
|
+
|
|
276
|
+
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.
|
|
277
|
+
|
|
217
278
|
### Write your own
|
|
218
279
|
|
|
219
280
|
`definePlugin`, `defineMiddleware` and `defineInterceptor.{request,response,error}` are identity functions that give you the parameter types without annotations.
|
|
220
281
|
|
|
282
|
+
**`return await next(ctx)` inside `try`.** Without `await`, the promise is returned before it settles, and the `catch` never sees its error.
|
|
283
|
+
|
|
284
|
+
**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()`:
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
const idempotent = defineMiddleware((ctx, next) =>
|
|
288
|
+
next({ ...ctx, init: { ...ctx.init, headers: { ...ctx.init.headers, "idempotency-key": crypto.randomUUID() } } }),
|
|
289
|
+
);
|
|
290
|
+
// A client of its own, so the order is exactly this: the key first, then retry.
|
|
291
|
+
const payments = arthron({ baseURL: API, use: [{ name: "idempotency", middleware: idempotent }, retry({ methods: ["POST"] })] });
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
(`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.)
|
|
295
|
+
|
|
221
296
|
**Per-call settings.** `api.get(path, { meta })` hands `meta` to every plugin as `ctx.meta` and never sends it. Declare your own keys once and they are typed everywhere:
|
|
222
297
|
|
|
223
298
|
```ts
|
|
224
299
|
declare module "arthron" {
|
|
225
300
|
interface ArthronMeta {
|
|
226
|
-
|
|
301
|
+
skipTrace?: boolean;
|
|
227
302
|
}
|
|
228
303
|
}
|
|
229
304
|
|
|
230
|
-
api.use({ request: (ctx) => (ctx.meta.
|
|
231
|
-
|
|
305
|
+
const traced = api.use({ request: (ctx) => (ctx.meta.skipTrace ? ctx : withTraceId(ctx)) });
|
|
306
|
+
traced.get("/health", { meta: { skipTrace: true } });
|
|
232
307
|
```
|
|
233
308
|
|
|
234
|
-
**Refresh a session on 401, then redo the request.**
|
|
235
|
-
|
|
236
|
-
```ts
|
|
237
|
-
import { definePlugin, isArthronError } from "arthron";
|
|
238
|
-
|
|
239
|
-
export const auth = (store: { token: () => string | null; refresh: () => Promise<void> }, origin: string) => {
|
|
240
|
-
let refreshing: Promise<void> | undefined;
|
|
241
|
-
const bearer = () => (store.token() ? `Bearer ${store.token()}` : undefined);
|
|
242
|
-
return definePlugin({
|
|
243
|
-
name: "auth",
|
|
244
|
-
request: (ctx) => {
|
|
245
|
-
const value = bearer();
|
|
246
|
-
if (!value || new URL(ctx.url).origin !== origin) return ctx;
|
|
247
|
-
return { ...ctx, init: { ...ctx.init, headers: { ...ctx.init.headers, authorization: value } } };
|
|
248
|
-
},
|
|
249
|
-
middleware: async (ctx, next) => {
|
|
250
|
-
try {
|
|
251
|
-
return await next(ctx);
|
|
252
|
-
} catch (e) {
|
|
253
|
-
if (!isArthronError(e) || e.status !== 401) throw e;
|
|
254
|
-
// Refresh only if the failed token is still current — else another call already did.
|
|
255
|
-
if (e.request.init.headers.authorization === bearer()) {
|
|
256
|
-
refreshing ??= store.refresh().finally(() => (refreshing = undefined));
|
|
257
|
-
await refreshing; // a failed refresh rejects here: the caller sees it
|
|
258
|
-
}
|
|
259
|
-
return next(ctx);
|
|
260
|
-
}
|
|
261
|
-
},
|
|
262
|
-
});
|
|
263
|
-
};
|
|
264
|
-
```
|
|
309
|
+
**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.
|
|
265
310
|
|
|
266
311
|
**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).
|
|
267
312
|
|
|
@@ -292,7 +337,7 @@ The same shape maps errors to your own types: `catch (e) { throw toDomainError(e
|
|
|
292
337
|
**Attach a trace id.** A one-line request interceptor.
|
|
293
338
|
|
|
294
339
|
```ts
|
|
295
|
-
api.use({ request: (ctx) => ({ ...ctx, init: { ...ctx.init, headers: { ...ctx.init.headers, "x-trace-id": crypto.randomUUID() } } }) });
|
|
340
|
+
const traced = api.use({ request: (ctx) => ({ ...ctx, init: { ...ctx.init, headers: { ...ctx.init.headers, "x-trace-id": crypto.randomUUID() } } }) });
|
|
296
341
|
```
|
|
297
342
|
|
|
298
343
|
## Reference
|
|
@@ -303,7 +348,7 @@ api.use({ request: (ctx) => ({ ...ctx, init: { ...ctx.init, headers: { ...ctx.in
|
|
|
303
348
|
type ArthronConfig = {
|
|
304
349
|
baseURL?: string;
|
|
305
350
|
allowAbsoluteUrls?: boolean; // let absolute URLs leave baseURL's origin (without config headers)
|
|
306
|
-
headers?:
|
|
351
|
+
headers?: HeadersOption; // anything fetch's headers take, or a record whose null drops a header (in extend)
|
|
307
352
|
fetch?: typeof fetch; // inject for tests or a polyfill; resolved per request
|
|
308
353
|
use?: Plugin[];
|
|
309
354
|
errorDetail?: (data: unknown, res: Response) => string | undefined; // picks ArthronError.detail out of the body
|
|
@@ -355,6 +400,7 @@ class ArthronError extends Error {
|
|
|
355
400
|
- **Redirects follow fetch's rules.** On a redirect to another origin, fetch drops `Authorization` but keeps other headers such as `x-api-key`. Pass `redirect: "error"` (or `"manual"`) for calls that carry such keys if the server could redirect off-origin.
|
|
356
401
|
- **Bodies.** `string`, `FormData`, `Blob`, `ArrayBuffer`/views, `URLSearchParams` and `ReadableStream` go through untouched — recognised by type tag, so polyfill and cross-realm instances count too. A `ReadableStream` is sent with `duplex: "half"`, which fetch requires. Anything else — including `null` — becomes JSON with `content-type: application/json`; a content type you set must be a JSON one. Bodies JSON would mangle — a `Map`, `Set`, generator, Node stream or function — are rejected with a `TypeError` that says what to pass instead. A `FormData` body is always sent without a `content-type` header, even with a JSON default, so the runtime can add the multipart boundary. `GET`/`HEAD` with a body is rejected with a `TypeError`.
|
|
357
402
|
- **Fetch defaults are flat and only sent when set.** Config takes the same fetch options per-call calls do (`credentials`, `cache`, `redirect`, …) as defaults; a per-call value wins and `extend` overrides. Nothing is sent that you did not set — cross-origin cookies need `credentials: "include"`. `signal`, `body` and `method` are per call: a client-wide signal would fail every call once aborted.
|
|
403
|
+
- **In browsers, CORS and the engine have the last word.** CI runs the built package in Chromium, Firefox and WebKit against a server on another origin. A JSON body, a custom header and `query()`'s QUERY method each make the browser send a preflight, so the API must allow them (`Access-Control-Allow-Methods: QUERY`, `Access-Control-Allow-Headers`). `retry()` reads `Retry-After` only if the API exposes it (`Access-Control-Expose-Headers: retry-after`); otherwise it waits its own delay. `redirect: "manual"` gives an opaque redirect (`status` 0, `type` `"opaqueredirect"`), returned rather than thrown. A `ReadableStream` body streams only over HTTP/2 (Chromium, WebKit), and the `ArthronError` for one sent to an HTTP/1.1 server says so. Where the browser cannot stream a request body at all (Firefox would send the text `"[object ReadableStream]"`), the call rejects with a `TypeError` before anything is sent. Either way, send a `Blob` or `ArrayBuffer` there.
|
|
358
404
|
- **Response bodies are single-use.** A response interceptor that wants to inspect the body must `res.clone()`; one that changes it returns a new `Response`.
|
|
359
405
|
- **Argument errors reject, they don't throw.** No `baseURL` with a relative path, a URL the global fetch cannot parse (`baseURL: "api.example.com"` without `https://`), a `BigInt` in a JSON body, a missing `method` in `request` — all surface as a rejected `TypeError` before anything is sent, never as a retried `status: 0`. An injected `fetch` is the judge of its own URLs: arthron does not pre-check them, so one that accepts relative URLs (SvelteKit's server-side `fetch`) works with `baseURL: "/api"`.
|
|
360
406
|
|
|
@@ -362,9 +408,13 @@ class ArthronError extends Error {
|
|
|
362
408
|
|
|
363
409
|
- `CHANGELOG.md` lists every release and the semver rules arthron follows since 1.0.
|
|
364
410
|
- `llms.txt` ships in the package — a compressed API reference meant to be read by LLM-based tools (`node_modules/arthron/llms.txt`).
|
|
365
|
-
- `examples/` holds type-checked, runnable recipes: LLM streaming, auth refresh, an error page, upload/download. `examples/recipes.test.ts` keeps the
|
|
411
|
+
- `examples/` holds type-checked, runnable recipes: LLM streaming, auth refresh, an error page, upload/download. `examples/recipes.test.ts` keeps the error-page recipe honest.
|
|
366
412
|
- Error messages state the fix (`… — pass baseURL in arthron({ baseURL }) or use an absolute URL`).
|
|
367
413
|
|
|
414
|
+
## Security
|
|
415
|
+
|
|
416
|
+
Report vulnerabilities privately through GitHub — see [SECURITY.md](SECURITY.md) for supported versions, response times and disclosure.
|
|
417
|
+
|
|
368
418
|
## License
|
|
369
419
|
|
|
370
420
|
MIT
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
//#region src/types/request.types.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* What plugins see and return. `init.headers` is always a plain record with
|
|
4
|
+
* lowercased names — spread it, read it, add to it — and the pipeline puts
|
|
5
|
+
* it back into that shape after every request interceptor.
|
|
6
|
+
*/
|
|
7
|
+
interface RequestContext {
|
|
8
|
+
url: string;
|
|
9
|
+
init: Omit<RequestInit, "headers"> & {
|
|
10
|
+
headers: Record<string, string>;
|
|
11
|
+
};
|
|
12
|
+
/** Per-call settings for plugins (`RequestOptions.meta`); never sent. */
|
|
13
|
+
meta: ArthronMeta;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Per-call settings for plugins, passed as `api.get(path, { meta })` and
|
|
17
|
+
* carried next to `init` — never sent to fetch. The built-in plugins read
|
|
18
|
+
* `bearerAuth`, `retry` and `timeout`; your own add keys by augmentation:
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* declare module "arthron" {
|
|
22
|
+
* interface ArthronMeta { skipTrace?: boolean }
|
|
23
|
+
* }
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
interface ArthronMeta {
|
|
27
|
+
/**
|
|
28
|
+
* `false` sends this call without the `bearerAuth()` token and never refreshes
|
|
29
|
+
* on its 401 — for the login and token-refresh endpoints.
|
|
30
|
+
*/
|
|
31
|
+
bearerAuth?: false;
|
|
32
|
+
/** `false` turns `retry()` off for this call; an object overrides its options. */
|
|
33
|
+
retry?: false | Omit<RetryOptions, "sleep">;
|
|
34
|
+
/** Deadline for this call in ms, overriding `timeout(ms)`. `Infinity` lifts it. */
|
|
35
|
+
timeout?: number;
|
|
36
|
+
}
|
|
37
|
+
type RetryOptions = {
|
|
38
|
+
/** Number of retries after the first attempt. Default 2 (3 requests total). */
|
|
39
|
+
retries?: number;
|
|
40
|
+
/** Statuses worth retrying. Network errors (status 0) always are. */
|
|
41
|
+
retryOn?: readonly number[];
|
|
42
|
+
/** Methods safe to repeat (QUERY is, per RFC 10008). Compared case-insensitively. */
|
|
43
|
+
methods?: readonly string[];
|
|
44
|
+
/**
|
|
45
|
+
* Backoff in ms for retry number `retry` (0-based). Default "full jitter":
|
|
46
|
+
* a random share of 300 · 2^retry, so clients that failed together do not
|
|
47
|
+
* retry together.
|
|
48
|
+
*/
|
|
49
|
+
delay?: (retry: number, error: unknown) => number;
|
|
50
|
+
/**
|
|
51
|
+
* Your own verdict on one failure: `true` retries, `false` stops,
|
|
52
|
+
* `undefined` leaves it to `retryOn`/`methods`. It sees every error — a
|
|
53
|
+
* TimeoutError from a `timeout()` placed after `retry()` included — but
|
|
54
|
+
* cannot exceed `retries`, resend a `ReadableStream` body, or retry a call
|
|
55
|
+
* the caller aborted.
|
|
56
|
+
*/
|
|
57
|
+
shouldRetry?: (error: unknown, attempt: number, ctx: RequestContext) => boolean | undefined;
|
|
58
|
+
/** Called before each wait, for logs and metrics. */
|
|
59
|
+
onRetry?: (error: unknown, attempt: number, waitMs: number) => void;
|
|
60
|
+
/** Injectable for tests. Default `setTimeout`; rejects with the abort reason. */
|
|
61
|
+
sleep?: (ms: number, signal?: AbortSignal | null) => Promise<void>;
|
|
62
|
+
/**
|
|
63
|
+
* Longest `Retry-After` (ms) worth honouring. Beyond it the request is not
|
|
64
|
+
* retried at all. Default 60 000.
|
|
65
|
+
*/
|
|
66
|
+
maxRetryAfter?: number;
|
|
67
|
+
};
|
|
68
|
+
type QueryValue = string | number | boolean | null | undefined;
|
|
69
|
+
/**
|
|
70
|
+
* Query parameters. `null`/`undefined` values are skipped, an array becomes
|
|
71
|
+
* repeated keys (`ids=1&ids=2`), and a `URLSearchParams` is used as is.
|
|
72
|
+
*/
|
|
73
|
+
type QueryParams = Record<string, QueryValue | readonly QueryValue[]> | URLSearchParams;
|
|
74
|
+
/**
|
|
75
|
+
* Headers as config and per-call options accept them: anything fetch's
|
|
76
|
+
* `headers` takes, or a plain object whose `null`/`undefined` values delete
|
|
77
|
+
* that header from the layer underneath (a client default, an inherited
|
|
78
|
+
* header). Not spelled `HeadersInit`: that name exists only in the DOM lib,
|
|
79
|
+
* and a Node project typed with `@types/node` alone does not have it.
|
|
80
|
+
*/
|
|
81
|
+
type HeadersOption = NonNullable<RequestInit["headers"]> | Record<string, string | null | undefined>;
|
|
82
|
+
//#endregion
|
|
83
|
+
//#region src/error/arthron-error.d.ts
|
|
84
|
+
type ArthronErrorInit = {
|
|
85
|
+
message: string;
|
|
86
|
+
status: number;
|
|
87
|
+
statusText: string;
|
|
88
|
+
headers: Headers;
|
|
89
|
+
data: unknown;
|
|
90
|
+
detail?: string | undefined;
|
|
91
|
+
request: RequestContext;
|
|
92
|
+
response?: Response;
|
|
93
|
+
cause?: unknown;
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* The only error arthron itself produces. Two sources: a response with
|
|
97
|
+
* `ok === false`, or fetch rejecting outright (network failure, `status` 0).
|
|
98
|
+
* Everything else — cancellation, interceptor bugs — is rethrown untouched.
|
|
99
|
+
*/
|
|
100
|
+
declare class ArthronError extends Error {
|
|
101
|
+
readonly name = "ArthronError";
|
|
102
|
+
/** HTTP status of the failed response; `0` when fetch itself failed (network). */
|
|
103
|
+
readonly status: number;
|
|
104
|
+
readonly statusText: string;
|
|
105
|
+
/** Response headers; empty for network failures. Not enumerable — kept out of logs. */
|
|
106
|
+
readonly headers: Headers;
|
|
107
|
+
/** Parsed body: JSON if it parses, else the text, else `undefined`. */
|
|
108
|
+
readonly data: unknown;
|
|
109
|
+
/**
|
|
110
|
+
* The server's own explanation, picked out of `data` (by default
|
|
111
|
+
* `message` → `error` → `error.message` → `detail`; see the `errorDetail`
|
|
112
|
+
* config). `undefined` when the body had none. Untrusted server text: show
|
|
113
|
+
* it to users as text, never as HTML. `message`, by contrast, is written by
|
|
114
|
+
* arthron — method, URL without query, status — and is meant for logs.
|
|
115
|
+
*/
|
|
116
|
+
readonly detail: string | undefined;
|
|
117
|
+
/**
|
|
118
|
+
* The context that was sent — url and init after request interceptors.
|
|
119
|
+
* Not enumerable: it holds credentials, so `JSON.stringify`, `console.error`
|
|
120
|
+
* and error trackers leave it out. Read it directly when you need it.
|
|
121
|
+
*/
|
|
122
|
+
readonly request: RequestContext;
|
|
123
|
+
/** The failed Response, still readable — its body holds what arthron read (at most 64 KiB). Absent for network failures. Not enumerable. */
|
|
124
|
+
readonly response: Response | undefined;
|
|
125
|
+
/** The underlying error for network failures. */
|
|
126
|
+
readonly cause: unknown;
|
|
127
|
+
constructor(init: ArthronErrorInit);
|
|
128
|
+
/** `instanceof ArthronError` holds for an error from any copy of the package. */
|
|
129
|
+
static [Symbol.hasInstance](value: unknown): boolean;
|
|
130
|
+
/** What serializers see: no headers, no body, no query or userinfo. */
|
|
131
|
+
toJSON(): {
|
|
132
|
+
name: string;
|
|
133
|
+
message: string;
|
|
134
|
+
detail: string | undefined;
|
|
135
|
+
status: number;
|
|
136
|
+
statusText: string;
|
|
137
|
+
method: string;
|
|
138
|
+
url: string;
|
|
139
|
+
data: unknown;
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/** `true` for errors arthron produced: a `!ok` response or a network failure. Cancellations and plugin bugs are not ArthronErrors. */
|
|
143
|
+
declare function isArthronError(error: unknown): error is ArthronError;
|
|
144
|
+
//#endregion
|
|
145
|
+
//#region src/types/plugin.types.d.ts
|
|
146
|
+
/** Runs before each attempt. Returns the context to send (may be a new object). */
|
|
147
|
+
type RequestInterceptor = (ctx: RequestContext) => RequestContext | Promise<RequestContext>;
|
|
148
|
+
/** Runs after a successful attempt. The body is single-use — `clone()` to peek. */
|
|
149
|
+
type ResponseInterceptor = (res: Response, ctx: RequestContext) => Response | Promise<Response>;
|
|
150
|
+
/** Runs after a failed attempt. Returns the error to throw; it is always thrown. */
|
|
151
|
+
type ErrorInterceptor = (err: ArthronError, ctx: RequestContext) => ArthronError | Promise<ArthronError>;
|
|
152
|
+
/**
|
|
153
|
+
* Wraps the whole call. `next` runs everything below it (request interceptors →
|
|
154
|
+
* fetch → response/error interceptors) and may be called again to retry.
|
|
155
|
+
* `next` rejects with an `ArthronError`, or with the untouched original when the
|
|
156
|
+
* failure was a cancellation or a bug in a plugin.
|
|
157
|
+
*/
|
|
158
|
+
type Middleware = (ctx: RequestContext, next: (ctx: RequestContext) => Promise<Response>) => Promise<Response>;
|
|
159
|
+
interface Plugin {
|
|
160
|
+
/**
|
|
161
|
+
* Identity within a client: `use`/`extend` replace the plugin of the same
|
|
162
|
+
* name in place, `without(name)` removes it. The built-ins are named
|
|
163
|
+
* "bearerAuth", "logging", "retry" and "timeout". Leave it out to always
|
|
164
|
+
* append.
|
|
165
|
+
*/
|
|
166
|
+
name?: string;
|
|
167
|
+
middleware?: Middleware;
|
|
168
|
+
request?: RequestInterceptor;
|
|
169
|
+
response?: ResponseInterceptor;
|
|
170
|
+
error?: ErrorInterceptor;
|
|
171
|
+
}
|
|
172
|
+
//#endregion
|
|
173
|
+
//#region src/plugins/bearer-auth/bearer-auth.types.d.ts
|
|
174
|
+
type BearerAuthOptions = {
|
|
175
|
+
/**
|
|
176
|
+
* Where the token may go: a URL, or several. Only their origins count, so
|
|
177
|
+
* your `baseURL` works as is. A request anywhere else — a CDN, a URL taken
|
|
178
|
+
* from a response body — is sent without the token.
|
|
179
|
+
*/
|
|
180
|
+
origin: string | readonly string[];
|
|
181
|
+
/**
|
|
182
|
+
* The current access token, read on every attempt. `null`, `undefined` or
|
|
183
|
+
* `""` sends no `authorization` header.
|
|
184
|
+
*/
|
|
185
|
+
token: () => string | null | undefined | Promise<string | null | undefined>;
|
|
186
|
+
/**
|
|
187
|
+
* Get a new token after a 401; the call is then redone once. Concurrent
|
|
188
|
+
* 401s share one refresh, and a call whose token was already replaced
|
|
189
|
+
* retries without one. Throw when the refresh fails — every waiting call
|
|
190
|
+
* rejects with that error. Requests the refresh makes through the same
|
|
191
|
+
* client need `meta: { bearerAuth: false }`. Without `refresh`, or for a
|
|
192
|
+
* `ReadableStream` body (already read, so it cannot be resent), a 401 is
|
|
193
|
+
* thrown as is.
|
|
194
|
+
*/
|
|
195
|
+
refresh?: () => Promise<void>;
|
|
196
|
+
};
|
|
197
|
+
//#endregion
|
|
198
|
+
//#region src/plugins/bearer-auth/bearer-auth.d.ts
|
|
199
|
+
/**
|
|
200
|
+
* Bearer-token auth. A request interceptor sets `authorization: Bearer
|
|
201
|
+
* <token>` on every attempt to `origin`; with `refresh`, a middleware turns a
|
|
202
|
+
* 401 into one refresh and one more try. Concurrent 401s share the refresh
|
|
203
|
+
* (with refresh-token rotation, parallel refreshes revoke each other), and a
|
|
204
|
+
* call whose token was replaced meanwhile just retries. A call opts out with
|
|
205
|
+
* `meta: { bearerAuth: false }`.
|
|
206
|
+
*/
|
|
207
|
+
declare function bearerAuth(options: BearerAuthOptions): Plugin;
|
|
208
|
+
//#endregion
|
|
209
|
+
//#region src/plugins/logging/logging.types.d.ts
|
|
210
|
+
type LogEventBase = {
|
|
211
|
+
method: string;
|
|
212
|
+
path: string;
|
|
213
|
+
};
|
|
214
|
+
type LogEvent = ({
|
|
215
|
+
phase: "start";
|
|
216
|
+
} & LogEventBase) | ({
|
|
217
|
+
phase: "success";
|
|
218
|
+
status: number;
|
|
219
|
+
ms: number;
|
|
220
|
+
} & LogEventBase) | ({
|
|
221
|
+
phase: "error";
|
|
222
|
+
/** HTTP status for an ArthronError (0 = network); undefined for anything else. */
|
|
223
|
+
status: number | undefined;
|
|
224
|
+
ms: number;
|
|
225
|
+
error: unknown;
|
|
226
|
+
} & LogEventBase);
|
|
227
|
+
type LoggingOptions = {
|
|
228
|
+
/** Default `true`. The library never reads `NODE_ENV` — pass `isDev` yourself. */
|
|
229
|
+
enabled?: boolean;
|
|
230
|
+
logger?: Pick<Console, "log" | "error">;
|
|
231
|
+
/** Receive structured events instead of the default console lines. */
|
|
232
|
+
onEvent?: (event: LogEvent) => void;
|
|
233
|
+
/** Clock in ms. Injectable for tests. */
|
|
234
|
+
now?: () => number;
|
|
235
|
+
};
|
|
236
|
+
//#endregion
|
|
237
|
+
//#region src/plugins/logging/logging.d.ts
|
|
238
|
+
/**
|
|
239
|
+
* One event when a request starts, one when it settles, with status and
|
|
240
|
+
* elapsed time. Wraps the whole call, so retries are included in the time.
|
|
241
|
+
* Default output is a console line per event; pass `onEvent` to get the
|
|
242
|
+
* structured event instead. Cancellations are not reported.
|
|
243
|
+
*/
|
|
244
|
+
declare function logging(options?: LoggingOptions): Plugin;
|
|
245
|
+
//#endregion
|
|
246
|
+
//#region src/plugins/retry/retry.d.ts
|
|
247
|
+
/**
|
|
248
|
+
* Retry on network errors and the configured statuses by calling `next`
|
|
249
|
+
* again — the whole attempt (request interceptors included) is re-run.
|
|
250
|
+
* Only `ArthronError` is considered; cancellation and plugin bugs pass through.
|
|
251
|
+
* A call can turn it off or override its options with `meta.retry`.
|
|
252
|
+
*/
|
|
253
|
+
declare function retry(options?: RetryOptions): Plugin;
|
|
254
|
+
//#endregion
|
|
255
|
+
//#region src/plugins/timeout/timeout.types.d.ts
|
|
256
|
+
type TimeoutOptions = {
|
|
257
|
+
/**
|
|
258
|
+
* Abort after this many ms in all, counted until the body has been fully
|
|
259
|
+
* read — the same deadline as `timeout(ms)`. Default: none.
|
|
260
|
+
*/
|
|
261
|
+
total?: number;
|
|
262
|
+
/**
|
|
263
|
+
* Abort when no data has arrived for this many ms — while waiting for the
|
|
264
|
+
* headers and between body chunks. Unlike `total`, a stream that keeps
|
|
265
|
+
* flowing is never cut off: `timeout({ idle: 30_000 })`.
|
|
266
|
+
*/
|
|
267
|
+
idle?: number;
|
|
268
|
+
};
|
|
269
|
+
//#endregion
|
|
270
|
+
//#region src/plugins/timeout/timeout.d.ts
|
|
271
|
+
/**
|
|
272
|
+
* Abort the request when it takes longer than `ms` — counted from the start
|
|
273
|
+
* of the attempt until the body has been fully read, so a server that sends
|
|
274
|
+
* headers and then stalls still trips it. `timeout({ idle })` instead aborts
|
|
275
|
+
* once no data has arrived for that long, and never cuts off a stream that
|
|
276
|
+
* keeps flowing; `timeout({ total, idle })` does both. Rejects with a
|
|
277
|
+
* `TimeoutError` (a `DOMException`, like `AbortSignal.timeout()` would give);
|
|
278
|
+
* it is not an `ArthronError`, so error interceptors and `retry` leave it
|
|
279
|
+
* alone.
|
|
280
|
+
*
|
|
281
|
+
* A call can change the total deadline with `meta.timeout`.
|
|
282
|
+
*
|
|
283
|
+
* Deadlines are timers on an AbortController — not `AbortSignal.timeout`,
|
|
284
|
+
* since the idle one restarts — joined to the caller's signal with
|
|
285
|
+
* `AbortSignal.any`.
|
|
286
|
+
*/
|
|
287
|
+
declare function timeout(ms: number): Plugin;
|
|
288
|
+
declare function timeout(options: TimeoutOptions): Plugin;
|
|
289
|
+
/** @deprecated Use `timeout({ total: ms, idle })`; this form goes in the next major. */
|
|
290
|
+
declare function timeout(ms: number, options: Pick<TimeoutOptions, "idle">): Plugin;
|
|
291
|
+
//#endregion
|
|
292
|
+
export { HeadersOption as _, LogEvent as a, RetryOptions as b, BearerAuthOptions as c, Plugin as d, RequestInterceptor as f, ArthronMeta as g, isArthronError as h, logging as i, ErrorInterceptor as l, ArthronError as m, TimeoutOptions as n, LoggingOptions as o, ResponseInterceptor as p, retry as r, bearerAuth as s, timeout as t, Middleware as u, QueryParams as v, RequestContext as y };
|
|
293
|
+
//# sourceMappingURL=index-Bz1P80JN.d.cts.map
|