arthron 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,10 +1,22 @@
1
1
  # arthron
2
2
 
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.**
3
+ A `fetch` client you assemble from plugins.
4
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.
5
+ ```ts
6
+ import { arthron, logging, retry, timeout } from "arthron";
7
+
8
+ const api = arthron({
9
+ baseURL: "https://api.example.com",
10
+ use: [logging(), timeout(10_000), retry()],
11
+ });
12
+
13
+ const user = await api.get("/me").json<User>();
14
+ ```
6
15
 
7
- > *arthron* (ἄρθρον) — Greek for "joint": the place where two parts meet and work as one.
16
+ - **Plain `Response` in, plain `Response` out.** No wrapper type to learn. `.json<T>()` and friends are there when you want the body.
17
+ - **Two kinds of plugin, kept apart by the types.** Middleware wraps the request and controls the flow — it can call `next` again to retry. Interceptors transform one value and return it; they cannot retry, skip, or swallow. Auth refresh, retry and timeouts are middleware. Headers, logging and error mapping are interceptors.
18
+ - **One error type, two causes.** `ArthronError` means the server said no (`!response.ok`) or the network failed (`status: 0`). Everything else — cancellation, a bug in a plugin — is rethrown as-is.
19
+ - Zero dependencies. ESM and CJS. TypeScript types included. Node 18+ and browsers.
8
20
 
9
21
  ## Install
10
22
 
@@ -12,176 +24,183 @@ A composable `fetch` client. Create one from config, plug in interceptors and mi
12
24
  npm i arthron
13
25
  ```
14
26
 
15
- ## Quick start
27
+ ## Usage
16
28
 
17
- ```ts
18
- import { arthron, logging, retry } from "arthron";
29
+ ### Create a client
19
30
 
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"
31
+ ```ts
32
+ const api = arthron({
33
+ baseURL: "https://api.example.com", // prefix for relative paths
34
+ headers: { accept: "application/json" }, // defaults; per-call headers win
35
+ credentials: "include", // only if you need cookies cross-origin
36
+ use: [retry()], // plugins, outermost first
27
37
  });
28
-
29
- const res = await api.get("/me"); // Response
30
- const user = await api.get("/me").json<User>(); // parsed body
31
- const blob = await api.get("/export").blob();
32
- const stream = (await api.post("/chat", { prompt })).body; // ReadableStream
33
38
  ```
34
39
 
35
- ## Concepts
40
+ Every field is optional. Without `baseURL`, only absolute URLs are accepted.
36
41
 
37
- One request runs like this:
42
+ ### Make requests
38
43
 
44
+ ```ts
45
+ await api.get("/users", { params: { page: 2 } }); // → Response
46
+ await api.post("/users", { name: "kim" }); // object body → JSON
47
+ await api.post("/upload", formData); // FormData/Blob/string/stream → sent as-is
48
+ await api.put("/users/1", { name: "lee" });
49
+ await api.patch("/users/1", { name: "lee" });
50
+ await api.delete("/users/1");
51
+ await api.head("/users/1");
52
+ await api.request("/users", { method: "OPTIONS" });
39
53
  ```
40
- middleware[0] (
41
- middleware[1] (
42
- ...
43
- attempt: request interceptors → fetch → ok ? response interceptors : error interceptors → throw
44
- )
45
- )
46
- ```
47
-
48
- - **Middleware** wraps the whole call, outermost first in registration order. `next(ctx)` runs everything beneath it; calling `next` again re-runs the whole attempt — that is how retry, auth refresh and timeouts are built.
49
- - **Interceptors** run inside each attempt. They receive a value (`RequestContext`, `Response`, or `ArthronError`) and return one. They cannot skip, retry, or swallow — an error interceptor's return value is always thrown.
50
- - Every attempt starts from a **copy** of the context, so headers an interceptor set on attempt one do not leak into attempt two.
51
54
 
52
- A plugin is just an object bundling any of the four:
55
+ Each call returns `Promise<Response>` with body helpers attached:
53
56
 
54
57
  ```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
- };
58
+ const res = await api.get("/me"); // Response — status, headers, body untouched
59
+ const user = await api.get("/me").json<User>(); // body parsed; T is a cast, not validation
60
+ const text = await api.get("/robots.txt").text();
61
+ const blob = await api.get("/export").blob();
62
+ const stream = (await api.post("/chat", prompt)).body; // ReadableStream
63
+ for await (const e of api.post("/chat", prompt).sse()) {} // see "Stream responses"
62
64
  ```
63
65
 
64
- ## API
66
+ `init` accepts everything `fetch` does (`signal`, `cache`, `headers`, …) plus `params`, a query object whose `null`/`undefined` values are dropped.
65
67
 
66
- ### `arthron(config?)`
68
+ ### Stream responses
67
69
 
68
70
  ```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
