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 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
- # Temporary Holding Version
1
+ # arthron
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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