arthron 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +297 -2
- package/dist/index.cjs +665 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +219 -0
- package/dist/index.d.mts +219 -0
- package/dist/index.mjs +656 -0
- package/dist/index.mjs.map +1 -0
- package/llms.txt +99 -0
- package/package.json +59 -4
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region src/body.ts
|
|
3
|
+
const RAW_CTORS = [
|
|
4
|
+
"FormData",
|
|
5
|
+
"Blob",
|
|
6
|
+
"ArrayBuffer",
|
|
7
|
+
"URLSearchParams",
|
|
8
|
+
"ReadableStream"
|
|
9
|
+
];
|
|
10
|
+
/** Bodies fetch already knows how to send. Content-Type is left to the caller / runtime. */
|
|
11
|
+
function isRawBody(body) {
|
|
12
|
+
if (typeof body === "string") return true;
|
|
13
|
+
if (ArrayBuffer.isView(body)) return true;
|
|
14
|
+
return RAW_CTORS.some((name) => {
|
|
15
|
+
const ctor = globalThis[name];
|
|
16
|
+
return typeof ctor === "function" && body instanceof ctor;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Turn a caller-supplied body into something fetch accepts. Raw bodies pass
|
|
21
|
+
* through untouched; anything else is JSON with `content-type` set — unless
|
|
22
|
+
* the caller already set one (headers arrive lowercased, so a plain lookup is
|
|
23
|
+
* enough).
|
|
24
|
+
*/
|
|
25
|
+
function serializeBody(body, headers) {
|
|
26
|
+
if (body === void 0) return {
|
|
27
|
+
body: void 0,
|
|
28
|
+
headers
|
|
29
|
+
};
|
|
30
|
+
if (isRawBody(body)) return {
|
|
31
|
+
body,
|
|
32
|
+
headers
|
|
33
|
+
};
|
|
34
|
+
const withType = "content-type" in headers ? headers : {
|
|
35
|
+
...headers,
|
|
36
|
+
"content-type": "application/json"
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
body: JSON.stringify(body),
|
|
40
|
+
headers: withType
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
//#endregion
|
|
44
|
+
//#region src/error.ts
|
|
45
|
+
/**
|
|
46
|
+
* The only error arthron itself produces. Two sources: a response with
|
|
47
|
+
* `ok === false`, or fetch rejecting outright (network failure, `status` 0).
|
|
48
|
+
* Everything else — cancellation, interceptor bugs — is rethrown untouched.
|
|
49
|
+
*/
|
|
50
|
+
var ArthronError = class extends Error {
|
|
51
|
+
name = "ArthronError";
|
|
52
|
+
/** HTTP status of the failed response; `0` when fetch itself failed (network). */
|
|
53
|
+
status;
|
|
54
|
+
statusText;
|
|
55
|
+
/** Response headers; empty for network failures. */
|
|
56
|
+
headers;
|
|
57
|
+
/** Parsed body: JSON if it parses, else the text, else `undefined`. */
|
|
58
|
+
data;
|
|
59
|
+
/** The context that was sent — url and init after request interceptors. */
|
|
60
|
+
request;
|
|
61
|
+
/** The original Response, still readable (the body was parsed from a clone). Absent for network failures. */
|
|
62
|
+
response;
|
|
63
|
+
/** The underlying error for network failures. */
|
|
64
|
+
cause;
|
|
65
|
+
constructor(init) {
|
|
66
|
+
super(init.message);
|
|
67
|
+
this.status = init.status;
|
|
68
|
+
this.statusText = init.statusText;
|
|
69
|
+
this.headers = init.headers;
|
|
70
|
+
this.data = init.data;
|
|
71
|
+
this.request = init.request;
|
|
72
|
+
this.response = init.response;
|
|
73
|
+
this.cause = init.cause;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
/** `true` for errors arthron produced: a `!ok` response or a network failure. Cancellations and plugin bugs are not ArthronErrors. */
|
|
77
|
+
function isArthronError(error) {
|
|
78
|
+
return error instanceof ArthronError;
|
|
79
|
+
}
|
|
80
|
+
/** Cancellation is not an HTTP failure. Matched by name so it works across realms. */
|
|
81
|
+
function isCancellation(error) {
|
|
82
|
+
const name = error?.name;
|
|
83
|
+
return name === "AbortError" || name === "TimeoutError";
|
|
84
|
+
}
|
|
85
|
+
const isRecord = (value) => typeof value === "object" && value !== null;
|
|
86
|
+
/** `message` → `error` (string) → `error.message` → `detail`. */
|
|
87
|
+
function defaultErrorMessage(data) {
|
|
88
|
+
if (!isRecord(data)) return void 0;
|
|
89
|
+
if (typeof data.message === "string") return data.message;
|
|
90
|
+
if (typeof data.error === "string") return data.error;
|
|
91
|
+
if (isRecord(data.error) && typeof data.error.message === "string") return data.error.message;
|
|
92
|
+
if (typeof data.detail === "string") return data.detail;
|
|
93
|
+
}
|
|
94
|
+
/** Body as JSON if it parses, else as text, else `undefined` when empty. */
|
|
95
|
+
async function readBody(response) {
|
|
96
|
+
const text = await response.text().catch(() => "");
|
|
97
|
+
if (text === "") return void 0;
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(text);
|
|
100
|
+
} catch {
|
|
101
|
+
return text;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async function responseError(response, request, resolve = defaultErrorMessage) {
|
|
105
|
+
const data = await readBody(response.clone());
|
|
106
|
+
return new ArthronError({
|
|
107
|
+
message: resolve(data, response) ?? `Request failed with status ${response.status}`,
|
|
108
|
+
status: response.status,
|
|
109
|
+
statusText: response.statusText,
|
|
110
|
+
headers: response.headers,
|
|
111
|
+
data,
|
|
112
|
+
request,
|
|
113
|
+
response
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function networkError(cause, request) {
|
|
117
|
+
return new ArthronError({
|
|
118
|
+
message: cause instanceof Error ? cause.message : "Network error",
|
|
119
|
+
status: 0,
|
|
120
|
+
statusText: "",
|
|
121
|
+
headers: new Headers(),
|
|
122
|
+
data: void 0,
|
|
123
|
+
request,
|
|
124
|
+
cause
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
//#endregion
|
|
128
|
+
//#region src/headers.ts
|
|
129
|
+
/**
|
|
130
|
+
* Normalize any `HeadersInit` (plain object, `Headers`, tuple array) into a
|
|
131
|
+
* plain record. Keys come back lowercased — `Headers` does that for us.
|
|
132
|
+
*/
|
|
133
|
+
function toHeaderRecord(headers) {
|
|
134
|
+
if (!headers) return {};
|
|
135
|
+
return Object.fromEntries(new Headers(headers).entries());
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Layer `override` on top of `base`. Names are compared case-insensitively and
|
|
139
|
+
* the override wins, so a caller's `content-type` replaces a default
|
|
140
|
+
* `Content-Type` instead of fetch sending both values joined.
|
|
141
|
+
*/
|
|
142
|
+
function mergeHeaders(base, override) {
|
|
143
|
+
return {
|
|
144
|
+
...toHeaderRecord(base),
|
|
145
|
+
...toHeaderRecord(override)
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
//#endregion
|
|
149
|
+
//#region src/pipeline.ts
|
|
150
|
+
/**
|
|
151
|
+
* Interceptors mutate the context in place. A middleware that calls `next`
|
|
152
|
+
* again (retry) must not see the previous attempt's changes, so every attempt
|
|
153
|
+
* starts from a fresh copy.
|
|
154
|
+
*/
|
|
155
|
+
function cloneContext({ url, init }) {
|
|
156
|
+
return {
|
|
157
|
+
url,
|
|
158
|
+
init: {
|
|
159
|
+
...init,
|
|
160
|
+
headers: toHeaderRecord(init.headers)
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Owns the order of one request.
|
|
166
|
+
*
|
|
167
|
+
* Middleware wraps the whole call, outermost first in registration order.
|
|
168
|
+
* Each attempt below it runs request interceptors → `send` → response
|
|
169
|
+
* interceptors. Only an `ArthronError` reaches the error interceptors; whatever
|
|
170
|
+
* they return is thrown. Anything else that is thrown — cancellation, a bug
|
|
171
|
+
* in a plugin — propagates untouched.
|
|
172
|
+
*/
|
|
173
|
+
async function runPipeline(plugins, ctx, send) {
|
|
174
|
+
const middlewares = [];
|
|
175
|
+
const request = [];
|
|
176
|
+
const response = [];
|
|
177
|
+
const error = [];
|
|
178
|
+
for (const plugin of plugins) {
|
|
179
|
+
if (plugin.middleware) middlewares.push(plugin.middleware);
|
|
180
|
+
if (plugin.request) request.push(plugin.request);
|
|
181
|
+
if (plugin.response) response.push(plugin.response);
|
|
182
|
+
if (plugin.error) error.push(plugin.error);
|
|
183
|
+
}
|
|
184
|
+
const attempt = async (input) => {
|
|
185
|
+
let current = cloneContext(input);
|
|
186
|
+
try {
|
|
187
|
+
for (const interceptor of request) current = await interceptor(current);
|
|
188
|
+
let res = await send(current);
|
|
189
|
+
for (const interceptor of response) res = await interceptor(res, current);
|
|
190
|
+
return res;
|
|
191
|
+
} catch (thrown) {
|
|
192
|
+
if (!isArthronError(thrown)) throw thrown;
|
|
193
|
+
let err = thrown;
|
|
194
|
+
for (const interceptor of error) err = await interceptor(err, current);
|
|
195
|
+
throw err;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
const dispatch = (index, input) => {
|
|
199
|
+
const middleware = middlewares[index];
|
|
200
|
+
if (!middleware) return attempt(input);
|
|
201
|
+
return middleware(input, (next) => dispatch(index + 1, next));
|
|
202
|
+
};
|
|
203
|
+
return dispatch(0, ctx);
|
|
204
|
+
}
|
|
205
|
+
//#endregion
|
|
206
|
+
//#region src/stream.ts
|
|
207
|
+
/** Yield the body line by line, reassembling lines split across chunks. */
|
|
208
|
+
async function* lines(response) {
|
|
209
|
+
if (!response.body) return;
|
|
210
|
+
const reader = response.body.getReader();
|
|
211
|
+
const decoder = new TextDecoder();
|
|
212
|
+
let buffer = "";
|
|
213
|
+
try {
|
|
214
|
+
while (true) {
|
|
215
|
+
const { value, done } = await reader.read();
|
|
216
|
+
buffer += decoder.decode(value, { stream: !done });
|
|
217
|
+
const parts = buffer.split(/\r\n|\r|\n/);
|
|
218
|
+
buffer = parts.pop() ?? "";
|
|
219
|
+
for (const line of parts) yield line;
|
|
220
|
+
if (done) break;
|
|
221
|
+
}
|
|
222
|
+
if (buffer) yield buffer;
|
|
223
|
+
} finally {
|
|
224
|
+
reader.releaseLock();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Parse a `text/event-stream` body into events. Follows the spec: a blank
|
|
229
|
+
* line dispatches, `data:` lines are joined with `\n`, one leading space
|
|
230
|
+
* after the colon is dropped, lines starting with `:` are comments, and a
|
|
231
|
+
* block without `data` is not dispatched. Provider sentinels like
|
|
232
|
+
* `[DONE]` are passed through untouched — they are not part of the spec.
|
|
233
|
+
*/
|
|
234
|
+
async function* parseSse(response) {
|
|
235
|
+
let data = [];
|
|
236
|
+
let event;
|
|
237
|
+
let id;
|
|
238
|
+
let retry;
|
|
239
|
+
const flush = () => {
|
|
240
|
+
if (data.length === 0) {
|
|
241
|
+
event = id = retry = void 0;
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const out = { data: data.join("\n") };
|
|
245
|
+
if (event !== void 0) out.event = event;
|
|
246
|
+
if (id !== void 0) out.id = id;
|
|
247
|
+
if (retry !== void 0) out.retry = retry;
|
|
248
|
+
data = [];
|
|
249
|
+
event = id = retry = void 0;
|
|
250
|
+
return out;
|
|
251
|
+
};
|
|
252
|
+
for await (const line of lines(response)) {
|
|
253
|
+
if (line === "") {
|
|
254
|
+
const out = flush();
|
|
255
|
+
if (out) yield out;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (line.startsWith(":")) continue;
|
|
259
|
+
const colon = line.indexOf(":");
|
|
260
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
261
|
+
let value = colon === -1 ? "" : line.slice(colon + 1);
|
|
262
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
263
|
+
switch (field) {
|
|
264
|
+
case "data":
|
|
265
|
+
data.push(value);
|
|
266
|
+
break;
|
|
267
|
+
case "event":
|
|
268
|
+
event = value;
|
|
269
|
+
break;
|
|
270
|
+
case "id":
|
|
271
|
+
id = value;
|
|
272
|
+
break;
|
|
273
|
+
case "retry": {
|
|
274
|
+
const ms = Number(value);
|
|
275
|
+
if (Number.isInteger(ms) && ms >= 0) retry = ms;
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const out = flush();
|
|
281
|
+
if (out) yield out;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Parse newline-delimited JSON. Blank lines are skipped; a malformed line
|
|
285
|
+
* rejects the iteration with the `SyntaxError` from `JSON.parse`.
|
|
286
|
+
*/
|
|
287
|
+
async function* parseNdjson(response) {
|
|
288
|
+
for await (const line of lines(response)) {
|
|
289
|
+
if (line.trim() === "") continue;
|
|
290
|
+
yield JSON.parse(line);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
//#endregion
|
|
294
|
+
//#region src/promise.ts
|
|
295
|
+
/** Defer the Response until iteration starts, so the helper itself never rejects. */
|
|
296
|
+
function iterate(promise, parse) {
|
|
297
|
+
return { async *[Symbol.asyncIterator]() {
|
|
298
|
+
yield* parse(await promise);
|
|
299
|
+
} };
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Sugar over `Promise<Response>`: `.json()` is `(await p).json()`. Awaiting
|
|
303
|
+
* without a helper still yields the untouched `Response`.
|
|
304
|
+
*/
|
|
305
|
+
function toArthronPromise(promise) {
|
|
306
|
+
return Object.assign(promise, {
|
|
307
|
+
json: () => promise.then((res) => res.json()),
|
|
308
|
+
text: () => promise.then((res) => res.text()),
|
|
309
|
+
blob: () => promise.then((res) => res.blob()),
|
|
310
|
+
arrayBuffer: () => promise.then((res) => res.arrayBuffer()),
|
|
311
|
+
formData: () => promise.then((res) => res.formData()),
|
|
312
|
+
sse: () => iterate(promise, parseSse),
|
|
313
|
+
ndjson: () => iterate(promise, parseNdjson)
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
//#endregion
|
|
317
|
+
//#region src/url.ts
|
|
318
|
+
const ABSOLUTE_URL = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
319
|
+
function isAbsoluteUrl(path) {
|
|
320
|
+
return ABSOLUTE_URL.test(path);
|
|
321
|
+
}
|
|
322
|
+
function toQueryString(params) {
|
|
323
|
+
if (!params) return "";
|
|
324
|
+
const search = new URLSearchParams();
|
|
325
|
+
for (const [key, value] of Object.entries(params)) {
|
|
326
|
+
if (value === null || value === void 0) continue;
|
|
327
|
+
search.append(key, String(value));
|
|
328
|
+
}
|
|
329
|
+
return search.toString();
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Join `baseURL` and `path` by plain string concatenation so a path prefix in
|
|
333
|
+
* the base (`/v1`) survives a leading slash in the path. WHATWG `new URL()`
|
|
334
|
+
* would drop it. Absolute paths skip the base entirely.
|
|
335
|
+
*/
|
|
336
|
+
function resolveUrl(baseURL, path, params) {
|
|
337
|
+
let url;
|
|
338
|
+
if (isAbsoluteUrl(path)) url = path;
|
|
339
|
+
else {
|
|
340
|
+
if (baseURL === void 0) throw new TypeError(`arthron: relative path "${path}" needs a baseURL — pass baseURL in arthron({ baseURL }) or use an absolute URL`);
|
|
341
|
+
url = `${baseURL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
|
|
342
|
+
}
|
|
343
|
+
const query = toQueryString(params);
|
|
344
|
+
if (!query) return url;
|
|
345
|
+
const hash = url.indexOf("#");
|
|
346
|
+
const [base, fragment] = hash === -1 ? [url, ""] : [url.slice(0, hash), url.slice(hash)];
|
|
347
|
+
return `${base}${base.includes("?") ? "&" : "?"}${query}${fragment}`;
|
|
348
|
+
}
|
|
349
|
+
//#endregion
|
|
350
|
+
//#region src/client.ts
|
|
351
|
+
const BODYLESS_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD"]);
|
|
352
|
+
/** Normalize once so `extend` can merge headers and append plugins cheaply. */
|
|
353
|
+
function resolveConfig(config) {
|
|
354
|
+
const { headers, use, ...rest } = config;
|
|
355
|
+
return {
|
|
356
|
+
...rest,
|
|
357
|
+
headers: toHeaderRecord(headers),
|
|
358
|
+
use: use ?? []
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Build a client from config. The result is immutable — `use` and `extend`
|
|
363
|
+
* return new clients and never touch the one they were called on.
|
|
364
|
+
*/
|
|
365
|
+
function arthron(config = {}) {
|
|
366
|
+
const resolved = resolveConfig(config);
|
|
367
|
+
/** The one place a failure becomes an ArthronError. Cancellation passes through. */
|
|
368
|
+
const send = async (ctx) => {
|
|
369
|
+
const fetchImpl = resolved.fetch ?? globalThis.fetch;
|
|
370
|
+
let res;
|
|
371
|
+
try {
|
|
372
|
+
res = await fetchImpl(ctx.url, ctx.init);
|
|
373
|
+
} catch (thrown) {
|
|
374
|
+
if (isCancellation(thrown)) throw thrown;
|
|
375
|
+
throw networkError(thrown, ctx);
|
|
376
|
+
}
|
|
377
|
+
if (!res.ok) throw await responseError(res, ctx, resolved.errorMessage);
|
|
378
|
+
return res;
|
|
379
|
+
};
|
|
380
|
+
const dispatch = (path, method, rawBody, { params, headers: callHeaders, ...init }) => {
|
|
381
|
+
try {
|
|
382
|
+
if (rawBody !== void 0 && BODYLESS_METHODS.has(method.toUpperCase())) throw new TypeError(`arthron: ${method.toUpperCase()} requests cannot carry a body — send it as params (query) or switch to POST`);
|
|
383
|
+
const url = resolveUrl(resolved.baseURL, path, params);
|
|
384
|
+
const { body, headers } = serializeBody(rawBody, mergeHeaders(resolved.headers, callHeaders));
|
|
385
|
+
const requestInit = {
|
|
386
|
+
...resolved.credentials !== void 0 && { credentials: resolved.credentials },
|
|
387
|
+
...init,
|
|
388
|
+
method,
|
|
389
|
+
headers
|
|
390
|
+
};
|
|
391
|
+
if (body !== void 0) requestInit.body = body;
|
|
392
|
+
return toArthronPromise(runPipeline(resolved.use, {
|
|
393
|
+
url,
|
|
394
|
+
init: requestInit
|
|
395
|
+
}, send));
|
|
396
|
+
} catch (thrown) {
|
|
397
|
+
return toArthronPromise(Promise.reject(thrown));
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
const derive = (next) => {
|
|
401
|
+
return arthron({
|
|
402
|
+
...resolved,
|
|
403
|
+
...next,
|
|
404
|
+
headers: mergeHeaders(resolved.headers, next.headers),
|
|
405
|
+
use: [...resolved.use, ...next.use ?? []]
|
|
406
|
+
});
|
|
407
|
+
};
|
|
408
|
+
return {
|
|
409
|
+
get: (path, init = {}) => dispatch(path, "GET", void 0, init),
|
|
410
|
+
delete: (path, init = {}) => dispatch(path, "DELETE", void 0, init),
|
|
411
|
+
head: (path, init = {}) => dispatch(path, "HEAD", void 0, init),
|
|
412
|
+
post: (path, body, init = {}) => dispatch(path, "POST", body, init),
|
|
413
|
+
put: (path, body, init = {}) => dispatch(path, "PUT", body, init),
|
|
414
|
+
patch: (path, body, init = {}) => dispatch(path, "PATCH", body, init),
|
|
415
|
+
request: (path, init) => {
|
|
416
|
+
const { method, body, ...rest } = init;
|
|
417
|
+
if (!method) return toArthronPromise(Promise.reject(/* @__PURE__ */ new TypeError("arthron: request() needs init.method — e.g. request(path, { method: \"OPTIONS\" }); use get/post/... for the common verbs")));
|
|
418
|
+
return dispatch(path, method, body, rest);
|
|
419
|
+
},
|
|
420
|
+
use: (...plugins) => derive({ use: plugins }),
|
|
421
|
+
extend: (next) => derive(next)
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
//#endregion
|
|
425
|
+
//#region src/define.ts
|
|
426
|
+
/** Identity helper: gives a middleware written in its own file the right parameter types. */
|
|
427
|
+
const defineMiddleware = (middleware) => middleware;
|
|
428
|
+
/** Identity helpers for each interceptor kind — `defineInterceptor.request(fn)` etc. */
|
|
429
|
+
const defineInterceptor = {
|
|
430
|
+
request: (interceptor) => interceptor,
|
|
431
|
+
response: (interceptor) => interceptor,
|
|
432
|
+
error: (interceptor) => interceptor
|
|
433
|
+
};
|
|
434
|
+
/** Identity helper: type-checks a plugin object literal without an annotation. */
|
|
435
|
+
const definePlugin = (plugin) => plugin;
|
|
436
|
+
//#endregion
|
|
437
|
+
//#region src/plugins/logging.ts
|
|
438
|
+
/** Origin + pathname only — the query and userinfo tend to carry secrets. */
|
|
439
|
+
function displayPath(url) {
|
|
440
|
+
try {
|
|
441
|
+
const parsed = new URL(url);
|
|
442
|
+
return `${parsed.origin}${parsed.pathname}`;
|
|
443
|
+
} catch {
|
|
444
|
+
return url.split("?")[0] ?? url;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
function consoleSink(logger) {
|
|
448
|
+
return (e) => {
|
|
449
|
+
if (e.phase === "start") logger.log(`[arthron] → ${e.method} ${e.path}`);
|
|
450
|
+
else if (e.phase === "success") logger.log(`[arthron] ← ${e.method} ${e.path} ${e.status} ${e.ms}ms`);
|
|
451
|
+
else logger.error(`[arthron] ✕ ${e.method} ${e.path} ${e.status ?? "-"} ${e.ms}ms`);
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* One event when a request starts, one when it settles, with status and
|
|
456
|
+
* elapsed time. Wraps the whole call, so retries are included in the time.
|
|
457
|
+
* Default output is a console line per event; pass `onEvent` to get the
|
|
458
|
+
* structured event instead. Cancellations are not reported.
|
|
459
|
+
*/
|
|
460
|
+
function logging(options = {}) {
|
|
461
|
+
const { enabled = true, logger = console, onEvent, now = () => performance.now() } = options;
|
|
462
|
+
const emit = onEvent ?? consoleSink(logger);
|
|
463
|
+
return {
|
|
464
|
+
name: "logging",
|
|
465
|
+
middleware: async (ctx, next) => {
|
|
466
|
+
if (!enabled) return next(ctx);
|
|
467
|
+
const method = ctx.init.method ?? "GET";
|
|
468
|
+
const path = displayPath(ctx.url);
|
|
469
|
+
const started = now();
|
|
470
|
+
const ms = () => Math.round(now() - started);
|
|
471
|
+
emit({
|
|
472
|
+
phase: "start",
|
|
473
|
+
method,
|
|
474
|
+
path
|
|
475
|
+
});
|
|
476
|
+
try {
|
|
477
|
+
const res = await next(ctx);
|
|
478
|
+
emit({
|
|
479
|
+
phase: "success",
|
|
480
|
+
method,
|
|
481
|
+
path,
|
|
482
|
+
status: res.status,
|
|
483
|
+
ms: ms()
|
|
484
|
+
});
|
|
485
|
+
return res;
|
|
486
|
+
} catch (error) {
|
|
487
|
+
if (!isCancellation(error)) {
|
|
488
|
+
const status = isArthronError(error) ? error.status : void 0;
|
|
489
|
+
emit({
|
|
490
|
+
phase: "error",
|
|
491
|
+
method,
|
|
492
|
+
path,
|
|
493
|
+
status,
|
|
494
|
+
ms: ms(),
|
|
495
|
+
error
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
throw error;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
//#endregion
|
|
504
|
+
//#region src/plugins/retry.ts
|
|
505
|
+
const DEFAULT_RETRY_ON = [
|
|
506
|
+
408,
|
|
507
|
+
429,
|
|
508
|
+
500,
|
|
509
|
+
502,
|
|
510
|
+
503,
|
|
511
|
+
504
|
|
512
|
+
];
|
|
513
|
+
const DEFAULT_METHODS = [
|
|
514
|
+
"GET",
|
|
515
|
+
"HEAD",
|
|
516
|
+
"OPTIONS",
|
|
517
|
+
"PUT",
|
|
518
|
+
"DELETE"
|
|
519
|
+
];
|
|
520
|
+
const DEFAULT_MAX_RETRY_AFTER = 6e4;
|
|
521
|
+
function defaultSleep(ms, signal) {
|
|
522
|
+
return new Promise((resolve, reject) => {
|
|
523
|
+
if (signal?.aborted) return reject(signal.reason);
|
|
524
|
+
const onAbort = () => {
|
|
525
|
+
clearTimeout(timer);
|
|
526
|
+
reject(signal?.reason);
|
|
527
|
+
};
|
|
528
|
+
const timer = setTimeout(() => {
|
|
529
|
+
signal?.removeEventListener("abort", onAbort);
|
|
530
|
+
resolve();
|
|
531
|
+
}, ms);
|
|
532
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
/** `Retry-After` as ms — seconds or an HTTP-date. `undefined` when absent or unparsable. */
|
|
536
|
+
function retryAfterMs(error) {
|
|
537
|
+
const value = error.headers.get("retry-after")?.trim();
|
|
538
|
+
if (!value) return void 0;
|
|
539
|
+
const seconds = Number(value);
|
|
540
|
+
if (!Number.isNaN(seconds)) return Math.max(0, seconds * 1e3);
|
|
541
|
+
const at = Date.parse(value);
|
|
542
|
+
if (Number.isNaN(at)) return void 0;
|
|
543
|
+
return Math.max(0, at - Date.now());
|
|
544
|
+
}
|
|
545
|
+
function isStreamBody(ctx) {
|
|
546
|
+
return typeof ReadableStream !== "undefined" && ctx.init.body instanceof ReadableStream;
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Retry on network errors and the configured statuses by calling `next`
|
|
550
|
+
* again — the whole attempt (request interceptors included) is re-run.
|
|
551
|
+
* Only `ArthronError` is considered; cancellation and plugin bugs pass through.
|
|
552
|
+
*/
|
|
553
|
+
function retry(options = {}) {
|
|
554
|
+
const { retries = 2, retryOn = DEFAULT_RETRY_ON, methods = DEFAULT_METHODS, delay = (retry) => 300 * 2 ** retry, sleep = defaultSleep, maxRetryAfter = DEFAULT_MAX_RETRY_AFTER } = options;
|
|
555
|
+
const allowed = new Set(methods.map((m) => m.toUpperCase()));
|
|
556
|
+
return {
|
|
557
|
+
name: "retry",
|
|
558
|
+
middleware: async (ctx, next) => {
|
|
559
|
+
const method = (ctx.init.method ?? "GET").toUpperCase();
|
|
560
|
+
const eligible = allowed.has(method) && !isStreamBody(ctx);
|
|
561
|
+
for (let attempt = 0;; attempt += 1) try {
|
|
562
|
+
return await next(ctx);
|
|
563
|
+
} catch (thrown) {
|
|
564
|
+
if (!eligible || attempt >= retries) throw thrown;
|
|
565
|
+
if (!isArthronError(thrown)) throw thrown;
|
|
566
|
+
if (thrown.status !== 0 && !retryOn.includes(thrown.status)) throw thrown;
|
|
567
|
+
if (ctx.init.signal?.aborted) throw thrown;
|
|
568
|
+
const retryAfter = retryAfterMs(thrown);
|
|
569
|
+
if (retryAfter !== void 0 && retryAfter > maxRetryAfter) throw thrown;
|
|
570
|
+
await sleep(retryAfter ?? delay(attempt, thrown), ctx.init.signal);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
//#endregion
|
|
576
|
+
//#region src/plugins/timeout.ts
|
|
577
|
+
/**
|
|
578
|
+
* Wrap `body` so the deadline keeps running until it is fully read, and so an
|
|
579
|
+
* abort while reading surfaces as a stream error instead of a silent stall.
|
|
580
|
+
*/
|
|
581
|
+
function guardBody(body, signal, finish) {
|
|
582
|
+
const reader = body.getReader();
|
|
583
|
+
return new ReadableStream({
|
|
584
|
+
start(controller) {
|
|
585
|
+
signal.addEventListener("abort", () => {
|
|
586
|
+
controller.error(signal.reason);
|
|
587
|
+
reader.cancel(signal.reason).catch(() => {});
|
|
588
|
+
}, { once: true });
|
|
589
|
+
},
|
|
590
|
+
async pull(controller) {
|
|
591
|
+
const { value, done } = await reader.read();
|
|
592
|
+
if (done) {
|
|
593
|
+
finish();
|
|
594
|
+
controller.close();
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
controller.enqueue(value);
|
|
598
|
+
},
|
|
599
|
+
cancel(reason) {
|
|
600
|
+
finish();
|
|
601
|
+
return reader.cancel(reason);
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Abort the request when it takes longer than `ms` — counted from the start
|
|
607
|
+
* of the attempt until the body has been fully read, so a server that sends
|
|
608
|
+
* headers and then stalls still trips it. Rejects with a `TimeoutError` (a
|
|
609
|
+
* `DOMException`, like `AbortSignal.timeout()` would give); it is not an
|
|
610
|
+
* `ArthronError`, so error interceptors and `retry` leave it alone.
|
|
611
|
+
*
|
|
612
|
+
* Built on `AbortController` + `setTimeout` rather than `AbortSignal.any` /
|
|
613
|
+
* `AbortSignal.timeout` so it runs on Node 18.
|
|
614
|
+
*/
|
|
615
|
+
function timeout(ms) {
|
|
616
|
+
return {
|
|
617
|
+
name: "timeout",
|
|
618
|
+
middleware: async (ctx, next) => {
|
|
619
|
+
const controller = new AbortController();
|
|
620
|
+
const outer = ctx.init.signal;
|
|
621
|
+
const forward = () => controller.abort(outer?.reason);
|
|
622
|
+
if (outer?.aborted) forward();
|
|
623
|
+
else outer?.addEventListener("abort", forward, { once: true });
|
|
624
|
+
const timer = setTimeout(() => controller.abort(new DOMException(`arthron: request timed out after ${ms}ms`, "TimeoutError")), ms);
|
|
625
|
+
const finish = () => {
|
|
626
|
+
clearTimeout(timer);
|
|
627
|
+
outer?.removeEventListener("abort", forward);
|
|
628
|
+
};
|
|
629
|
+
let res;
|
|
630
|
+
try {
|
|
631
|
+
res = await next({
|
|
632
|
+
...ctx,
|
|
633
|
+
init: {
|
|
634
|
+
...ctx.init,
|
|
635
|
+
signal: controller.signal
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
} catch (thrown) {
|
|
639
|
+
finish();
|
|
640
|
+
throw thrown;
|
|
641
|
+
}
|
|
642
|
+
if (!res.body) {
|
|
643
|
+
finish();
|
|
644
|
+
return res;
|
|
645
|
+
}
|
|
646
|
+
return new Response(guardBody(res.body, controller.signal, finish), {
|
|
647
|
+
status: res.status,
|
|
648
|
+
statusText: res.statusText,
|
|
649
|
+
headers: res.headers
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
//#endregion
|
|
655
|
+
exports.ArthronError = ArthronError;
|
|
656
|
+
exports.arthron = arthron;
|
|
657
|
+
exports.defineInterceptor = defineInterceptor;
|
|
658
|
+
exports.defineMiddleware = defineMiddleware;
|
|
659
|
+
exports.definePlugin = definePlugin;
|
|
660
|
+
exports.isArthronError = isArthronError;
|
|
661
|
+
exports.logging = logging;
|
|
662
|
+
exports.retry = retry;
|
|
663
|
+
exports.timeout = timeout;
|
|
664
|
+
|
|
665
|
+
//# sourceMappingURL=index.cjs.map
|