- };
71
+ // Server-sent events — one event per `for await` step
72
+ for await (const { event, data } of api.post("/v1/chat/completions", body).sse()) {
73
+ if (data === "[DONE]") break; // provider sentinel; arthron does not interpret it
74
+ process.stdout.write(JSON.parse(data).choices[0].delta.content ?? "");
75
+ }
76
+
77
+ // Newline-delimited JSON (Ollama, many log endpoints)
78
+ for await (const row of api.get("/logs").ndjson<LogRow>()) console.log(row);
77
79
  ```
78
80
 
79
- ### Instance
81
+ `.sse()` follows the EventSource spec (multi-line `data:` joined with `\n`, comments ignored, dispatch on blank line). Both helpers honour `signal` and `timeout` while the body is being read.
82
+
83
+ ### Handle errors
80
84
 
81
85
  ```ts
82
- 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 })
86
+ import { isArthronError } from "arthron";
85
87
 
86
- api.use(...plugins) // new client with plugins appended
87
- api.extend(config) // new client: config shallow-merged, headers merged, plugins appended
88
+ try {
89
+ await api.get("/users/404");
90
+ } catch (e) {
91
+ if (isArthronError(e)) {
92
+ e.status; // 404, or 0 when the network failed
93
+ e.data; // parsed body: JSON, else text, else undefined
94
+ e.message; // from the body (`message` → `error` → `error.message` → `detail`), else "Request failed with status 404"
95
+ e.response; // the original Response, still readable
96
+ }
97
+ }
88
98
  ```
89
99
 
90
- `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.
100
+ A request that is aborted or times out rejects with the browser's `AbortError` / `TimeoutError`, not an `ArthronError`.
95
101
 
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`.
102
+ ### Derive clients
97
103
 
98
- ### Return value — `ArthronPromise`
99
-
100
- Every method returns `Promise<Response>` with convenience helpers:
104
+ Clients are immutable. `use` and `extend` return a new client and leave the original alone.
101
105
 
102
106
  ```ts
103
- const 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()
107
+ const base = arthron({ baseURL: API, use: [logging(), retry()] });
108
+ const authed = base.use(auth()); // base + auth
109
+ const admin = authed.extend({ headers: { "x-role": "admin" } }); // authed + header
106
110
  ```
107
111
 
108
- A `204`/empty body makes `.json()` reject, exactly like `Response.json()` would.
112
+ `extend` shallow-merges the config, merges headers case-insensitively (child wins), and appends plugins.
113
+
114
+ ## Plugins
109
115
 
110
- ### Errors — `ArthronError`
116
+ A plugin is an object with any of four slots:
111
117
 
112
118
  ```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
- }
119
+ type Plugin = {
120
+ name?: string;
121
+ middleware?: (ctx: RequestContext, next: (ctx: RequestContext) => Promise<Response>) => Promise<Response>;
122
+ request?: (ctx: RequestContext) => RequestContext | Promise<RequestContext>;
123
+ response?: (res: Response, ctx: RequestContext) => Response | Promise<Response>;
124
+ error?: (err: ArthronError, ctx: RequestContext) => ArthronError | Promise<ArthronError>;
125
+ };
126
+
127
+ type RequestContext = { url: string; init: RequestInit };
122
128
  ```
123
129
 
124
- 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.
130
+ How one request runs:
125
131
 
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.
132
+ ```
133
+ middleware 1
134
+ middleware 2
135
+ attempt ─ request interceptors → fetch → response interceptors
136
+ ↘ error interceptors → throw
137
+ ```
127
138
 
128
- ```ts
129
- import { isArthronError } from "arthron";
139
+ | | Middleware | Interceptor |
140
+ |---|---|---|
141
+ | Runs | Once per call, wrapping everything below | Once per attempt |
142
+ | Gets | `ctx` and `next` | A value: context, `Response`, or `ArthronError` |
143
+ | Can | Call `next` again (retry), skip it, add a deadline | Return a changed value |
144
+ | Cannot | — | Retry, skip, or stop the error from being thrown |
145
+ | Examples | `retry`, `timeout`, auth refresh | auth header, tracing id, error mapping |
130
146
 
