arthron 0.1.0 → 1.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/README.md +125 -48
- package/dist/index.cjs +752 -268
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +308 -83
- package/dist/index.d.mts +308 -83
- package/dist/index.mjs +752 -269
- package/dist/index.mjs.map +1 -1
- package/llms.txt +46 -30
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -14,9 +14,9 @@ const user = await api.get("/me").json<User>();
|
|
|
14
14
|
```
|
|
15
15
|
|
|
16
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
|
|
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, timeouts, logging, 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.
|
|
18
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
|
|
19
|
+
- Zero dependencies. ESM and CJS. TypeScript types included, for TypeScript 5.0+. Node 22+ and browsers.
|
|
20
20
|
|
|
21
21
|
## Install
|
|
22
22
|
|
|
@@ -33,11 +33,12 @@ const api = arthron({
|
|
|
33
33
|
baseURL: "https://api.example.com", // prefix for relative paths
|
|
34
34
|
headers: { accept: "application/json" }, // defaults; per-call headers win
|
|
35
35
|
credentials: "include", // only if you need cookies cross-origin
|
|
36
|
+
cache: "no-store", // any fetch option works as a default; per-call values win
|
|
36
37
|
use: [retry()], // plugins, outermost first
|
|
37
38
|
});
|
|
38
39
|
```
|
|
39
40
|
|
|
40
|
-
Every field is optional. Without `baseURL`, only absolute URLs are accepted.
|
|
41
|
+
Every field is optional. Without `baseURL`, only absolute URLs are accepted. With one, an absolute URL must stay on `baseURL`'s origin unless you set `allowAbsoluteUrls: true` — and even then config `headers` are only sent to that origin.
|
|
41
42
|
|
|
42
43
|
### Make requests
|
|
43
44
|
|
|
@@ -47,6 +48,7 @@ await api.post("/users", { name: "kim" }); // object body → JSON
|
|
|
47
48
|
await api.post("/upload", formData); // FormData/Blob/string/stream → sent as-is
|
|
48
49
|
await api.put("/users/1", { name: "lee" });
|
|
49
50
|
await api.patch("/users/1", { name: "lee" });
|
|
51
|
+
await api.query("/search", { filter: { tag: "x" } }); // RFC 10008 QUERY: GET semantics, body carries the query
|
|
50
52
|
await api.delete("/users/1");
|
|
51
53
|
await api.head("/users/1");
|
|
52
54
|
await api.request("/users", { method: "OPTIONS" });
|
|
@@ -57,13 +59,16 @@ Each call returns `Promise<Response>` with body helpers attached:
|
|
|
57
59
|
```ts
|
|
58
60
|
const res = await api.get("/me"); // Response — status, headers, body untouched
|
|
59
61
|
const user = await api.get("/me").json<User>(); // body parsed; T is a cast, not validation
|
|
62
|
+
const safe = await api.get("/me").json(UserSchema); // validated by any Standard Schema (Zod, Valibot, ArkType); typed from it
|
|
60
63
|
const text = await api.get("/robots.txt").text();
|
|
61
64
|
const blob = await api.get("/export").blob();
|
|
62
65
|
const stream = (await api.post("/chat", prompt)).body; // ReadableStream
|
|
63
66
|
for await (const e of api.post("/chat", prompt).sse()) {} // see "Stream responses"
|
|
64
67
|
```
|
|
65
68
|
|
|
66
|
-
`init` accepts everything `fetch` does (`signal`, `cache`, `headers`, …) plus `params
|
|
69
|
+
`init` accepts everything `fetch` does (`signal`, `cache`, `headers`, …) plus `params`: 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]`. Put ids into the path with `path`, not string interpolation: `api.get("/users/{id}", { path: { id } })` encodes each value into its own segment, so an id like `../admin` or `x?role=admin` cannot change which endpoint you call (`encodeURIComponent` alone lets `..` through). A `null` (or `undefined`) header value drops a client default for that call: `api.get("/public", { headers: { authorization: null } })`.
|
|
70
|
+
|
|
71
|
+
`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).
|
|
67
72
|
|
|
68
73
|
### Stream responses
|
|
69
74
|
|
|
@@ -78,7 +83,9 @@ for await (const { event, data } of api.post("/v1/chat/completions", body).sse()
|
|
|
78
83
|
for await (const row of api.get("/logs").ndjson<LogRow>()) console.log(row);
|
|
79
84
|
```
|
|
80
85
|
|
|
81
|
-
`.sse()`
|
|
86
|
+
`.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. 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.
|
|
87
|
+
|
|
88
|
+
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 `maxEventSize` (default 8 Mi), rejects the loop with a `RangeError` and closes the connection. Raise them per call if you expect bigger payloads: `.sse({ maxEventSize: 32 * 1024 * 1024 })`.
|
|
82
89
|
|
|
83
90
|
### Handle errors
|
|
84
91
|
|
|
@@ -91,13 +98,23 @@ try {
|
|
|
91
98
|
if (isArthronError(e)) {
|
|
92
99
|
e.status; // 404, or 0 when the network failed
|
|
93
100
|
e.data; // parsed body: JSON, else text, else undefined
|
|
94
|
-
e.
|
|
95
|
-
e.
|
|
101
|
+
e.detail; // the server's explanation (`message` → `error` → `error.message` → `detail`), or undefined — show this to users
|
|
102
|
+
e.message; // arthron's own: "GET https://api.example.com/users/404 failed with 404 Not Found" — for logs
|
|
103
|
+
e.response; // the failed Response, still readable (up to 64 KiB of its body)
|
|
104
|
+
e.request; // what was sent (url, init) — read it directly; it is left out of logs
|
|
96
105
|
}
|
|
97
106
|
}
|
|
98
107
|
```
|
|
99
108
|
|
|
100
|
-
|
|
109
|
+
`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. `request`, `response` and `headers` are not enumerable, and `toJSON()` returns only name, message, detail, status, method, a URL without query or userinfo, and `data`. So `JSON.stringify(e)`, `console.error(e)` and error trackers never print your tokens.
|
|
110
|
+
|
|
111
|
+
`message` and `detail` are kept apart on purpose. `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. `detail` is what the server said: the right thing for a toast (`e.detail ?? "Something went wrong"`), but untrusted — render it as text, never as HTML, and filter it if your gateway leaks internal routes in it.
|
|
112
|
+
|
|
113
|
+
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: (res) => res.status !== 404 })` returns the 404 to inspect. An ok response never rejects.
|
|
114
|
+
|
|
115
|
+
`.json(schema)` takes any [Standard Schema](https://standardschema.dev) — Zod, Valibot, ArkType and others — validates the body with it, and resolves with the schema's output (transforms included). A body that does not match rejects with a `SchemaError` 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.
|
|
116
|
+
|
|
117
|
+
A request that is aborted or times out rejects with the abort reason — `AbortError` / `TimeoutError`, or whatever you passed to `abort(reason)` — not an `ArthronError`.
|
|
101
118
|
|
|
102
119
|
### Derive clients
|
|
103
120
|
|
|
@@ -109,7 +126,7 @@ const authed = base.use(auth()); // base + auth
|
|
|
109
126
|
const admin = authed.extend({ headers: { "x-role": "admin" } }); // authed + header
|
|
110
127
|
```
|
|
111
128
|
|
|
112
|
-
`extend` shallow-merges the config, merges headers case-insensitively (child wins), and
|
|
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`.
|
|
113
130
|
|
|
114
131
|
## Plugins
|
|
115
132
|
|
|
@@ -117,14 +134,18 @@ A plugin is an object with any of four slots:
|
|
|
117
134
|
|
|
118
135
|
```ts
|
|
119
136
|
type Plugin = {
|
|
120
|
-
name?: string;
|
|
137
|
+
name?: string; // identity: use/extend replace the plugin of the same name, without(name) removes it
|
|
121
138
|
middleware?: (ctx: RequestContext, next: (ctx: RequestContext) => Promise<Response>) => Promise<Response>;
|
|
122
139
|
request?: (ctx: RequestContext) => RequestContext | Promise<RequestContext>;
|
|
123
140
|
response?: (res: Response, ctx: RequestContext) => Response | Promise<Response>;
|
|
124
141
|
error?: (err: ArthronError, ctx: RequestContext) => ArthronError | Promise<ArthronError>;
|
|
125
142
|
};
|
|
126
143
|
|
|
127
|
-
type RequestContext = {
|
|
144
|
+
type RequestContext = {
|
|
145
|
+
url: string;
|
|
146
|
+
init: Omit<RequestInit, "headers"> & { headers: Record<string, string> }; // lowercase names
|
|
147
|
+
meta: ArthronMeta; // per-call plugin settings, never sent
|
|
148
|
+
};
|
|
128
149
|
```
|
|
129
150
|
|
|
130
151
|
How one request runs:
|
|
@@ -142,9 +163,9 @@ middleware 1
|
|
|
142
163
|
| Gets | `ctx` and `next` | A value: context, `Response`, or `ArthronError` |
|
|
143
164
|
| Can | Call `next` again (retry), skip it, add a deadline | Return a changed value |
|
|
144
165
|
| Cannot | — | Retry, skip, or stop the error from being thrown |
|
|
145
|
-
| Examples | `retry`, `timeout`, auth refresh | auth header, tracing id, error
|
|
166
|
+
| Examples | `retry`, `timeout`, `logging`, auth refresh, error pages | auth header, tracing id, per-attempt error details |
|
|
146
167
|
|
|
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.
|
|
168
|
+
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. `ctx.init.headers` is always a plain record with lowercased names, restored to that shape after every request interceptor, so `{ ...ctx.init.headers, name: value }` is safe.
|
|
148
169
|
|
|
149
170
|
### Built in
|
|
150
171
|
|
|
@@ -154,20 +175,26 @@ Re-runs the attempt on network failure or a retryable status. Because it re-runs
|
|
|
154
175
|
|
|
155
176
|
| option | default | |
|
|
156
177
|
|---|---|---|
|
|
157
|
-
| `retries` | `2` | retries after the first attempt (3 requests total) |
|
|
178
|
+
| `retries` | `2` | retries after the first attempt (3 requests total); a whole number ≥ 0 |
|
|
158
179
|
| `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 |
|
|
180
|
+
| `methods` | `GET HEAD OPTIONS QUERY PUT DELETE` | case-insensitive |
|
|
181
|
+
| `delay` | `(retry) => Math.random() * 300 * 2 ** retry` | ms; `retry` counts from 0. Full jitter, so clients that failed together don't retry together |
|
|
161
182
|
| `maxRetryAfter` | `60_000` | a `Retry-After` longer than this means "don't retry" |
|
|
183
|
+
| `shouldRetry` | — | `(error, attempt, ctx) => boolean \| undefined`: `true` retries, `false` stops, `undefined` uses the rules above. Sees every error, an inner `TimeoutError` included |
|
|
184
|
+
| `onRetry` | — | `(error, attempt, waitMs) => void`, before each wait |
|
|
185
|
+
|
|
186
|
+
Honours `Retry-After` (seconds or HTTP date). Never retries a `ReadableStream` body or an aborted request, and never more than `retries` times — `shouldRetry` cannot lift those.
|
|
162
187
|
|
|
163
|
-
|
|
188
|
+
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`.
|
|
164
189
|
|
|
165
|
-
#### `timeout(ms)`
|
|
190
|
+
#### `timeout(ms, { idle? })`
|
|
166
191
|
|
|
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.
|
|
192
|
+
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
|
+
|
|
194
|
+
A total deadline also cuts off a stream that is still flowing. For streams, bound the silence instead: `timeout(Infinity, { 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(600_000, { idle: 30_000 })`.
|
|
168
195
|
|
|
169
196
|
```ts
|
|
170
|
-
arthron({ use: [retry(), timeout(5_000)] }); // 5 s per attempt
|
|
197
|
+
arthron({ use: [retry(), timeout(5_000)] }); // 5 s per attempt — a timed-out attempt is not retried
|
|
171
198
|
arthron({ use: [timeout(5_000), retry()] }); // 5 s for all attempts together
|
|
172
199
|
```
|
|
173
200
|
|
|
@@ -191,47 +218,77 @@ Query strings and `user:pass@` are never printed. Cancellations are not logged.
|
|
|
191
218
|
|
|
192
219
|
`definePlugin`, `defineMiddleware` and `defineInterceptor.{request,response,error}` are identity functions that give you the parameter types without annotations.
|
|
193
220
|
|
|
194
|
-
**
|
|
221
|
+
**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
|
+
|
|
223
|
+
```ts
|
|
224
|
+
declare module "arthron" {
|
|
225
|
+
interface ArthronMeta {
|
|
226
|
+
skipAuth?: boolean;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
api.use({ request: (ctx) => (ctx.meta.skipAuth ? ctx : withToken(ctx)) });
|
|
231
|
+
api.get("/health", { meta: { skipAuth: true } });
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
**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. Two details matter in production: concurrent 401s must share one refresh (with refresh-token rotation, parallel refreshes revoke each other), and the token must only go to your API's origin. Tested in [`examples/auth-refresh.ts`](examples/auth-refresh.ts).
|
|
195
235
|
|
|
196
236
|
```ts
|
|
197
237
|
import { definePlugin, isArthronError } from "arthron";
|
|
198
238
|
|
|
199
|
-
export const auth = (store: { token: () => string | null; refresh: () => Promise<void> }) =>
|
|
200
|
-
|
|
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({
|
|
201
243
|
name: "auth",
|
|
202
244
|
request: (ctx) => {
|
|
203
|
-
const
|
|
204
|
-
if (!
|
|
205
|
-
return { ...ctx, init: { ...ctx.init, headers: { ...ctx.init.headers, authorization:
|
|
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 } } };
|
|
206
248
|
},
|
|
207
249
|
middleware: async (ctx, next) => {
|
|
208
250
|
try {
|
|
209
251
|
return await next(ctx);
|
|
210
252
|
} catch (e) {
|
|
211
253
|
if (!isArthronError(e) || e.status !== 401) throw e;
|
|
212
|
-
|
|
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
|
+
}
|
|
213
259
|
return next(ctx);
|
|
214
260
|
}
|
|
215
261
|
},
|
|
216
262
|
});
|
|
263
|
+
};
|
|
217
264
|
```
|
|
218
265
|
|
|
219
|
-
**Send users to an error page.**
|
|
266
|
+
**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).
|
|
220
267
|
|
|
221
268
|
```ts
|
|
222
|
-
import {
|
|
269
|
+
import { definePlugin, isArthronError } from "arthron";
|
|
223
270
|
|
|
224
271
|
export const errorRedirect = (navigate: (to: string) => void) =>
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
272
|
+
definePlugin({
|
|
273
|
+
name: "errorRedirect",
|
|
274
|
+
middleware: async (ctx, next) => {
|
|
275
|
+
try {
|
|
276
|
+
return await next(ctx);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
if (isArthronError(error) && (ctx.init.method ?? "GET") === "GET") {
|
|
279
|
+
if (error.status === 404) navigate("/not-found");
|
|
280
|
+
else if (error.status >= 500) navigate("/error");
|
|
281
|
+
}
|
|
282
|
+
throw error;
|
|
283
|
+
}
|
|
284
|
+
},
|
|
230
285
|
});
|
|
231
286
|
|
|
232
|
-
api
|
|
287
|
+
const api = arthron({ baseURL: API, use: [errorRedirect(router.push), retry()] }); // first: wraps retry
|
|
233
288
|
```
|
|
234
289
|
|
|
290
|
+
The same shape maps errors to your own types: `catch (e) { throw toDomainError(e); }` in an outermost middleware runs once per call, after retries.
|
|
291
|
+
|
|
235
292
|
**Attach a trace id.** A one-line request interceptor.
|
|
236
293
|
|
|
237
294
|
```ts
|
|
@@ -245,11 +302,15 @@ api.use({ request: (ctx) => ({ ...ctx, init: { ...ctx.init, headers: { ...ctx.in
|
|
|
245
302
|
```ts
|
|
246
303
|
type ArthronConfig = {
|
|
247
304
|
baseURL?: string;
|
|
248
|
-
|
|
249
|
-
|
|
305
|
+
allowAbsoluteUrls?: boolean; // let absolute URLs leave baseURL's origin (without config headers)
|
|
306
|
+
headers?: HeadersInit | Record<string, string | null>; // null drops a header (in extend)
|
|
250
307
|
fetch?: typeof fetch; // inject for tests or a polyfill; resolved per request
|
|
251
308
|
use?: Plugin[];
|
|
252
|
-
|
|
309
|
+
errorDetail?: (data: unknown, res: Response) => string | undefined; // picks ArthronError.detail out of the body
|
|
310
|
+
throwOn?: (res: Response) => boolean; // which non-ok responses reject (also per call)
|
|
311
|
+
// …plus any fetch option as a default: credentials, cache, redirect, mode,
|
|
312
|
+
// keepalive, referrerPolicy, priority, integrity. Not body, method or signal —
|
|
313
|
+
// those belong to one request (passing them throws).
|
|
253
314
|
};
|
|
254
315
|
```
|
|
255
316
|
|
|
@@ -258,12 +319,16 @@ type ArthronConfig = {
|
|
|
258
319
|
```ts
|
|
259
320
|
get(path, init) · delete(path, init) · head(path, init)
|
|
260
321
|
post(path, body, init) · put(path, body, init) · patch(path, body, init)
|
|
322
|
+
query(path, body, init) // RFC 10008 QUERY
|
|
261
323
|
request(path, { method, body, ...init })
|
|
262
|
-
use(...plugins) → new client
|
|
324
|
+
use(...plugins) → new client (same-name plugins replaced in place)
|
|
263
325
|
extend(config) → new client
|
|
326
|
+
without(...names) → new client without those plugins
|
|
264
327
|
```
|
|
265
328
|
|
|
266
|
-
All arguments after `path` are optional, except `method` in `request`.
|
|
329
|
+
All arguments after `path` are optional, except `method` in `request`. `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.
|
|
330
|
+
|
|
331
|
+
The public object types — `ArthronConfig`, `RequestOptions`, `RequestContext`, `ArthronMeta`, `Plugin`, `ArthronPromise`, `Arthron` — are interfaces, so a plugin package can add its own options with `declare module "arthron" { interface RequestOptions { … } }`.
|
|
267
332
|
|
|
268
333
|
### `ArthronError`
|
|
269
334
|
|
|
@@ -271,28 +336,40 @@ All arguments after `path` are optional, except `method` in `request`.
|
|
|
271
336
|
class ArthronError extends Error {
|
|
272
337
|
status: number; // 0 for network failures
|
|
273
338
|
statusText: string;
|
|
274
|
-
headers: Headers;
|
|
339
|
+
headers: Headers; // not enumerable
|
|
275
340
|
data: unknown;
|
|
276
|
-
|
|
277
|
-
|
|
341
|
+
detail?: string; // the server's explanation, picked out of data; untrusted text
|
|
342
|
+
response?: Response; // absent for network failures; not enumerable
|
|
343
|
+
request: RequestContext; // not enumerable — holds credentials
|
|
278
344
|
cause?: unknown; // the underlying error for network failures
|
|
345
|
+
toJSON(): { name; message; detail; status; statusText; method; url; data }; // url without query/userinfo
|
|
279
346
|
}
|
|
280
347
|
```
|
|
281
348
|
|
|
282
349
|
### Rules worth knowing
|
|
283
350
|
|
|
284
|
-
- **
|
|
285
|
-
- **
|
|
286
|
-
-
|
|
351
|
+
- **Fill path segments with `path`.** `{name}` placeholders are encoded per segment; `""`, `.` and `..` are refused, as are missing or unused values. Without `path`, braces are left alone.
|
|
352
|
+
- **URLs are joined by string concatenation.** `baseURL: "https://a/v1"` + `"/users"` → `https://a/v1/users`. The `/v1` survives, unlike with `new URL()`.
|
|
353
|
+
- **Absolute URLs stay on `baseURL`'s origin.** `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. `allowAbsoluteUrls: true` lets other origins through, with per-call headers only. Only `http(s)` URLs are accepted.
|
|
354
|
+
- **Error bodies are read up to 64 KiB.** That is enough for any error message and keeps a huge or endless error body from filling memory. Past the limit `data` is the raw text and the rest of the body is cancelled.
|
|
355
|
+
- **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
|
+
- **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
|
+
- **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.
|
|
358
|
+
- **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.
|
|
287
359
|
- **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
|
|
360
|
+
- **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"`.
|
|
289
361
|
|
|
290
362
|
## For tooling and agents
|
|
291
363
|
|
|
364
|
+
- `CHANGELOG.md` lists every release and the semver rules arthron follows since 1.0.
|
|
292
365
|
- `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.
|
|
366
|
+
- `examples/` holds type-checked, runnable recipes: LLM streaming, auth refresh, an error page, upload/download. `examples/recipes.test.ts` keeps the auth and error-page recipes honest.
|
|
294
367
|
- Error messages state the fix (`… — pass baseURL in arthron({ baseURL }) or use an absolute URL`).
|
|
295
368
|
|
|
369
|
+
## Security
|
|
370
|
+
|
|
371
|
+
Report vulnerabilities privately through GitHub — see [SECURITY.md](SECURITY.md) for supported versions, response times and disclosure.
|
|
372
|
+
|
|
296
373
|
## License
|
|
297
374
|
|
|
298
375
|
MIT
|