arthron 0.0.0-stage → 0.0.1
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/LICENSE +21 -0
- package/README.md +242 -2
- package/dist/index.cjs +440 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +159 -0
- package/dist/index.d.mts +159 -0
- package/dist/index.mjs +432 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +56 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 lapidix
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,243 @@
|
|
|
1
|
-
#
|
|
1
|
+
# arthron
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Middleware owns the flow — it can call `next` again to retry the whole request. Interceptors only transform values and can never change the flow. arthron keeps the two apart at the type level.**
|
|
4
|
+
|
|
5
|
+
A composable `fetch` client. Create one from config, plug in interceptors and middleware, get a plain `Response` back. Zero dependencies, TypeScript first, Node 18+ and browsers.
|
|
6
|
+
|
|
7
|
+
> *arthron* (ἄρθρον) — Greek for "joint": the place where two parts meet and work as one.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm i arthron
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { arthron, logging, retry } from "arthron";
|
|
19
|
+
|
|
20
|
+
const base = arthron({
|
|
21
|
+
baseURL: "https://api.example.com",
|
|
22
|
+
use: [logging(), retry({ retries: 3 })],
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const api = base.extend({
|
|
26
|
+
use: [auth({ getToken, refresh })], // your own plugin — see "Writing your own plugin"
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const res = await api.get("/me"); // Response
|
|
30
|
+
const user = await api.get("/me").json<User>(); // parsed body
|
|
31
|
+
const blob = await api.get("/export").blob();
|
|
32
|
+
const stream = (await api.post("/chat", { prompt })).body; // ReadableStream
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Concepts
|
|
36
|
+
|
|
37
|
+
One request runs like this:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
middleware[0] (
|
|
41
|
+
middleware[1] (
|
|
42
|
+
...
|
|
43
|
+
attempt: request interceptors → fetch → ok ? response interceptors : error interceptors → throw
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- **Middleware** wraps the whole call, outermost first in registration order. `next(ctx)` runs everything beneath it; calling `next` again re-runs the whole attempt — that is how retry, auth refresh and timeouts are built.
|
|
49
|
+
- **Interceptors** run inside each attempt. They receive a value (`RequestContext`, `Response`, or `ArthronError`) and return one. They cannot skip, retry, or swallow — an error interceptor's return value is always thrown.
|
|
50
|
+
- Every attempt starts from a **copy** of the context, so headers an interceptor set on attempt one do not leak into attempt two.
|
|
51
|
+
|
|
52
|
+
A plugin is just an object bundling any of the four:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
type Plugin = {
|
|
56
|
+
name?: string;
|
|
57
|
+
middleware?: (ctx, next) => Promise<Response>;
|
|
58
|
+
request?: (ctx) => RequestContext | Promise<RequestContext>;
|
|
59
|
+
response?: (res, ctx) => Response | Promise<Response>;
|
|
60
|
+
error?: (err, ctx) => ArthronError | Promise<ArthronError>;
|
|
61
|
+
};
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## API
|
|
65
|
+
|
|
66
|
+
### `arthron(config?)`
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
type ArthronConfig = {
|
|
70
|
+
baseURL?: string; // prefix for relative paths; without it only absolute URLs are accepted
|
|
71
|
+
headers?: HeadersInit; // defaults; per-call headers win, compared case-insensitively
|
|
72
|
+
credentials?: RequestCredentials; // forwarded as-is; NOT set unless you set it
|
|
73
|
+
fetch?: typeof fetch; // injection point for tests / polyfills
|
|
74
|
+
use?: Plugin[];
|
|
75
|
+
errorMessage?: (data: unknown, res: Response) => string | undefined;
|
|
76
|
+
};
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Instance
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
api.get(path, init?) api.delete(path, init?) api.head(path, init?)
|
|
83
|
+
api.post(path, body?, init?) api.put(path, body?, init?) api.patch(path, body?, init?)
|
|
84
|
+
api.request(path, { method, body?, ...init })
|
|
85
|
+
|
|
86
|
+
api.use(...plugins) // new client with plugins appended
|
|
87
|
+
api.extend(config) // new client: config shallow-merged, headers merged, plugins appended
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`init` is `RequestInit` minus `method`/`body`, plus `params` (query object; `null`/`undefined` skipped, appended with `&` if the path already has a query).
|
|
91
|
+
|
|
92
|
+
Clients are **immutable**. `use` and `extend` return new clients; the receiver — and anything previously derived from it — is untouched.
|
|
93
|
+
|
|
94
|
+
**Body rules.** `string`, `FormData`, `Blob`, `ArrayBuffer`/views, `URLSearchParams`, `ReadableStream` are sent as-is with no `Content-Type` added. Anything else is `JSON.stringify`-ed with `content-type: application/json`, unless you already set a content type.
|
|
95
|
+
|
|
96
|
+
**URL rules.** `baseURL` and `path` are joined by string concatenation — `"https://a/v1" + "/users"` → `https://a/v1/users`. The prefix survives a leading slash (unlike `new URL()`). Absolute paths skip `baseURL`.
|
|
97
|
+
|
|
98
|
+
### Return value — `ArthronPromise`
|
|
99
|
+
|
|
100
|
+
Every method returns `Promise<Response>` with convenience helpers:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
const res = await api.get("/me"); // Response, untouched
|
|
104
|
+
const body = await api.get("/me").json<User>(); // (await res).json() — T is a cast, not validation
|
|
105
|
+
.text() .blob() .arrayBuffer() .formData()
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
A `204`/empty body makes `.json()` reject, exactly like `Response.json()` would.
|
|
109
|
+
|
|
110
|
+
### Errors — `ArthronError`
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
class ArthronError extends Error {
|
|
114
|
+
status: number; // HTTP status; 0 for network failures
|
|
115
|
+
statusText: string;
|
|
116
|
+
headers: Headers;
|
|
117
|
+
data: unknown; // parsed body: JSON if it parses, else text, else undefined
|
|
118
|
+
response?: Response; // the original; its body is already consumed into `data`. Absent for network failures
|
|
119
|
+
request: RequestContext;
|
|
120
|
+
cause?: unknown; // the original error for network failures
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Only two things become an `ArthronError`: a response with `ok === false`, and `fetch` itself rejecting (network failure, `status: 0`). **Everything else is rethrown untouched and skips the error interceptors** — `AbortError`, `TimeoutError`, and anything a plugin throws. A bug in an interceptor is a bug, not a 500.
|
|
125
|
+
|
|
126
|
+
The message is picked from the body by `message` → `error` (string) → `error.message` → `detail`, falling back to `Request failed with status N`. Override with `errorMessage` in the config.
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
import { isArthronError } from "arthron";
|
|
130
|
+
|
|
131
|
+
try { await api.get("/me"); }
|
|
132
|
+
catch (e) { if (isArthronError(e) && e.status === 404) { /* … */ } else throw e; }
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Built-in plugins
|
|
136
|
+
|
|
137
|
+
### `logging(options?)`
|
|
138
|
+
|
|
139
|
+
One line when a request starts, one when it settles. The query string is dropped (it tends to carry tokens). Cancellations are not logged.
|
|
140
|
+
|
|
141
|
+
| option | default | |
|
|
142
|
+
|---|---|---|
|
|
143
|
+
| `enabled` | `true` | arthron never reads `NODE_ENV` — pass `isDev` yourself |
|
|
144
|
+
| `logger` | `console` | needs `log` and `error` |
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
[arthron] → GET /users
|
|
148
|
+
[arthron] ← GET /users 200 42ms
|
|
149
|
+
[arthron] ✕ GET /users 503 1203ms
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### `retry(options?)`
|
|
153
|
+
|
|
154
|
+
Retries by calling `next` again, so request interceptors (fresh auth header, fresh trace id) run on every attempt.
|
|
155
|
+
|
|
156
|
+
| option | default | |
|
|
157
|
+
|---|---|---|
|
|
158
|
+
| `retries` | `2` | retries *after* the first attempt — 3 requests total |
|
|
159
|
+
| `retryOn` | `[408, 429, 500, 502, 503, 504]` | network failures (`status 0`) are always retried |
|
|
160
|
+
| `methods` | `GET HEAD OPTIONS PUT DELETE` | case-insensitive |
|
|
161
|
+
| `delay` | `retry => 300 * 2 ** retry` | ms; `retry` is 0-based |
|
|
162
|
+
| `maxRetryAfter` | `60_000` | a `Retry-After` longer than this (ms) means "don't retry" |
|
|
163
|
+
|
|
164
|
+
`Retry-After` (seconds or HTTP-date) wins over `delay` when present and within `maxRetryAfter`; beyond it the error is thrown without retrying. `ReadableStream` bodies are never retried (they can only be read once). Respects `signal` — an aborted request stops retrying immediately.
|
|
165
|
+
|
|
166
|
+
## Writing your own plugin
|
|
167
|
+
|
|
168
|
+
`defineMiddleware`, `defineInterceptor.{request,response,error}` and `definePlugin` are identity functions that give you contextual typing. Use them or annotate — same result.
|
|
169
|
+
|
|
170
|
+
### `auth` — refresh on 401, then re-run the request
|
|
171
|
+
|
|
172
|
+
This is the case the middleware/interceptor split exists for. The refresh and the retry are *flow*, so they live in middleware. Attaching the header is a *value change*, so it is a request interceptor — and because every `next` re-runs the interceptors, the retried request automatically picks up the new token.
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
import { definePlugin, isArthronError } from "arthron";
|
|
176
|
+
|
|
177
|
+
export const auth = ({ getToken, refresh }: {
|
|
178
|
+
getToken: () => string | null;
|
|
179
|
+
refresh: () => Promise<void>;
|
|
180
|
+
}) =>
|
|
181
|
+
definePlugin({
|
|
182
|
+
name: "auth",
|
|
183
|
+
request: (ctx) => {
|
|
184
|
+
const token = getToken();
|
|
185
|
+
if (!token) return ctx;
|
|
186
|
+
return { ...ctx, init: { ...ctx.init, headers: { ...ctx.init.headers, authorization: `Bearer ${token}` } } };
|
|
187
|
+
},
|
|
188
|
+
middleware: async (ctx, next) => {
|
|
189
|
+
try {
|
|
190
|
+
return await next(ctx);
|
|
191
|
+
} catch (e) {
|
|
192
|
+
if (!isArthronError(e) || e.status !== 401) throw e;
|
|
193
|
+
await refresh(); // throws → propagates; caller decides to log out
|
|
194
|
+
return next(ctx); // whole attempt again, request interceptor adds the new token
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### `errorRedirect` — react to errors without changing them
|
|
201
|
+
|
|
202
|
+
An error interceptor can only *look* and *return*; it cannot swallow. That makes it the right home for side effects like navigation.
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
import { defineInterceptor } from "arthron";
|
|
206
|
+
|
|
207
|
+
export const errorRedirect = (navigate: (to: string) => void) =>
|
|
208
|
+
defineInterceptor.error((err, ctx) => {
|
|
209
|
+
const isGet = (ctx.init.method ?? "GET") === "GET";
|
|
210
|
+
if (isGet && err.status === 404) navigate("/not-found");
|
|
211
|
+
if (isGet && err.status >= 500) navigate("/error");
|
|
212
|
+
return err; // still thrown
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
api.use({ error: errorRedirect(router.push) });
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### `timeout` — a four-line middleware
|
|
219
|
+
|
|
220
|
+
`AbortSignal.any` needs Node 20.3+ or a current browser; on Node 18 combine the signals with an `AbortController` by hand.
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
import { defineMiddleware } from "arthron";
|
|
224
|
+
|
|
225
|
+
export const timeout = (ms: number) =>
|
|
226
|
+
defineMiddleware((ctx, next) =>
|
|
227
|
+
next({ ...ctx, init: { ...ctx.init, signal: AbortSignal.any([ctx.init.signal, AbortSignal.timeout(ms)].filter(Boolean) as AbortSignal[]) } }),
|
|
228
|
+
);
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
The resulting `TimeoutError` is rethrown untouched — it is not an `ArthronError`, so `retry` will not retry it.
|
|
232
|
+
|
|
233
|
+
## Gotchas
|
|
234
|
+
|
|
235
|
+
- **`Response` bodies are single-use.** A response interceptor that wants to peek must `res.clone()` first; one that wants to change the body returns a new `Response`.
|
|
236
|
+
- **`credentials` is not set by default.** Cookie-based sessions need `credentials: "include"` in the config (cross-origin) — arthron will not guess.
|
|
237
|
+
- **`baseURL` joining is string concatenation.** `"…/v1"` + `"/users"` keeps `/v1`. Pass an absolute URL to escape the base.
|
|
238
|
+
- **Only `!ok` and network failures become `ArthronError`.** Cancellations and plugin exceptions propagate as-is and do not reach error interceptors or `retry`.
|
|
239
|
+
- **`.json<T>()` is a cast.** Validate with your schema library if you need to.
|
|
240
|
+
|
|
241
|
+
## License
|
|
242
|
+
|
|
243
|
+
MIT
|