131
- try { await api.get("/me"); }
132
- catch (e) { if (isArthronError(e) && e.status === 404) { /* … */ } else throw e; }
133
- ```
147
+ Every attempt starts from a fresh copy of the context, so a header set by an interceptor on attempt one does not leak into attempt two.
134
148
 
135
- ## Built-in plugins
149
+ ### Built in
136
150
 
137
- ### `logging(options?)`
151
+ #### `retry(options)`
138
152
 
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.
153
+ Re-runs the attempt on network failure or a retryable status. Because it re-runs the whole attempt, request interceptors run again — a refreshed token or a new trace id is picked up automatically.
140
154
 
141
155
  | option | default | |
142
156
  |---|---|---|
143
- | `enabled` | `true` | arthron never reads `NODE_ENV` — pass `isDev` yourself |
144
- | `logger` | `console` | needs `log` and `error` |
157
+ | `retries` | `2` | retries after the first attempt (3 requests total) |
158
+ | `retryOn` | `[408, 429, 500, 502, 503, 504]` | network failures (`status: 0`) are always retried |
159
+ | `methods` | `GET HEAD OPTIONS PUT DELETE` | case-insensitive |
160
+ | `delay` | `(retry) => 300 * 2 ** retry` | ms; `retry` counts from 0 |
161
+ | `maxRetryAfter` | `60_000` | a `Retry-After` longer than this means "don't retry" |
145
162
 
146
- ```
147
- [arthron] → GET /users
148
- [arthron] ← GET /users 200 42ms
149
- [arthron] ✕ GET /users 503 1203ms
163
+ Honours `Retry-After` (seconds or HTTP date). Never retries a `ReadableStream` body or an aborted request.
164
+
165
+ #### `timeout(ms)`
166
+
167
+ Aborts the attempt after `ms`, counted until the body has been fully read — a server that sends headers and then stalls still trips it. Rejects with a `TimeoutError`, which `retry` and error interceptors ignore. Combines with a `signal` you pass yourself.
168
+
169
+ ```ts
170
+ arthron({ use: [retry(), timeout(5_000)] }); // 5 s per attempt
171
+ arthron({ use: [timeout(5_000), retry()] }); // 5 s for all attempts together
150
172
  ```
151
173
 
152
- ### `retry(options?)`
174
+ #### `logging(options)`
153
175
 
154
- Retries by calling `next` again, so request interceptors (fresh auth header, fresh trace id) run on every attempt.
176
+ ```
177
+ [arthron] → GET https://api.example.com/users
178
+ [arthron] ← GET https://api.example.com/users 200 42ms
179
+ [arthron] ✕ GET https://api.example.com/users 503 1203ms
180
+ ```
155
181
 
156
182
  | option | default | |
157
183
  |---|---|---|
158
- | `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.
184
+ | `enabled` | `true` | pass `process.env.NODE_ENV !== "production"` or similar — arthron never reads env |
185
+ | `logger` | `console` | anything with `log` and `error` |
186
+ | `onEvent` | — | receive `{ phase, method, path, status, ms, error }` instead of console lines |
165
187
 
166
- ## Writing your own plugin
188
+ Query strings and `user:pass@` are never printed. Cancellations are not logged.
167
189
 
168
- `defineMiddleware`, `defineInterceptor.{request,response,error}` and `definePlugin` are identity functions that give you contextual typing. Use them or annotate — same result.
190
+ ### Write your own
169
191
 
170
- ### `auth` — refresh on 401, then re-run the request
192
+ `definePlugin`, `defineMiddleware` and `defineInterceptor.{request,response,error}` are identity functions that give you the parameter types without annotations.
171
193
 
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.
194
+ **Refresh a session on 401, then redo the request.** The refresh is flow, so it is middleware. The header is a value, so it is a request interceptor — and because `next` re-runs the interceptors, the retried request carries the new token without extra code.
173
195
 
174
196
  ```ts
175
197
  import { definePlugin, isArthronError } from "arthron";
176
198
 
177
- export const auth = ({ getToken, refresh }: {
178
- getToken: () => string | null;
179
- refresh: () => Promise<void>;
180
- }) =>
199
+ export const auth = (store: { token: () => string | null; refresh: () => Promise<void> }) =>
181
200
  definePlugin({
182
201
  name: "auth",
183
202
  request: (ctx) => {
184
- const token = getToken();
203
+ const token = store.token();
185
204
  if (!token) return ctx;
186
205
  return { ...ctx, init: { ...ctx.init, headers: { ...ctx.init.headers, authorization: `Bearer ${token}` } } };
187
206
  },
@@ -190,53 +209,89 @@ export const auth = ({ getToken, refresh }: {
190
209
  return await next(ctx);
191
210
  } catch (e) {
192
211
  if (!isArthronError(e) || e.status !== 401) throw e;
193
- await refresh(); // throws → propagates; caller decides to log out
194
- return next(ctx); // whole attempt again, request interceptor adds the new token
212
+ await store.refresh(); // if this throws, the caller sees it
213
+ return next(ctx);
195
214
  }
196
215
  },
197
216
  });
