arthron 1.2.0 → 2.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 +76 -413
- package/dist/index.cjs +287 -170
- package/dist/index.d.cts +158 -115
- package/dist/index.d.mts +158 -115
- package/dist/index.mjs +269 -151
- package/dist/plugins.cjs +866 -6
- package/dist/plugins.d.cts +138 -2
- package/dist/plugins.d.mts +138 -2
- package/dist/plugins.mjs +862 -1
- package/dist/shared.cjs +751 -0
- package/dist/shared.d.cts +239 -0
- package/dist/shared.d.mts +239 -0
- package/dist/shared.mjs +602 -0
- package/llms.txt +48 -45
- package/package.json +9 -3
- package/dist/index-C3lylSNd.d.cts +0 -333
- package/dist/index-C3lylSNd.d.mts +0 -333
- package/dist/index.cjs.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/plugins-CHCMxtAE.mjs +0 -1166
- package/dist/plugins-CHCMxtAE.mjs.map +0 -1
- package/dist/plugins-Cigqz2SS.cjs +0 -1279
- package/dist/plugins-Cigqz2SS.cjs.map +0 -1
package/llms.txt
CHANGED
|
@@ -1,97 +1,100 @@
|
|
|
1
1
|
# arthron
|
|
2
2
|
|
|
3
|
-
> A fetch client
|
|
3
|
+
> A lightweight fetch client you assemble from plugins. Returns native `Response`; middleware controls flow (can retry via `next`), interceptors transform values. Zero dependencies, ESM + CJS, Node 22+ and browsers, TypeScript 5.0+.
|
|
4
4
|
|
|
5
|
-
Import: `import { arthron, isArthronError,
|
|
5
|
+
Import: `import { arthron, isArthronError, ArthronError, SchemaMismatchError, createPlugin } from "arthron";` and the built-in plugins `import { bearerAuth, retry, timeout, logging, uploadProgress } from "arthron/plugins";` (only there — not from "arthron"). The plugins' types (`RetryOptions`, `TimeoutOptions`, `BearerAuthOptions`, `LoggingOptions`, `LogEvent`, `UploadProgressEvent`) also come from "arthron/plugins" only. Times are in ms everywhere.
|
|
6
|
+
|
|
7
|
+
From 1.x: most old names throw a TypeError that names the new one — `use`→`plugins`, `fetch`→`fetchFn`, `errorDetail`→`extractDescription`, `allowAbsoluteUrls`→`allowedOrigins`, `params`/`path`→`queryParams`/`pathParams`, plugin `name`→`pluginName`, slots `request`/`response`/`error`→`requestInterceptor`/`responseInterceptor`/`errorInterceptor`, `requestContext.fetch`→`requestContext.fetchFn`, `token`/`refresh`→`accessTokenFn`/`refreshFn`, bearerAuth `origin`→`tokenOrigins` (a list), `retryOn`/`delay`→`statuses`/`delayFn`, `meta.onUploadProgress`→`meta.uploadProgress: { onProgress }`. Renamed fields and types are TypeScript errors: `e.detail`/`e.data`/`e.request`→`e.description`/`e.payload`/`e.requestContext`, `definePlugin`→`createPlugin`, `SchemaError`→`SchemaMismatchError`, `StandardSchemaV1`/`InferOutput`/`SchemaIssue`→`ArthronSchema`/`ArthronSchema.Infer`/`ArthronSchema.Issue`, SSE `event`/`retry`/`maxEventSize`→`type`/`reconnectionTime`/`maxDataLength`, upload `progress`→`fraction`, `LogEvent` `path`/`ms`→`url`/`duration`.
|
|
6
8
|
|
|
7
9
|
## Create
|
|
8
10
|
|
|
9
11
|
```ts
|
|
10
12
|
const api = arthron({
|
|
11
|
-
baseURL?: string, // prefix for relative paths; required unless you pass absolute URLs
|
|
12
|
-
|
|
13
|
-
headers?: HeadersInit,
|
|
13
|
+
baseURL?: string, // prefix for relative paths; required unless you pass absolute URLs; an http(s) one without a host → TypeError
|
|
14
|
+
allowedOrigins?: readonly string[], // other origins an absolute URL may go to (origin of each URL; http(s) only; empty or a single string → TypeError); without baseURL it alone limits absolute URLs, and config headers are refused (TypeError)
|
|
15
|
+
headers?: HeadersInit | Record<string, string | null | undefined>, // defaults, sent to baseURL's origin only; per-call headers win (case-insensitive); { name: null } in extend/per-call drops one
|
|
14
16
|
credentials?, cache?, redirect?, mode?, keepalive?, … // any fetch option (RequestInit minus body/method/headers/signal/window) as a default; per-call wins; NOT set unless you set it; passing signal/body/method throws
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
throwOn?: (
|
|
17
|
+
fetchFn?: typeof fetch, // the function requests are sent with (fake in tests, polyfill, framework fetch); default the global fetch, read per request; not a function → TypeError
|
|
18
|
+
plugins?: readonly Plugin[], // outermost first; one with the pluginName of an earlier one replaces it
|
|
19
|
+
extractDescription?: (payload: unknown, response: Response) => string | undefined, // extracts ArthronError.description; failed responses only; if it throws, description is undefined
|
|
20
|
+
throwOn?: (response: Response) => boolean, // which non-ok responses reject; also a per-call option
|
|
19
21
|
});
|
|
20
22
|
```
|
|
21
23
|
|
|
22
24
|
## Request
|
|
23
25
|
|
|
24
26
|
```ts
|
|
25
|
-
api.get(path,
|
|
26
|
-
api.post(path, body?,
|
|
27
|
-
api.request(path, { method, body?, ...
|
|
28
|
-
//
|
|
27
|
+
api.get(path, requestOptions?) | api.delete(path, requestOptions?) | api.head(path, requestOptions?)
|
|
28
|
+
api.post(path, body?, requestOptions?) | api.put(…) | api.patch(…) | api.query(path, body?, requestOptions?) // QUERY = RFC 10008
|
|
29
|
+
api.request(path, { method, body?, ...requestOptions }) // method: HttpMethod (GET…QUERY autocomplete, any string allowed); standard ones sent uppercased; no method → TypeError
|
|
30
|
+
// requestOptions = RequestInit minus method/body, plus queryParams?: Record<string, scalar | scalar[] | null | undefined> | URLSearchParams (arrays → repeated keys; object values → TypeError); headers values may be null to drop a default; meta?: ArthronMeta (plugin settings, not sent); pathParams?: Record<string, string|number> fills {name} placeholders, encoded per segment ("", ".", ".." refused) — use it instead of interpolating ids
|
|
29
31
|
```
|
|
30
32
|
|
|
31
|
-
Every method returns `Promise<Response>` plus helpers:
|
|
32
|
-
`.json<T>()` (cast) / `.json(schema)` (
|
|
33
|
+
Every request method returns `Promise<Response>` plus helpers:
|
|
34
|
+
`.json<T>()` (cast) / `.json(schema)` (an `ArthronSchema`: `StandardSchemaV1` from @standard-schema/spec under arthron's name — pass a Zod, Valibot or ArkType schema as is; typed as `ArthronSchema.Infer<S>`; a mismatch → `SchemaMismatchError` with `.issues: ArthronSchema.Issue[]`, not an ArthronError) `.text()` `.blob()` `.arrayBuffer()` `.formData()` `.sse({ maxLineLength?, maxDataLength? }?): AsyncIterable<{ type, data, id?, lastEventId, reconnectionTime? }>` (`type` is the `event:` field, `"message"` when there is none, as in a browser; `lastEventId` and `reconnectionTime` (ms, from `retry:`) persist across events, per spec; no reconnection) `.ndjson<T>({ maxLineLength? }?): AsyncIterable<T>` — limits default to 8 Mi characters; exceeding one rejects with RangeError and cancels the body
|
|
33
35
|
|
|
34
36
|
- Body: string/FormData/Blob/ArrayBuffer/URLSearchParams/ReadableStream sent as-is (matched by type tag, so polyfill/cross-realm instances count; streams get `duplex: "half"`; FormData is sent without content-type so the runtime sets the boundary). Any other value (incl. null) → JSON + `content-type: application/json`; a content type you set must be JSON-ish (else TypeError). Map/Set/iterables/async iterables (Node streams)/functions → TypeError. GET/HEAD with body → TypeError.
|
|
35
|
-
- URL: `baseURL` + path by string concatenation (`"https://a/v1" + "/u"` → `https://a/v1/u`). An absolute path must be http(s)
|
|
36
|
-
- Argument errors (no baseURL, URL
|
|
37
|
-
- `.sse()`/`.ndjson()`: leaving the loop early cancels the body. `.sse()` drops an event the
|
|
37
|
+
- URL: `baseURL` + path by string concatenation (`"https://a/v1" + "/u"` → `https://a/v1/u`). An absolute path must be http(s); with a baseURL it must be on baseURL's origin or in `allowedOrigins`, without one in `allowedOrigins` if set — else TypeError (with neither set, any http(s) URL goes). Config headers go to baseURL's origin only, never to an `allowedOrigins` entry (calls there carry per-call headers alone); a client with neither baseURL nor allowedOrigins sends them everywhere. On cross-origin redirects fetch drops Authorization but keeps custom headers (x-api-key) — use `redirect: "error"` where that matters. `queryParams` appended with `&` if a query exists, placed before `#fragment`.
|
|
38
|
+
- Argument errors (no baseURL, a URL that does not parse, BigInt body, missing method) reject the promise with TypeError before fetch; they do not throw synchronously and are never retried. URLs are pre-checked only for the global fetch; a `fetchFn` you pass decides its own URL rules (relative URLs are fine if it accepts them).
|
|
39
|
+
- `.sse()`/`.ndjson()`: leaving the loop early cancels the body. `.sse()` drops an incomplete event at the end of the stream (spec).
|
|
38
40
|
|
|
39
41
|
## Errors
|
|
40
42
|
|
|
41
43
|
```ts
|
|
42
44
|
class ArthronError extends Error {
|
|
45
|
+
constructor(options: ArthronErrorOptions); // { message, status, statusText, headers, payload, description?, requestContext, response?, cause? } — for tests and plugins
|
|
43
46
|
status: number; // HTTP status, 0 = network failure
|
|
44
47
|
statusText: string;
|
|
45
|
-
headers: Headers; // not enumerable
|
|
48
|
+
headers: Headers; // the response's headers; not enumerable
|
|
46
49
|
message: string; // arthron's: "GET https://api.test/users/1 failed with 404 Not Found" / "… failed: fetch failed" — no query, no server text
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
description: string | undefined; // for people: the server's description of the failure (payload message → error → error.message → detail, or extractDescription); untrusted — show as text
|
|
51
|
+
payload: unknown; // the failed response's body: JSON if parseable, else text, else undefined; at most 64 KiB read (text if cut)
|
|
49
52
|
response?: Response; // still readable, holding what was read (≤ 64 KiB); absent for network failure; not enumerable
|
|
50
|
-
|
|
51
|
-
cause?: unknown; // underlying error for network failure
|
|
52
|
-
toJSON(): { name, message,
|
|
53
|
+
requestContext: RequestContext; // the requestContext plugins see, as sent: url, init (method, headers as a lowercase record, body), meta — not a fetch Request; not enumerable (holds credentials)
|
|
54
|
+
cause?: unknown; // underlying error for network failure (URL query/userinfo redacted in it too)
|
|
55
|
+
toJSON(): { name, message, description, status, statusText, method, url /* no query/userinfo */, payload };
|
|
53
56
|
}
|
|
54
57
|
isArthronError(e): e is ArthronError // branded with Symbol.for: works (and instanceof works) across the ESM and CJS copies
|
|
55
58
|
```
|
|
56
59
|
|
|
57
|
-
- Only `!response.ok` and fetch rejection become ArthronError — and not every non-ok: opaque responses (no-cors) and the 3xx of `redirect: "manual"` are returned; `throwOn(
|
|
58
|
-
-
|
|
59
|
-
- `message` is arthron's (for logs); `
|
|
60
|
+
- Only `!response.ok` and fetch rejection become ArthronError — and not every non-ok: opaque responses (no-cors) and the 3xx of `redirect: "manual"` are returned; `throwOn(response)` (config or per call; the call's wins) replaces that default — it decides for every non-ok response, opaque and manual 3xx included. An ok response never rejects. Aborts (`AbortError`/`TimeoutError` or any custom `abort(reason)`, including during the error-body read) and exceptions thrown by plugins propagate untouched and skip error interceptors.
|
|
61
|
+
- ArthronError keeps requestContext, response and response headers non-enumerable; toJSON() omits request headers, body, URL query and userinfo. It includes server-provided payload and description, which may contain secrets if the API puts them there. The built-in logger prints a redacted URL and status; its onEvent callback receives the error object.
|
|
62
|
+
- `message` is arthron's (for logs); `description` is the server's (for people: `e.description ?? fallback`). Default `description`: payload `message` → `error` (string) → `error.message` → `detail`, else undefined. Override with `extractDescription: (payload) => (payload as { msg?: string }).msg`.
|
|
60
63
|
|
|
61
64
|
## Plugins
|
|
62
65
|
|
|
63
66
|
```ts
|
|
64
67
|
type Plugin = {
|
|
65
|
-
|
|
66
|
-
middleware?: (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
pluginName: string; // required — identity: use/extend replace a plugin with the same pluginName in place; without(pluginName) removes it. Not `name`: a function has one, and would pass for a plugin
|
|
69
|
+
middleware?: (requestContext, next: (requestContext) => Promise<Response>) => Promise<Response>; // wraps the call; may call next again (retry) or not at all
|
|
70
|
+
requestInterceptor?: (requestContext) => requestContext; // per attempt, before fetch
|
|
71
|
+
responseInterceptor?: (response, requestContext) => Response; // per successful attempt; body is single-use, clone() to peek
|
|
72
|
+
errorInterceptor?: (error: ArthronError, requestContext) => ArthronError; // per failed attempt; return value is always thrown
|
|
70
73
|
};
|
|
71
|
-
type RequestContext = { url: string; init: Omit<RequestInit, "headers"> & { headers: Record<string, string> }; meta: ArthronMeta;
|
|
72
|
-
// meta: per-call plugin settings from api.get(path, { meta }), never sent.
|
|
74
|
+
type RequestContext = { url: string; init: Omit<RequestInit, "headers"> & { headers: Record<string, string> }; meta: ArthronMeta; fetchFn?: typeof fetch }; // fetchFn: the function this attempt is sent with (client's, else global) — a plugin may replace or wrap it per attempt, and a failure through it still becomes an ArthronError; one that is not a function, or the 1.x requestContext.fetch, → TypeError. Call it as requestContext.fetchFn(…) only after copying it to a variable: as a method, the browser's fetch throws "Illegal invocation". headers: lowercase record, re-normalized after each request interceptor — spreading is safe
|
|
75
|
+
// meta: per-call plugin settings from api.get(path, { meta }), never sent. Each built-in with a per-call setting reads the key named after it: meta.bearerAuth (false), meta.retry (false | options), meta.timeout (false | ms | { total?, idle? }), meta.uploadProgress ({ onProgress }).
|
|
73
76
|
// Add keys: declare module "arthron" { interface ArthronMeta { skipTrace?: boolean } }
|
|
74
77
|
```
|
|
75
78
|
|
|
76
|
-
Order: `middleware[0]( middleware[1]( attempt: request interceptors → fetch → response | error interceptors ) )` — a middleware
|
|
79
|
+
Order: `middleware[0]( middleware[1]( attempt: request interceptors → fetch → response | error interceptors ) )` — the outermost middleware runs once per call; one below a middleware that calls `next` again runs again each time; code after `await next(requestContext)` runs after the response interceptors. Inside `try`, write `return await next(requestContext)` or the catch misses the error. Default order: `[logging(), bearerAuth(…), timeout(ms), retry()]` (timeout before retry = whole call, after = per attempt). Plugins with the same pluginName replace each other; for two timeouts rename one: `{ ...timeout(30_000), pluginName: "callTimeout" }`. Values that must be fresh per attempt (token, signature) → request interceptor; the same on every attempt (idempotency key) → middleware before retry. Each attempt gets a fresh copy of requestContext (headers, and a FormData/URLSearchParams body).
|
|
77
80
|
|
|
78
|
-
`api.use(...plugins)`, `api.extend(
|
|
81
|
+
`api.use(...plugins)`, `api.extend(clientOptions)` and `api.without(...pluginNames)` return NEW clients; the original is unchanged. A plugin replaces the one with the same pluginName at its position (built-ins: "bearerAuth", "retry", "timeout", "logging", "uploadProgress"); other names append.
|
|
79
82
|
|
|
80
|
-
|
|
83
|
+
`createPlugin(p)` types a plugin and checks it (TypeError for: no pluginName, an unknown key — keep state in a closure, in a function that returns createPlugin({ … }), as the built-ins do — a 1.x `name` field or `request`/`response`/`error` slot, a slot that is not a function, nothing to run, a bare function); `use`/`plugins` run the same check. `createMiddleware(m)`, `createRequestInterceptor(f)`, `createResponseInterceptor(f)`, `createErrorInterceptor(f)` type a piece written on its own (create + the type's name).
|
|
81
84
|
|
|
82
85
|
## Built-in plugins (from "arthron/plugins")
|
|
83
86
|
|
|
84
|
-
- `retry({ retries=2,
|
|
85
|
-
- `timeout(ms)` | `timeout({ total?, idle? })` — `total` (= `ms`): deadline from
|
|
86
|
-
- `bearerAuth({
|
|
87
|
-
- `logging({ enabled=true, logger=console, onEvent?,
|
|
88
|
-
- `uploadProgress()` — does nothing alone; a call with `meta: {
|
|
87
|
+
- `retry({ retries=2, statuses=[408,429,500,502,503,504], methods=[GET,HEAD,OPTIONS,QUERY,PUT,DELETE], delayFn=(attempt)=>Math.random()*300*2**attempt /* full jitter; Retry-After wins; must return a finite number ≥ 0, else TypeError */, maxRetryAfter=60000, shouldRetry?(error, attempt, requestContext) → true|false|undefined, onRetry?(error, attempt, delay) })` — `attempt` is 0-based in all three. Re-runs the whole attempt (request interceptors included). Honours Retry-After (seconds or HTTP-date). Never retries ReadableStream bodies or aborted requests, nor beyond `retries`, nor past a Retry-After longer than `maxRetryAfter`; by default never non-ArthronErrors (shouldRetry may opt in, e.g. an inner TimeoutError). With methods: ["POST"], network errors (status 0) are retried too — refuse them with shouldRetry where a duplicate would hurt. Per call: `meta: { retry: false }` or `meta: { retry: { retries: 5 } }`. `retries` must be a whole number ≥ 0 (TypeError otherwise); waits are capped at 2^31-1 ms.
|
|
88
|
+
- `timeout(ms)` | `timeout({ total?, idle? })` — `total` (= `ms`): deadline from the start of what it wraps until the body is fully read; `idle`: aborts after that many ms without data while arthron waits on the server (for the headers, then the next chunk, read one ahead of the caller; time the caller spends between reads does not count), so `timeout({ idle: 30_000 })` never cuts a flowing stream — nor a body left unread past its first chunk (cancel it, or add `total`). Both together: whichever trips first. `{}` → TypeError; so is an object second argument (the 1.x `timeout(ms, { idle })`). Rejects with TimeoutError (not ArthronError; retry does not repeat it unless shouldRetry returns true for it). Place after `retry` for per-attempt, before it for total. Per call: `meta: { timeout: false }` lifts both, `meta: { timeout: ms }` replaces total, `meta: { timeout: { idle: 30_000 } }` replaces the parts it sets. `Infinity` = no deadline; NaN/negative/>2^31-1 → TypeError. Keeps `res.url`/`redirected`/`type` (through `clone()` too); timers are unref'd.
|
|
89
|
+
- `bearerAuth({ tokenOrigins, accessTokenFn, refreshFn? })` — `authorization: Bearer <token>` on every attempt, only to requests on `tokenOrigins` (a list of URLs; only origins count; non-http(s), empty or a single string → TypeError; requests from a relative baseURL are on the page's origin, so list that origin — `"/api"` itself → TypeError). `accessTokenFn: () => string | null | undefined | Promise<…>`, called per attempt; empty → no header. With `refreshFn: () => Promise<void>` (refreshes the session so the next accessTokenFn() returns the new token), a 401 refreshes and redoes the call once: concurrent 401s share one refresh, a call whose token was already replaced retries without refreshing, a second 401 is thrown, a rejected refresh reaches every waiting call unchanged. Without `refreshFn`, or for a ReadableStream body (cannot be resent), 401 is thrown without refreshing. Per call: `meta: { bearerAuth: false }` = no token, no refresh — use it on the login/refresh requests if they use the same client. A request refreshFn starts before its first await gets it automatically; one started after an await must pass it (else its 401 waits on the refresh it belongs to). Token is opaque: refresh ahead of a JWT's exp inside `accessTokenFn`.
|
|
90
|
+
- `logging({ enabled=true, logger=console, onEvent?, timeouts=false })` — start/success/error lines or structured events `{ phase, method, url /* query and userinfo removed */, status?, duration? /* ms, retries included */, error? }`. An error without a status prints its name where the status goes (`TimeoutError`, `TypeError`). A cancelled call has a start event but no success or error event; with `timeouts: true` a TimeoutError before the response arrives gets an error event (status undefined) — one while the body is read only rejects that read, after the success event.
|
|
91
|
+
- `uploadProgress()` — does nothing alone; a call with `meta: { uploadProgress: { onProgress: ({ loaded, total, fraction }) => … } }` and a body reports upload progress (`fraction` is 0–1, not a percentage; total/fraction undefined when the size is unknown; restarts at 0 per attempt; no events after the response or an abort). Browser (incl. Electron renderer): sent over XMLHttpRequest (cross-origin → CORS preflight; response not streamed; redirect "manual"/"error", mode "no-cors", credentials "omit" to the page's origin, a referrerPolicy/referrer of its own, integrity → TypeError before sending; cache/priority/keepalive ignored; ReadableStream body → no progress). Node (incl. jsdom on Node): fetch with the body counted in ≤ 64 KiB steps, content-length and Blob content-type kept (S3 presigned PUT works); FormData encoded with a known length, files streamed. Deno/Bun/Cloudflare Workers/Electron main/service workers, a client fetchFn or another plugin's → sent as usual without progress. A throwing onProgress rejects the call with its own error (not ArthronError, not retried) — keep uploadProgress last in plugins. Works through requestContext.fetchFn.
|
|
89
92
|
|
|
90
93
|
## Recipes
|
|
91
94
|
|
|
92
95
|
Auth refresh — built in (`bearerAuth()` above; examples/auth-refresh.ts). The refresh request skips auth:
|
|
93
96
|
```ts
|
|
94
|
-
const api = arthron({ baseURL: API,
|
|
97
|
+
const api = arthron({ baseURL: API, plugins: [bearerAuth({ tokenOrigins: [API], accessTokenFn: () => tok, refreshFn: async () => {
|
|
95
98
|
tok = (await api.post("/token/refresh", undefined, { meta: { bearerAuth: false } }).json<{ access_token: string }>()).access_token;
|
|
96
99
|
} })] });
|
|
97
100
|
```
|
|
@@ -108,8 +111,8 @@ for await (const { data } of api.post("/v1/chat/completions", { model, messages,
|
|
|
108
111
|
|
|
109
112
|
## Non-goals
|
|
110
113
|
|
|
111
|
-
No request/response key transformation, no caching, no dedupe, no backend-envelope unwrapping. Do those in your
|
|
114
|
+
No request/response key transformation, no caching, no dedupe, no backend-envelope unwrapping. Do those in your own code around the client.
|
|
112
115
|
|
|
113
116
|
## Types
|
|
114
117
|
|
|
115
|
-
Public object types are interfaces (
|
|
118
|
+
Public object types are interfaces (ArthronClientOptions, RequestOptions, RequestOptionsWithMethod, RequestContext, ArthronMeta, Plugin, ResponsePromise, ArthronClient, ArthronSchema): extend them with `declare module "arthron" { interface … }`. Also exported: ArthronErrorOptions, DescriptionExtractor, QueryParams, HttpMethod, SseEvent, SseOptions, NdjsonOptions, Middleware and the interceptor types.
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "arthron",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"description": "A lightweight fetch client you assemble from plugins. Zero dependencies, returns native Response.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "lapidix",
|
|
7
|
+
"homepage": "https://lapidix.github.io/arthron/",
|
|
7
8
|
"repository": {
|
|
8
9
|
"type": "git",
|
|
9
10
|
"url": "git+https://github.com/lapidix/arthron.git"
|
|
@@ -61,11 +62,16 @@
|
|
|
61
62
|
"check:types-pkg": "attw --pack .",
|
|
62
63
|
"check:types-consumer": "node test/types-consumer/check.mjs",
|
|
63
64
|
"check:browsers": "pnpm --dir test/browser test",
|
|
64
|
-
"
|
|
65
|
+
"docs:dev": "pnpm --dir docs dev",
|
|
66
|
+
"docs:build": "pnpm --dir docs build",
|
|
67
|
+
"docs:preview": "pnpm --dir docs preview",
|
|
68
|
+
"release:approve": "node scripts/approve-release.mjs",
|
|
69
|
+
"prepublishOnly": "pnpm lint && pnpm typecheck && pnpm test && pnpm build && node scripts/smoke.mjs && pnpm check:types-pkg"
|
|
65
70
|
},
|
|
66
71
|
"devDependencies": {
|
|
67
72
|
"@arethetypeswrong/cli": "^0.18.5",
|
|
68
73
|
"@biomejs/biome": "^2.5.15",
|
|
74
|
+
"@standard-schema/spec": "^1.1.0",
|
|
69
75
|
"@types/node": "^26.6.5",
|
|
70
76
|
"tsdown": "^0.23.1",
|
|
71
77
|
"typescript": "^7.0.2",
|
|
@@ -1,333 +0,0 @@
|
|
|
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
|
-
* The fetch this attempt is sent with — the client's `fetch`, else the
|
|
16
|
-
* global one. Always set on contexts arthron creates; replace it to change
|
|
17
|
-
* the transport for this attempt, wrap it to observe. Optional so a
|
|
18
|
-
* context you build yourself stays valid.
|
|
19
|
-
*/
|
|
20
|
-
fetch?: typeof fetch;
|
|
21
|
-
}
|
|
22
|
-
/**
|
|
23
|
-
* Per-call settings for plugins, passed as `api.get(path, { meta })` and
|
|
24
|
-
* carried next to `init` — never sent to fetch. The built-in plugins read
|
|
25
|
-
* `bearerAuth`, `onUploadProgress`, `retry` and `timeout`; your own add keys
|
|
26
|
-
* by augmentation:
|
|
27
|
-
*
|
|
28
|
-
* ```ts
|
|
29
|
-
* declare module "arthron" {
|
|
30
|
-
* interface ArthronMeta { skipTrace?: boolean }
|
|
31
|
-
* }
|
|
32
|
-
* ```
|
|
33
|
-
*/
|
|
34
|
-
interface ArthronMeta {
|
|
35
|
-
/**
|
|
36
|
-
* `false` sends this call without the `bearerAuth()` token and never refreshes
|
|
37
|
-
* on its 401 — for the login and token-refresh endpoints.
|
|
38
|
-
*/
|
|
39
|
-
bearerAuth?: false;
|
|
40
|
-
/**
|
|
41
|
-
* Upload progress for this call, with `uploadProgress()` on the client.
|
|
42
|
-
* Never called without that plugin or without a body, nor where progress
|
|
43
|
-
* cannot be had: a client with an injected `fetch`, another plugin's
|
|
44
|
-
* transport, Deno, Bun, Cloudflare Workers, a service worker, or a
|
|
45
|
-
* `ReadableStream` body in a browser — those calls are sent as usual.
|
|
46
|
-
*/
|
|
47
|
-
onUploadProgress?: (event: UploadProgressEvent) => void;
|
|
48
|
-
/** `false` turns `retry()` off for this call; an object overrides its options. */
|
|
49
|
-
retry?: false | Omit<RetryOptions, "sleep">;
|
|
50
|
-
/** Deadline for this call in ms, overriding `timeout(ms)`. `Infinity` lifts it. */
|
|
51
|
-
timeout?: number;
|
|
52
|
-
}
|
|
53
|
-
/** One step of an upload, as `meta.onUploadProgress` receives it. */
|
|
54
|
-
type UploadProgressEvent = {
|
|
55
|
-
/** Bytes sent so far. */
|
|
56
|
-
loaded: number;
|
|
57
|
-
/** Bytes in the whole body; `undefined` when it cannot be known (a stream without content-length). */
|
|
58
|
-
total: number | undefined;
|
|
59
|
-
/** `loaded / total`, from 0 to 1; `undefined` when `total` is. */
|
|
60
|
-
progress: number | undefined;
|
|
61
|
-
};
|
|
62
|
-
type RetryOptions = {
|
|
63
|
-
/** Number of retries after the first attempt. Default 2 (3 requests total). */
|
|
64
|
-
retries?: number;
|
|
65
|
-
/** Statuses worth retrying. Network errors (status 0) always are. */
|
|
66
|
-
retryOn?: readonly number[];
|
|
67
|
-
/** Methods safe to repeat (QUERY is, per RFC 10008). Compared case-insensitively. */
|
|
68
|
-
methods?: readonly string[];
|
|
69
|
-
/**
|
|
70
|
-
* Backoff in ms for retry number `retry` (0-based). Default "full jitter":
|
|
71
|
-
* a random share of 300 · 2^retry, so clients that failed together do not
|
|
72
|
-
* retry together.
|
|
73
|
-
*/
|
|
74
|
-
delay?: (retry: number, error: unknown) => number;
|
|
75
|
-
/**
|
|
76
|
-
* Your own verdict on one failure: `true` retries, `false` stops,
|
|
77
|
-
* `undefined` leaves it to `retryOn`/`methods`. It sees every error — a
|
|
78
|
-
* TimeoutError from a `timeout()` placed after `retry()` included — but
|
|
79
|
-
* cannot exceed `retries`, resend a `ReadableStream` body, or retry a call
|
|
80
|
-
* the caller aborted.
|
|
81
|
-
*/
|
|
82
|
-
shouldRetry?: (error: unknown, attempt: number, ctx: RequestContext) => boolean | undefined;
|
|
83
|
-
/** Called before each wait, for logs and metrics. */
|
|
84
|
-
onRetry?: (error: unknown, attempt: number, waitMs: number) => void;
|
|
85
|
-
/** Injectable for tests. Default `setTimeout`; rejects with the abort reason. */
|
|
86
|
-
sleep?: (ms: number, signal?: AbortSignal | null) => Promise<void>;
|
|
87
|
-
/**
|
|
88
|
-
* Longest `Retry-After` (ms) worth honouring. Beyond it the request is not
|
|
89
|
-
* retried at all. Default 60 000.
|
|
90
|
-
*/
|
|
91
|
-
maxRetryAfter?: number;
|
|
92
|
-
};
|
|
93
|
-
type QueryValue = string | number | boolean | null | undefined;
|
|
94
|
-
/**
|
|
95
|
-
* Query parameters. `null`/`undefined` values are skipped, an array becomes
|
|
96
|
-
* repeated keys (`ids=1&ids=2`), and a `URLSearchParams` is used as is.
|
|
97
|
-
*/
|
|
98
|
-
type QueryParams = Record<string, QueryValue | readonly QueryValue[]> | URLSearchParams;
|
|
99
|
-
/**
|
|
100
|
-
* Headers as config and per-call options accept them: anything fetch's
|
|
101
|
-
* `headers` takes, or a plain object whose `null`/`undefined` values delete
|
|
102
|
-
* that header from the layer underneath (a client default, an inherited
|
|
103
|
-
* header). Not spelled `HeadersInit`: that name exists only in the DOM lib,
|
|
104
|
-
* and a Node project typed with `@types/node` alone does not have it.
|
|
105
|
-
*/
|
|
106
|
-
type HeadersOption = NonNullable<RequestInit["headers"]> | Record<string, string | null | undefined>;
|
|
107
|
-
//#endregion
|
|
108
|
-
//#region src/error/arthron-error.d.ts
|
|
109
|
-
type ArthronErrorInit = {
|
|
110
|
-
message: string;
|
|
111
|
-
status: number;
|
|
112
|
-
statusText: string;
|
|
113
|
-
headers: Headers;
|
|
114
|
-
data: unknown;
|
|
115
|
-
detail?: string | undefined;
|
|
116
|
-
request: RequestContext;
|
|
117
|
-
response?: Response;
|
|
118
|
-
cause?: unknown;
|
|
119
|
-
};
|
|
120
|
-
/**
|
|
121
|
-
* The only error arthron itself produces. Two sources: a response with
|
|
122
|
-
* `ok === false`, or fetch rejecting outright (network failure, `status` 0).
|
|
123
|
-
* Everything else — cancellation, interceptor bugs — is rethrown untouched.
|
|
124
|
-
*/
|
|
125
|
-
declare class ArthronError extends Error {
|
|
126
|
-
readonly name = "ArthronError";
|
|
127
|
-
/** HTTP status of the failed response; `0` when fetch itself failed (network). */
|
|
128
|
-
readonly status: number;
|
|
129
|
-
readonly statusText: string;
|
|
130
|
-
/** Response headers; empty for network failures. Not enumerable — kept out of logs. */
|
|
131
|
-
readonly headers: Headers;
|
|
132
|
-
/** Parsed body: JSON if it parses, else the text, else `undefined`. */
|
|
133
|
-
readonly data: unknown;
|
|
134
|
-
/**
|
|
135
|
-
* The server's own explanation, picked out of `data` (by default
|
|
136
|
-
* `message` → `error` → `error.message` → `detail`; see the `errorDetail`
|
|
137
|
-
* config). `undefined` when the body had none. Untrusted server text: show
|
|
138
|
-
* it to users as text, never as HTML. `message`, by contrast, is written by
|
|
139
|
-
* arthron — method, URL without query, status — and is meant for logs.
|
|
140
|
-
*/
|
|
141
|
-
readonly detail: string | undefined;
|
|
142
|
-
/**
|
|
143
|
-
* The context that was sent — url and init after request interceptors.
|
|
144
|
-
* Not enumerable: it holds credentials, so `JSON.stringify`, `console.error`
|
|
145
|
-
* and error trackers leave it out. Read it directly when you need it.
|
|
146
|
-
*/
|
|
147
|
-
readonly request: RequestContext;
|
|
148
|
-
/** The failed Response, still readable — its body holds what arthron read (at most 64 KiB). Absent for network failures. Not enumerable. */
|
|
149
|
-
readonly response: Response | undefined;
|
|
150
|
-
/** The underlying error for network failures. */
|
|
151
|
-
readonly cause: unknown;
|
|
152
|
-
constructor(init: ArthronErrorInit);
|
|
153
|
-
/** `instanceof ArthronError` holds for an error from any copy of the package. */
|
|
154
|
-
static [Symbol.hasInstance](value: unknown): boolean;
|
|
155
|
-
/** What serializers see: no headers, no body, no query or userinfo. */
|
|
156
|
-
toJSON(): {
|
|
157
|
-
name: string;
|
|
158
|
-
message: string;
|
|
159
|
-
detail: string | undefined;
|
|
160
|
-
status: number;
|
|
161
|
-
statusText: string;
|
|
162
|
-
method: string;
|
|
163
|
-
url: string;
|
|
164
|
-
data: unknown;
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
/** `true` for errors arthron produced: a `!ok` response or a network failure. Cancellations and plugin bugs are not ArthronErrors. */
|
|
168
|
-
declare function isArthronError(error: unknown): error is ArthronError;
|
|
169
|
-
//#endregion
|
|
170
|
-
//#region src/types/plugin.types.d.ts
|
|
171
|
-
/** Runs before each attempt. Returns the context to send (may be a new object). */
|
|
172
|
-
type RequestInterceptor = (ctx: RequestContext) => RequestContext | Promise<RequestContext>;
|
|
173
|
-
/** Runs after a successful attempt. The body is single-use — `clone()` to peek. */
|
|
174
|
-
type ResponseInterceptor = (res: Response, ctx: RequestContext) => Response | Promise<Response>;
|
|
175
|
-
/** Runs after a failed attempt. Returns the error to throw; it is always thrown. */
|
|
176
|
-
type ErrorInterceptor = (err: ArthronError, ctx: RequestContext) => ArthronError | Promise<ArthronError>;
|
|
177
|
-
/**
|
|
178
|
-
* Wraps the whole call. `next` runs everything below it (request interceptors →
|
|
179
|
-
* fetch → response/error interceptors) and may be called again to retry.
|
|
180
|
-
* `next` rejects with an `ArthronError`, or with the untouched original when the
|
|
181
|
-
* failure was a cancellation or a bug in a plugin.
|
|
182
|
-
*/
|
|
183
|
-
type Middleware = (ctx: RequestContext, next: (ctx: RequestContext) => Promise<Response>) => Promise<Response>;
|
|
184
|
-
interface Plugin {
|
|
185
|
-
/**
|
|
186
|
-
* Identity within a client: `use`/`extend` replace the plugin of the same
|
|
187
|
-
* name in place, `without(name)` removes it. The built-ins are named
|
|
188
|
-
* "bearerAuth", "logging", "retry", "timeout" and "uploadProgress". Leave it out to always
|
|
189
|
-
* append.
|
|
190
|
-
*/
|
|
191
|
-
name?: string;
|
|
192
|
-
middleware?: Middleware;
|
|
193
|
-
request?: RequestInterceptor;
|
|
194
|
-
response?: ResponseInterceptor;
|
|
195
|
-
error?: ErrorInterceptor;
|
|
196
|
-
}
|
|
197
|
-
//#endregion
|
|
198
|
-
//#region src/plugins/bearer-auth/bearer-auth.types.d.ts
|
|
199
|
-
type BearerAuthOptions = {
|
|
200
|
-
/**
|
|
201
|
-
* Where the token may go: a URL, or several. Only their origins count, so
|
|
202
|
-
* your `baseURL` works as is. A request anywhere else — a CDN, a URL taken
|
|
203
|
-
* from a response body — is sent without the token.
|
|
204
|
-
*/
|
|
205
|
-
origin: string | readonly string[];
|
|
206
|
-
/**
|
|
207
|
-
* The current access token, read on every attempt. `null`, `undefined` or
|
|
208
|
-
* `""` sends no `authorization` header.
|
|
209
|
-
*/
|
|
210
|
-
token: () => string | null | undefined | Promise<string | null | undefined>;
|
|
211
|
-
/**
|
|
212
|
-
* Get a new token after a 401; the call is then redone once. Concurrent
|
|
213
|
-
* 401s share one refresh, and a call whose token was already replaced
|
|
214
|
-
* retries without one. Throw when the refresh fails — every waiting call
|
|
215
|
-
* rejects with that error. Requests the refresh makes through the same
|
|
216
|
-
* client need `meta: { bearerAuth: false }`. Without `refresh`, or for a
|
|
217
|
-
* `ReadableStream` body (already read, so it cannot be resent), a 401 is
|
|
218
|
-
* thrown as is.
|
|
219
|
-
*/
|
|
220
|
-
refresh?: () => Promise<void>;
|
|
221
|
-
};
|
|
222
|
-
//#endregion
|
|
223
|
-
//#region src/plugins/bearer-auth/bearer-auth.d.ts
|
|
224
|
-
/**
|
|
225
|
-
* Bearer-token auth. A request interceptor sets `authorization: Bearer
|
|
226
|
-
* <token>` on every attempt to `origin`; with `refresh`, a middleware turns a
|
|
227
|
-
* 401 into one refresh and one more try. Concurrent 401s share the refresh
|
|
228
|
-
* (with refresh-token rotation, parallel refreshes revoke each other), and a
|
|
229
|
-
* call whose token was replaced meanwhile just retries. A call opts out with
|
|
230
|
-
* `meta: { bearerAuth: false }`.
|
|
231
|
-
*/
|
|
232
|
-
declare function bearerAuth(options: BearerAuthOptions): Plugin;
|
|
233
|
-
//#endregion
|
|
234
|
-
//#region src/plugins/logging/logging.types.d.ts
|
|
235
|
-
type LogEventBase = {
|
|
236
|
-
method: string;
|
|
237
|
-
path: string;
|
|
238
|
-
};
|
|
239
|
-
type LogEvent = ({
|
|
240
|
-
phase: "start";
|
|
241
|
-
} & LogEventBase) | ({
|
|
242
|
-
phase: "success";
|
|
243
|
-
status: number;
|
|
244
|
-
ms: number;
|
|
245
|
-
} & LogEventBase) | ({
|
|
246
|
-
phase: "error";
|
|
247
|
-
/** HTTP status for an ArthronError (0 = network); undefined for anything else. */
|
|
248
|
-
status: number | undefined;
|
|
249
|
-
ms: number;
|
|
250
|
-
error: unknown;
|
|
251
|
-
} & LogEventBase);
|
|
252
|
-
type LoggingOptions = {
|
|
253
|
-
/** Default `true`. The library never reads `NODE_ENV` — pass `isDev` yourself. */
|
|
254
|
-
enabled?: boolean;
|
|
255
|
-
logger?: Pick<Console, "log" | "error">;
|
|
256
|
-
/** Receive structured events instead of the default console lines. */
|
|
257
|
-
onEvent?: (event: LogEvent) => void;
|
|
258
|
-
/** Clock in ms. Injectable for tests. */
|
|
259
|
-
now?: () => number;
|
|
260
|
-
};
|
|
261
|
-
//#endregion
|
|
262
|
-
//#region src/plugins/logging/logging.d.ts
|
|
263
|
-
/**
|
|
264
|
-
* One event when a request starts, one when it settles, with status and
|
|
265
|
-
* elapsed time. Wraps the whole call, so retries are included in the time.
|
|
266
|
-
* Default output is a console line per event; pass `onEvent` to get the
|
|
267
|
-
* structured event instead. Cancellations are not reported.
|
|
268
|
-
*/
|
|
269
|
-
declare function logging(options?: LoggingOptions): Plugin;
|
|
270
|
-
//#endregion
|
|
271
|
-
//#region src/plugins/retry/retry.d.ts
|
|
272
|
-
/**
|
|
273
|
-
* Retry on network errors and the configured statuses by calling `next`
|
|
274
|
-
* again — the whole attempt (request interceptors included) is re-run.
|
|
275
|
-
* Only `ArthronError` is considered; cancellation and plugin bugs pass through.
|
|
276
|
-
* A call can turn it off or override its options with `meta.retry`.
|
|
277
|
-
*/
|
|
278
|
-
declare function retry(options?: RetryOptions): Plugin;
|
|
279
|
-
//#endregion
|
|
280
|
-
//#region src/plugins/timeout/timeout.types.d.ts
|
|
281
|
-
type TimeoutOptions = {
|
|
282
|
-
/**
|
|
283
|
-
* Abort after this many ms in all, counted until the body has been fully
|
|
284
|
-
* read — the same deadline as `timeout(ms)`. Default: none.
|
|
285
|
-
*/
|
|
286
|
-
total?: number;
|
|
287
|
-
/**
|
|
288
|
-
* Abort when no data has arrived for this many ms — while waiting for the
|
|
289
|
-
* headers and between body chunks. Unlike `total`, a stream that keeps
|
|
290
|
-
* flowing is never cut off: `timeout({ idle: 30_000 })`.
|
|
291
|
-
*/
|
|
292
|
-
idle?: number;
|
|
293
|
-
};
|
|
294
|
-
//#endregion
|
|
295
|
-
//#region src/plugins/timeout/timeout.d.ts
|
|
296
|
-
/**
|
|
297
|
-
* Abort the request when it takes longer than `ms` — counted from the start
|
|
298
|
-
* of the attempt until the body has been fully read, so a server that sends
|
|
299
|
-
* headers and then stalls still trips it. `timeout({ idle })` instead aborts
|
|
300
|
-
* once no data has arrived for that long, and never cuts off a stream that
|
|
301
|
-
* keeps flowing; `timeout({ total, idle })` does both. Rejects with a
|
|
302
|
-
* `TimeoutError` (a `DOMException`, like `AbortSignal.timeout()` would give);
|
|
303
|
-
* it is not an `ArthronError`, so error interceptors and `retry` leave it
|
|
304
|
-
* alone.
|
|
305
|
-
*
|
|
306
|
-
* A call can change the total deadline with `meta.timeout`.
|
|
307
|
-
*
|
|
308
|
-
* Deadlines are timers on an AbortController — not `AbortSignal.timeout`,
|
|
309
|
-
* since the idle one restarts — joined to the caller's signal with
|
|
310
|
-
* `AbortSignal.any`.
|
|
311
|
-
*/
|
|
312
|
-
declare function timeout(ms: number): Plugin;
|
|
313
|
-
declare function timeout(options: TimeoutOptions): Plugin;
|
|
314
|
-
/** @deprecated Use `timeout({ total: ms, idle })`; this form goes in the next major. */
|
|
315
|
-
declare function timeout(ms: number, options: Pick<TimeoutOptions, "idle">): Plugin;
|
|
316
|
-
//#endregion
|
|
317
|
-
//#region src/plugins/upload-progress/upload-progress.d.ts
|
|
318
|
-
/**
|
|
319
|
-
* Does nothing by itself — only a call that passes `meta.onUploadProgress`
|
|
320
|
-
* reports upload progress. On such a call it changes how that one attempt
|
|
321
|
-
* is sent: over XMLHttpRequest in a browser, with a counted body in Node.
|
|
322
|
-
* Every other call, on this client too, goes out exactly as before.
|
|
323
|
-
*
|
|
324
|
-
* Progress needs the runtime's own fetch: with an injected `fetch`, another
|
|
325
|
-
* plugin's transport, or a runtime that is neither (Deno, Bun, Cloudflare
|
|
326
|
-
* Workers, a service worker), the call is sent as usual without progress.
|
|
327
|
-
* Put it last in `use`: the call's signal carries a throwing callback's
|
|
328
|
-
* error, and an interceptor after it that replaces the signal would cut it.
|
|
329
|
-
*/
|
|
330
|
-
declare function uploadProgress(): Plugin;
|
|
331
|
-
//#endregion
|
|
332
|
-
export { UploadProgressEvent as S, ArthronMeta as _, logging as a, RequestContext as b, bearerAuth as c, Middleware as d, Plugin as f, isArthronError as g, ArthronError as h, retry as i, BearerAuthOptions as l, ResponseInterceptor as m, timeout as n, LogEvent as o, RequestInterceptor as p, TimeoutOptions as r, LoggingOptions as s, uploadProgress as t, ErrorInterceptor as u, HeadersOption as v, RetryOptions as x, QueryParams as y };
|
|
333
|
-
//# sourceMappingURL=index-C3lylSNd.d.cts.map
|