198
217
  ```
199
218
 
200
- ### `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.
219
+ **Send users to an error page.** Error interceptors can look and act, but the error is thrown regardless — which is what you want for a side effect like navigation.
203
220
 
204
221
  ```ts
205
222
  import { defineInterceptor } from "arthron";
206
223
 
207
224
  export const errorRedirect = (navigate: (to: string) => void) =>
208
225
  defineInterceptor.error((err, ctx) => {
209
- 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
226
+ if ((ctx.init.method ?? "GET") !== "GET") return err;
227
+ if (err.status === 404) navigate("/not-found");
228
+ if (err.status >= 500) navigate("/error");
229
+ return err;
213
230
  });
214
231
 
215
232
  api.use({ error: errorRedirect(router.push) });
216
233
  ```
217
234
 
218
- ### `timeout` — a four-line middleware
235
+ **Attach a trace id.** A one-line request interceptor.
236
+
237
+ ```ts
238
+ api.use({ request: (ctx) => ({ ...ctx, init: { ...ctx.init, headers: { ...ctx.init.headers, "x-trace-id": crypto.randomUUID() } } }) });
239
+ ```
240
+
241
+ ## Reference
242
+
243
+ ### `arthron(config)`
244
+
245
+ ```ts
246
+ type ArthronConfig = {
247
+ baseURL?: string;
248
+ headers?: HeadersInit;
249
+ credentials?: RequestCredentials;
250
+ fetch?: typeof fetch; // inject for tests or a polyfill; resolved per request
251
+ use?: Plugin[];
252
+ errorMessage?: (data: unknown, res: Response) => string | undefined;
253
+ };
254
+ ```
219
255
 
220
- `AbortSignal.any` needs Node 20.3+ or a current browser; on Node 18 combine the signals with an `AbortController` by hand.
256
+ ### Client
221
257
 
222
258
  ```ts
223
- import { defineMiddleware } from "arthron";
259
+ get(path, init) · delete(path, init) · head(path, init)
260
+ post(path, body, init) · put(path, body, init) · patch(path, body, init)
261
+ request(path, { method, body, ...init })
262
+ use(...plugins) → new client
263
+ extend(config) → new client
264
+ ```
265
+
266
+ All arguments after `path` are optional, except `method` in `request`.
267
+
268
+ ### `ArthronError`
224
269
 
225
- 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
- );
270
+ ```ts
271
+ class ArthronError extends Error {
272
+ status: number; // 0 for network failures
273
+ statusText: string;
274
+ headers: Headers;
275
+ data: unknown;
276
+ response?: Response; // absent for network failures
277
+ request: RequestContext;
278
+ cause?: unknown; // the underlying error for network failures
279
+ }
229
280
  ```
230
281
 
231
- The resulting `TimeoutError` is rethrown untouched — it is not an `ArthronError`, so `retry` will not retry it.
282
+ ### Rules worth knowing
283
+
284
+ - **URLs are joined by string concatenation.** `baseURL: "https://a/v1"` + `"/users"` → `https://a/v1/users`. The `/v1` survives, unlike with `new URL()`. Absolute URLs bypass `baseURL`.
285
+ - **Bodies.** `string`, `FormData`, `Blob`, `ArrayBuffer`/views, `URLSearchParams` and `ReadableStream` go through untouched. Anything else — including `null` — becomes JSON with `content-type: application/json` unless you set a content type. `GET`/`HEAD` with a body is rejected with a `TypeError`.
286
+ - **`credentials` is not set unless you set it.** Cross-origin cookies need `credentials: "include"`.
287
+ - **Response bodies are single-use.** A response interceptor that wants to inspect the body must `res.clone()`; one that changes it returns a new `Response`.
288
+ - **Argument errors reject, they don't throw.** No `baseURL` with a relative path, a `BigInt` in a JSON body, a missing `method` in `request` — all surface as a rejected promise.
232
289
 
233
- ## Gotchas
290
+ ## For tooling and agents
234
291
 
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.
292
+ - `llms.txt` ships in the package — a compressed API reference meant to be read by LLM-based tools (`node_modules/arthron/llms.txt`).
293
+ - `examples/` holds type-checked, runnable recipes: LLM streaming, auth refresh, upload/download.
294
+ - Error messages state the fix (`… — pass baseURL in arthron({ baseURL }) or use an absolute URL`).
240
295
 
241
296
  ## License
242
297