arthron 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +190 -135
- package/dist/index.cjs +243 -18
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +63 -3
- package/dist/index.d.mts +63 -3
- package/dist/index.mjs +243 -19
- package/dist/index.mjs.map +1 -1
- package/llms.txt +99 -0
- package/package.json +5 -2
package/dist/index.d.cts
CHANGED
|
@@ -17,17 +17,37 @@ type ArthronErrorInit = {
|
|
|
17
17
|
*/
|
|
18
18
|
export declare class ArthronError extends Error {
|
|
19
19
|
readonly name = "ArthronError";
|
|
20
|
+
/** HTTP status of the failed response; `0` when fetch itself failed (network). */
|
|
20
21
|
readonly status: number;
|
|
21
22
|
readonly statusText: string;
|
|
23
|
+
/** Response headers; empty for network failures. */
|
|
22
24
|
readonly headers: Headers;
|
|
25
|
+
/** Parsed body: JSON if it parses, else the text, else `undefined`. */
|
|
23
26
|
readonly data: unknown;
|
|
27
|
+
/** The context that was sent — url and init after request interceptors. */
|
|
24
28
|
readonly request: RequestContext;
|
|
29
|
+
/** The original Response, still readable (the body was parsed from a clone). Absent for network failures. */
|
|
25
30
|
readonly response: Response | undefined;
|
|
31
|
+
/** The underlying error for network failures. */
|
|
26
32
|
readonly cause: unknown;
|
|
27
33
|
constructor(init: ArthronErrorInit);
|
|
28
34
|
}
|
|
35
|
+
/** `true` for errors arthron produced: a `!ok` response or a network failure. Cancellations and plugin bugs are not ArthronErrors. */
|
|
29
36
|
export declare function isArthronError(error: unknown): error is ArthronError;
|
|
30
37
|
//#endregion
|
|
38
|
+
//#region src/stream.d.ts
|
|
39
|
+
/** One server-sent event, as described by the WHATWG EventSource spec. */
|
|
40
|
+
type SseEvent = {
|
|
41
|
+
/** The `event:` field, when the server sent one. */
|
|
42
|
+
event?: string;
|
|
43
|
+
/** All `data:` lines of the block, joined with `\n`. */
|
|
44
|
+
data: string;
|
|
45
|
+
/** The `id:` field, when present. */
|
|
46
|
+
id?: string;
|
|
47
|
+
/** The `retry:` field in ms, when present and numeric. */
|
|
48
|
+
retry?: number;
|
|
49
|
+
};
|
|
50
|
+
//#endregion
|
|
31
51
|
//#region src/url.d.ts
|
|
32
52
|
type QueryParams = Record<string, string | number | boolean | null | undefined>;
|
|
33
53
|
//#endregion
|
|
@@ -83,6 +103,10 @@ type ArthronPromise = Promise<Response> & {
|
|
|
83
103
|
blob(): Promise<Blob>;
|
|
84
104
|
arrayBuffer(): Promise<ArrayBuffer>;
|
|
85
105
|
formData(): Promise<FormData>;
|
|
106
|
+
/** Iterate a `text/event-stream` body as events. */
|
|
107
|
+
sse(): AsyncIterable<SseEvent>;
|
|
108
|
+
/** Iterate a newline-delimited JSON body, one parsed value per line. */
|
|
109
|
+
ndjson<T = unknown>(): AsyncIterable<T>;
|
|
86
110
|
};
|
|
87
111
|
type Arthron = {
|
|
88
112
|
get(path: string, init?: RequestOptions): ArthronPromise;
|
|
@@ -106,26 +130,49 @@ type Arthron = {
|
|
|
106
130
|
export declare function arthron(config?: ArthronConfig): Arthron;
|
|
107
131
|
//#endregion
|
|
108
132
|
//#region src/define.d.ts
|
|
133
|
+
/** Identity helper: gives a middleware written in its own file the right parameter types. */
|
|
109
134
|
export declare const defineMiddleware: (middleware: Middleware) => Middleware;
|
|
135
|
+
/** Identity helpers for each interceptor kind — `defineInterceptor.request(fn)` etc. */
|
|
110
136
|
export declare const defineInterceptor: {
|
|
111
137
|
request: (interceptor: RequestInterceptor) => RequestInterceptor;
|
|
112
138
|
response: (interceptor: ResponseInterceptor) => ResponseInterceptor;
|
|
113
139
|
error: (interceptor: ErrorInterceptor) => ErrorInterceptor;
|
|
114
140
|
};
|
|
141
|
+
/** Identity helper: type-checks a plugin object literal without an annotation. */
|
|
115
142
|
export declare const definePlugin: (plugin: Plugin) => Plugin;
|
|
116
143
|
//#endregion
|
|
117
144
|
//#region src/plugins/logging.d.ts
|
|
145
|
+
type LogEventBase = {
|
|
146
|
+
method: string;
|
|
147
|
+
path: string;
|
|
148
|
+
};
|
|
149
|
+
type LogEvent = ({
|
|
150
|
+
phase: "start";
|
|
151
|
+
} & LogEventBase) | ({
|
|
152
|
+
phase: "success";
|
|
153
|
+
status: number;
|
|
154
|
+
ms: number;
|
|
155
|
+
} & LogEventBase) | ({
|
|
156
|
+
phase: "error";
|
|
157
|
+
/** HTTP status for an ArthronError (0 = network); undefined for anything else. */
|
|
158
|
+
status: number | undefined;
|
|
159
|
+
ms: number;
|
|
160
|
+
error: unknown;
|
|
161
|
+
} & LogEventBase);
|
|
118
162
|
type LoggingOptions = {
|
|
119
163
|
/** Default `true`. The library never reads `NODE_ENV` — pass `isDev` yourself. */
|
|
120
164
|
enabled?: boolean;
|
|
121
165
|
logger?: Pick<Console, "log" | "error">;
|
|
166
|
+
/** Receive structured events instead of the default console lines. */
|
|
167
|
+
onEvent?: (event: LogEvent) => void;
|
|
122
168
|
/** Clock in ms. Injectable for tests. */
|
|
123
169
|
now?: () => number;
|
|
124
170
|
};
|
|
125
171
|
/**
|
|
126
|
-
* One
|
|
172
|
+
* One event when a request starts, one when it settles, with status and
|
|
127
173
|
* elapsed time. Wraps the whole call, so retries are included in the time.
|
|
128
|
-
*
|
|
174
|
+
* Default output is a console line per event; pass `onEvent` to get the
|
|
175
|
+
* structured event instead. Cancellations are not reported.
|
|
129
176
|
*/
|
|
130
177
|
export declare function logging(options?: LoggingOptions): Plugin;
|
|
131
178
|
//#endregion
|
|
@@ -155,5 +202,18 @@ type RetryOptions = {
|
|
|
155
202
|
*/
|
|
156
203
|
export declare function retry(options?: RetryOptions): Plugin;
|
|
157
204
|
//#endregion
|
|
158
|
-
|
|
205
|
+
//#region src/plugins/timeout.d.ts
|
|
206
|
+
/**
|
|
207
|
+
* Abort the request when it takes longer than `ms` — counted from the start
|
|
208
|
+
* of the attempt until the body has been fully read, so a server that sends
|
|
209
|
+
* headers and then stalls still trips it. Rejects with a `TimeoutError` (a
|
|
210
|
+
* `DOMException`, like `AbortSignal.timeout()` would give); it is not an
|
|
211
|
+
* `ArthronError`, so error interceptors and `retry` leave it alone.
|
|
212
|
+
*
|
|
213
|
+
* Built on `AbortController` + `setTimeout` rather than `AbortSignal.any` /
|
|
214
|
+
* `AbortSignal.timeout` so it runs on Node 18.
|
|
215
|
+
*/
|
|
216
|
+
export declare function timeout(ms: number): Plugin;
|
|
217
|
+
//#endregion
|
|
218
|
+
export type { Arthron, ArthronConfig, ArthronPromise, ErrorInterceptor, ErrorMessageResolver, LogEvent, LoggingOptions, Middleware, Plugin, QueryParams, RequestContext, RequestInterceptor, RequestOptions, ResponseInterceptor, RetryOptions, SseEvent };
|
|
159
219
|
//# sourceMappingURL=index.d.cts.map
|
package/dist/index.d.mts
CHANGED
|
@@ -17,17 +17,37 @@ type ArthronErrorInit = {
|
|
|
17
17
|
*/
|
|
18
18
|
export declare class ArthronError extends Error {
|
|
19
19
|
readonly name = "ArthronError";
|
|
20
|
+
/** HTTP status of the failed response; `0` when fetch itself failed (network). */
|
|
20
21
|
readonly status: number;
|
|
21
22
|
readonly statusText: string;
|
|
23
|
+
/** Response headers; empty for network failures. */
|
|
22
24
|
readonly headers: Headers;
|
|
25
|
+
/** Parsed body: JSON if it parses, else the text, else `undefined`. */
|
|
23
26
|
readonly data: unknown;
|
|
27
|
+
/** The context that was sent — url and init after request interceptors. */
|
|
24
28
|
readonly request: RequestContext;
|
|
29
|
+
/** The original Response, still readable (the body was parsed from a clone). Absent for network failures. */
|
|
25
30
|
readonly response: Response | undefined;
|
|
31
|
+
/** The underlying error for network failures. */
|
|
26
32
|
readonly cause: unknown;
|
|
27
33
|
constructor(init: ArthronErrorInit);
|
|
28
34
|
}
|
|
35
|
+
/** `true` for errors arthron produced: a `!ok` response or a network failure. Cancellations and plugin bugs are not ArthronErrors. */
|
|
29
36
|
export declare function isArthronError(error: unknown): error is ArthronError;
|
|
30
37
|
//#endregion
|
|
38
|
+
//#region src/stream.d.ts
|
|
39
|
+
/** One server-sent event, as described by the WHATWG EventSource spec. */
|
|
40
|
+
type SseEvent = {
|
|
41
|
+
/** The `event:` field, when the server sent one. */
|
|
42
|
+
event?: string;
|
|
43
|
+
/** All `data:` lines of the block, joined with `\n`. */
|
|
44
|
+
data: string;
|
|
45
|
+
/** The `id:` field, when present. */
|
|
46
|
+
id?: string;
|
|
47
|
+
/** The `retry:` field in ms, when present and numeric. */
|
|
48
|
+
retry?: number;
|
|
49
|
+
};
|
|
50
|
+
//#endregion
|
|
31
51
|
//#region src/url.d.ts
|
|
32
52
|
type QueryParams = Record<string, string | number | boolean | null | undefined>;
|
|
33
53
|
//#endregion
|
|
@@ -83,6 +103,10 @@ type ArthronPromise = Promise<Response> & {
|
|
|
83
103
|
blob(): Promise<Blob>;
|
|
84
104
|
arrayBuffer(): Promise<ArrayBuffer>;
|
|
85
105
|
formData(): Promise<FormData>;
|
|
106
|
+
/** Iterate a `text/event-stream` body as events. */
|
|
107
|
+
sse(): AsyncIterable<SseEvent>;
|
|
108
|
+
/** Iterate a newline-delimited JSON body, one parsed value per line. */
|
|
109
|
+
ndjson<T = unknown>(): AsyncIterable<T>;
|
|
86
110
|
};
|
|
87
111
|
type Arthron = {
|
|
88
112
|
get(path: string, init?: RequestOptions): ArthronPromise;
|
|
@@ -106,26 +130,49 @@ type Arthron = {
|
|
|
106
130
|
export declare function arthron(config?: ArthronConfig): Arthron;
|
|
107
131
|
//#endregion
|
|
108
132
|
//#region src/define.d.ts
|
|
133
|
+
/** Identity helper: gives a middleware written in its own file the right parameter types. */
|
|
109
134
|
export declare const defineMiddleware: (middleware: Middleware) => Middleware;
|
|
135
|
+
/** Identity helpers for each interceptor kind — `defineInterceptor.request(fn)` etc. */
|
|
110
136
|
export declare const defineInterceptor: {
|
|
111
137
|
request: (interceptor: RequestInterceptor) => RequestInterceptor;
|
|
112
138
|
response: (interceptor: ResponseInterceptor) => ResponseInterceptor;
|
|
113
139
|
error: (interceptor: ErrorInterceptor) => ErrorInterceptor;
|
|
114
140
|
};
|
|
141
|
+
/** Identity helper: type-checks a plugin object literal without an annotation. */
|
|
115
142
|
export declare const definePlugin: (plugin: Plugin) => Plugin;
|
|
116
143
|
//#endregion
|
|
117
144
|
//#region src/plugins/logging.d.ts
|
|
145
|
+
type LogEventBase = {
|
|
146
|
+
method: string;
|
|
147
|
+
path: string;
|
|
148
|
+
};
|
|
149
|
+
type LogEvent = ({
|
|
150
|
+
phase: "start";
|
|
151
|
+
} & LogEventBase) | ({
|
|
152
|
+
phase: "success";
|
|
153
|
+
status: number;
|
|
154
|
+
ms: number;
|
|
155
|
+
} & LogEventBase) | ({
|
|
156
|
+
phase: "error";
|
|
157
|
+
/** HTTP status for an ArthronError (0 = network); undefined for anything else. */
|
|
158
|
+
status: number | undefined;
|
|
159
|
+
ms: number;
|
|
160
|
+
error: unknown;
|
|
161
|
+
} & LogEventBase);
|
|
118
162
|
type LoggingOptions = {
|
|
119
163
|
/** Default `true`. The library never reads `NODE_ENV` — pass `isDev` yourself. */
|
|
120
164
|
enabled?: boolean;
|
|
121
165
|
logger?: Pick<Console, "log" | "error">;
|
|
166
|
+
/** Receive structured events instead of the default console lines. */
|
|
167
|
+
onEvent?: (event: LogEvent) => void;
|
|
122
168
|
/** Clock in ms. Injectable for tests. */
|
|
123
169
|
now?: () => number;
|
|
124
170
|
};
|
|
125
171
|
/**
|
|
126
|
-
* One
|
|
172
|
+
* One event when a request starts, one when it settles, with status and
|
|
127
173
|
* elapsed time. Wraps the whole call, so retries are included in the time.
|
|
128
|
-
*
|
|
174
|
+
* Default output is a console line per event; pass `onEvent` to get the
|
|
175
|
+
* structured event instead. Cancellations are not reported.
|
|
129
176
|
*/
|
|
130
177
|
export declare function logging(options?: LoggingOptions): Plugin;
|
|
131
178
|
//#endregion
|
|
@@ -155,5 +202,18 @@ type RetryOptions = {
|
|
|
155
202
|
*/
|
|
156
203
|
export declare function retry(options?: RetryOptions): Plugin;
|
|
157
204
|
//#endregion
|
|
158
|
-
|
|
205
|
+
//#region src/plugins/timeout.d.ts
|
|
206
|
+
/**
|
|
207
|
+
* Abort the request when it takes longer than `ms` — counted from the start
|
|
208
|
+
* of the attempt until the body has been fully read, so a server that sends
|
|
209
|
+
* headers and then stalls still trips it. Rejects with a `TimeoutError` (a
|
|
210
|
+
* `DOMException`, like `AbortSignal.timeout()` would give); it is not an
|
|
211
|
+
* `ArthronError`, so error interceptors and `retry` leave it alone.
|
|
212
|
+
*
|
|
213
|
+
* Built on `AbortController` + `setTimeout` rather than `AbortSignal.any` /
|
|
214
|
+
* `AbortSignal.timeout` so it runs on Node 18.
|
|
215
|
+
*/
|
|
216
|
+
export declare function timeout(ms: number): Plugin;
|
|
217
|
+
//#endregion
|
|
218
|
+
export type { Arthron, ArthronConfig, ArthronPromise, ErrorInterceptor, ErrorMessageResolver, LogEvent, LoggingOptions, Middleware, Plugin, QueryParams, RequestContext, RequestInterceptor, RequestOptions, ResponseInterceptor, RetryOptions, SseEvent };
|
|
159
219
|
//# sourceMappingURL=index.d.mts.map
|
package/dist/index.mjs
CHANGED
|
@@ -48,12 +48,18 @@ function serializeBody(body, headers) {
|
|
|
48
48
|
*/
|
|
49
49
|
var ArthronError = class extends Error {
|
|
50
50
|
name = "ArthronError";
|
|
51
|
+
/** HTTP status of the failed response; `0` when fetch itself failed (network). */
|
|
51
52
|
status;
|
|
52
53
|
statusText;
|
|
54
|
+
/** Response headers; empty for network failures. */
|
|
53
55
|
headers;
|
|
56
|
+
/** Parsed body: JSON if it parses, else the text, else `undefined`. */
|
|
54
57
|
data;
|
|
58
|
+
/** The context that was sent — url and init after request interceptors. */
|
|
55
59
|
request;
|
|
60
|
+
/** The original Response, still readable (the body was parsed from a clone). Absent for network failures. */
|
|
56
61
|
response;
|
|
62
|
+
/** The underlying error for network failures. */
|
|
57
63
|
cause;
|
|
58
64
|
constructor(init) {
|
|
59
65
|
super(init.message);
|
|
@@ -66,6 +72,7 @@ var ArthronError = class extends Error {
|
|
|
66
72
|
this.cause = init.cause;
|
|
67
73
|
}
|
|
68
74
|
};
|
|
75
|
+
/** `true` for errors arthron produced: a `!ok` response or a network failure. Cancellations and plugin bugs are not ArthronErrors. */
|
|
69
76
|
function isArthronError(error) {
|
|
70
77
|
return error instanceof ArthronError;
|
|
71
78
|
}
|
|
@@ -94,7 +101,7 @@ async function readBody(response) {
|
|
|
94
101
|
}
|
|
95
102
|
}
|
|
96
103
|
async function responseError(response, request, resolve = defaultErrorMessage) {
|
|
97
|
-
const data = await readBody(response);
|
|
104
|
+
const data = await readBody(response.clone());
|
|
98
105
|
return new ArthronError({
|
|
99
106
|
message: resolve(data, response) ?? `Request failed with status ${response.status}`,
|
|
100
107
|
status: response.status,
|
|
@@ -195,7 +202,101 @@ async function runPipeline(plugins, ctx, send) {
|
|
|
195
202
|
return dispatch(0, ctx);
|
|
196
203
|
}
|
|
197
204
|
//#endregion
|
|
205
|
+
//#region src/stream.ts
|
|
206
|
+
/** Yield the body line by line, reassembling lines split across chunks. */
|
|
207
|
+
async function* lines(response) {
|
|
208
|
+
if (!response.body) return;
|
|
209
|
+
const reader = response.body.getReader();
|
|
210
|
+
const decoder = new TextDecoder();
|
|
211
|
+
let buffer = "";
|
|
212
|
+
try {
|
|
213
|
+
while (true) {
|
|
214
|
+
const { value, done } = await reader.read();
|
|
215
|
+
buffer += decoder.decode(value, { stream: !done });
|
|
216
|
+
const parts = buffer.split(/\r\n|\r|\n/);
|
|
217
|
+
buffer = parts.pop() ?? "";
|
|
218
|
+
for (const line of parts) yield line;
|
|
219
|
+
if (done) break;
|
|
220
|
+
}
|
|
221
|
+
if (buffer) yield buffer;
|
|
222
|
+
} finally {
|
|
223
|
+
reader.releaseLock();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Parse a `text/event-stream` body into events. Follows the spec: a blank
|
|
228
|
+
* line dispatches, `data:` lines are joined with `\n`, one leading space
|
|
229
|
+
* after the colon is dropped, lines starting with `:` are comments, and a
|
|
230
|
+
* block without `data` is not dispatched. Provider sentinels like
|
|
231
|
+
* `[DONE]` are passed through untouched — they are not part of the spec.
|
|
232
|
+
*/
|
|
233
|
+
async function* parseSse(response) {
|
|
234
|
+
let data = [];
|
|
235
|
+
let event;
|
|
236
|
+
let id;
|
|
237
|
+
let retry;
|
|
238
|
+
const flush = () => {
|
|
239
|
+
if (data.length === 0) {
|
|
240
|
+
event = id = retry = void 0;
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const out = { data: data.join("\n") };
|
|
244
|
+
if (event !== void 0) out.event = event;
|
|
245
|
+
if (id !== void 0) out.id = id;
|
|
246
|
+
if (retry !== void 0) out.retry = retry;
|
|
247
|
+
data = [];
|
|
248
|
+
event = id = retry = void 0;
|
|
249
|
+
return out;
|
|
250
|
+
};
|
|
251
|
+
for await (const line of lines(response)) {
|
|
252
|
+
if (line === "") {
|
|
253
|
+
const out = flush();
|
|
254
|
+
if (out) yield out;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (line.startsWith(":")) continue;
|
|
258
|
+
const colon = line.indexOf(":");
|
|
259
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
260
|
+
let value = colon === -1 ? "" : line.slice(colon + 1);
|
|
261
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
262
|
+
switch (field) {
|
|
263
|
+
case "data":
|
|
264
|
+
data.push(value);
|
|
265
|
+
break;
|
|
266
|
+
case "event":
|
|
267
|
+
event = value;
|
|
268
|
+
break;
|
|
269
|
+
case "id":
|
|
270
|
+
id = value;
|
|
271
|
+
break;
|
|
272
|
+
case "retry": {
|
|
273
|
+
const ms = Number(value);
|
|
274
|
+
if (Number.isInteger(ms) && ms >= 0) retry = ms;
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const out = flush();
|
|
280
|
+
if (out) yield out;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Parse newline-delimited JSON. Blank lines are skipped; a malformed line
|
|
284
|
+
* rejects the iteration with the `SyntaxError` from `JSON.parse`.
|
|
285
|
+
*/
|
|
286
|
+
async function* parseNdjson(response) {
|
|
287
|
+
for await (const line of lines(response)) {
|
|
288
|
+
if (line.trim() === "") continue;
|
|
289
|
+
yield JSON.parse(line);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
//#endregion
|
|
198
293
|
//#region src/promise.ts
|
|
294
|
+
/** Defer the Response until iteration starts, so the helper itself never rejects. */
|
|
295
|
+
function iterate(promise, parse) {
|
|
296
|
+
return { async *[Symbol.asyncIterator]() {
|
|
297
|
+
yield* parse(await promise);
|
|
298
|
+
} };
|
|
299
|
+
}
|
|
199
300
|
/**
|
|
200
301
|
* Sugar over `Promise<Response>`: `.json()` is `(await p).json()`. Awaiting
|
|
201
302
|
* without a helper still yields the untouched `Response`.
|
|
@@ -206,7 +307,9 @@ function toArthronPromise(promise) {
|
|
|
206
307
|
text: () => promise.then((res) => res.text()),
|
|
207
308
|
blob: () => promise.then((res) => res.blob()),
|
|
208
309
|
arrayBuffer: () => promise.then((res) => res.arrayBuffer()),
|
|
209
|
-
formData: () => promise.then((res) => res.formData())
|
|
310
|
+
formData: () => promise.then((res) => res.formData()),
|
|
311
|
+
sse: () => iterate(promise, parseSse),
|
|
312
|
+
ndjson: () => iterate(promise, parseNdjson)
|
|
210
313
|
});
|
|
211
314
|
}
|
|
212
315
|
//#endregion
|
|
@@ -233,15 +336,18 @@ function resolveUrl(baseURL, path, params) {
|
|
|
233
336
|
let url;
|
|
234
337
|
if (isAbsoluteUrl(path)) url = path;
|
|
235
338
|
else {
|
|
236
|
-
if (baseURL === void 0) throw new TypeError(`arthron: relative path "${path}"
|
|
339
|
+
if (baseURL === void 0) throw new TypeError(`arthron: relative path "${path}" needs a baseURL — pass baseURL in arthron({ baseURL }) or use an absolute URL`);
|
|
237
340
|
url = `${baseURL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
|
|
238
341
|
}
|
|
239
342
|
const query = toQueryString(params);
|
|
240
343
|
if (!query) return url;
|
|
241
|
-
|
|
344
|
+
const hash = url.indexOf("#");
|
|
345
|
+
const [base, fragment] = hash === -1 ? [url, ""] : [url.slice(0, hash), url.slice(hash)];
|
|
346
|
+
return `${base}${base.includes("?") ? "&" : "?"}${query}${fragment}`;
|
|
242
347
|
}
|
|
243
348
|
//#endregion
|
|
244
349
|
//#region src/client.ts
|
|
350
|
+
const BODYLESS_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD"]);
|
|
245
351
|
/** Normalize once so `extend` can merge headers and append plugins cheaply. */
|
|
246
352
|
function resolveConfig(config) {
|
|
247
353
|
const { headers, use, ...rest } = config;
|
|
@@ -257,9 +363,9 @@ function resolveConfig(config) {
|
|
|
257
363
|
*/
|
|
258
364
|
function arthron(config = {}) {
|
|
259
365
|
const resolved = resolveConfig(config);
|
|
260
|
-
const fetchImpl = resolved.fetch ?? globalThis.fetch;
|
|
261
366
|
/** The one place a failure becomes an ArthronError. Cancellation passes through. */
|
|
262
367
|
const send = async (ctx) => {
|
|
368
|
+
const fetchImpl = resolved.fetch ?? globalThis.fetch;
|
|
263
369
|
let res;
|
|
264
370
|
try {
|
|
265
371
|
res = await fetchImpl(ctx.url, ctx.init);
|
|
@@ -272,6 +378,7 @@ function arthron(config = {}) {
|
|
|
272
378
|
};
|
|
273
379
|
const dispatch = (path, method, rawBody, { params, headers: callHeaders, ...init }) => {
|
|
274
380
|
try {
|
|
381
|
+
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`);
|
|
275
382
|
const url = resolveUrl(resolved.baseURL, path, params);
|
|
276
383
|
const { body, headers } = serializeBody(rawBody, mergeHeaders(resolved.headers, callHeaders));
|
|
277
384
|
const requestInit = {
|
|
@@ -306,7 +413,7 @@ function arthron(config = {}) {
|
|
|
306
413
|
patch: (path, body, init = {}) => dispatch(path, "PATCH", body, init),
|
|
307
414
|
request: (path, init) => {
|
|
308
415
|
const { method, body, ...rest } = init;
|
|
309
|
-
if (!method) return toArthronPromise(Promise.reject(/* @__PURE__ */ new TypeError("arthron: request()
|
|
416
|
+
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")));
|
|
310
417
|
return dispatch(path, method, body, rest);
|
|
311
418
|
},
|
|
312
419
|
use: (...plugins) => derive({ use: plugins }),
|
|
@@ -315,41 +422,79 @@ function arthron(config = {}) {
|
|
|
315
422
|
}
|
|
316
423
|
//#endregion
|
|
317
424
|
//#region src/define.ts
|
|
425
|
+
/** Identity helper: gives a middleware written in its own file the right parameter types. */
|
|
318
426
|
const defineMiddleware = (middleware) => middleware;
|
|
427
|
+
/** Identity helpers for each interceptor kind — `defineInterceptor.request(fn)` etc. */
|
|
319
428
|
const defineInterceptor = {
|
|
320
429
|
request: (interceptor) => interceptor,
|
|
321
430
|
response: (interceptor) => interceptor,
|
|
322
431
|
error: (interceptor) => interceptor
|
|
323
432
|
};
|
|
433
|
+
/** Identity helper: type-checks a plugin object literal without an annotation. */
|
|
324
434
|
const definePlugin = (plugin) => plugin;
|
|
325
435
|
//#endregion
|
|
326
436
|
//#region src/plugins/logging.ts
|
|
437
|
+
/** Origin + pathname only — the query and userinfo tend to carry secrets. */
|
|
438
|
+
function displayPath(url) {
|
|
439
|
+
try {
|
|
440
|
+
const parsed = new URL(url);
|
|
441
|
+
return `${parsed.origin}${parsed.pathname}`;
|
|
442
|
+
} catch {
|
|
443
|
+
return url.split("?")[0] ?? url;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
function consoleSink(logger) {
|
|
447
|
+
return (e) => {
|
|
448
|
+
if (e.phase === "start") logger.log(`[arthron] → ${e.method} ${e.path}`);
|
|
449
|
+
else if (e.phase === "success") logger.log(`[arthron] ← ${e.method} ${e.path} ${e.status} ${e.ms}ms`);
|
|
450
|
+
else logger.error(`[arthron] ✕ ${e.method} ${e.path} ${e.status ?? "-"} ${e.ms}ms`);
|
|
451
|
+
};
|
|
452
|
+
}
|
|
327
453
|
/**
|
|
328
|
-
* One
|
|
454
|
+
* One event when a request starts, one when it settles, with status and
|
|
329
455
|
* elapsed time. Wraps the whole call, so retries are included in the time.
|
|
330
|
-
*
|
|
456
|
+
* Default output is a console line per event; pass `onEvent` to get the
|
|
457
|
+
* structured event instead. Cancellations are not reported.
|
|
331
458
|
*/
|
|
332
459
|
function logging(options = {}) {
|
|
333
|
-
const { enabled = true, logger = console, now = () => performance.now() } = options;
|
|
460
|
+
const { enabled = true, logger = console, onEvent, now = () => performance.now() } = options;
|
|
461
|
+
const emit = onEvent ?? consoleSink(logger);
|
|
334
462
|
return {
|
|
335
463
|
name: "logging",
|
|
336
464
|
middleware: async (ctx, next) => {
|
|
337
465
|
if (!enabled) return next(ctx);
|
|
338
466
|
const method = ctx.init.method ?? "GET";
|
|
339
|
-
const path = ctx.url
|
|
467
|
+
const path = displayPath(ctx.url);
|
|
340
468
|
const started = now();
|
|
341
|
-
const
|
|
342
|
-
|
|
469
|
+
const ms = () => Math.round(now() - started);
|
|
470
|
+
emit({
|
|
471
|
+
phase: "start",
|
|
472
|
+
method,
|
|
473
|
+
path
|
|
474
|
+
});
|
|
343
475
|
try {
|
|
344
476
|
const res = await next(ctx);
|
|
345
|
-
|
|
477
|
+
emit({
|
|
478
|
+
phase: "success",
|
|
479
|
+
method,
|
|
480
|
+
path,
|
|
481
|
+
status: res.status,
|
|
482
|
+
ms: ms()
|
|
483
|
+
});
|
|
346
484
|
return res;
|
|
347
|
-
} catch (
|
|
348
|
-
if (!isCancellation(
|
|
349
|
-
const status = isArthronError(
|
|
350
|
-
|
|
485
|
+
} catch (error) {
|
|
486
|
+
if (!isCancellation(error)) {
|
|
487
|
+
const status = isArthronError(error) ? error.status : void 0;
|
|
488
|
+
emit({
|
|
489
|
+
phase: "error",
|
|
490
|
+
method,
|
|
491
|
+
path,
|
|
492
|
+
status,
|
|
493
|
+
ms: ms(),
|
|
494
|
+
error
|
|
495
|
+
});
|
|
351
496
|
}
|
|
352
|
-
throw
|
|
497
|
+
throw error;
|
|
353
498
|
}
|
|
354
499
|
}
|
|
355
500
|
};
|
|
@@ -427,6 +572,85 @@ function retry(options = {}) {
|
|
|
427
572
|
};
|
|
428
573
|
}
|
|
429
574
|
//#endregion
|
|
430
|
-
|
|
575
|
+
//#region src/plugins/timeout.ts
|
|
576
|
+
/**
|
|
577
|
+
* Wrap `body` so the deadline keeps running until it is fully read, and so an
|
|
578
|
+
* abort while reading surfaces as a stream error instead of a silent stall.
|
|
579
|
+
*/
|
|
580
|
+
function guardBody(body, signal, finish) {
|
|
581
|
+
const reader = body.getReader();
|
|
582
|
+
return new ReadableStream({
|
|
583
|
+
start(controller) {
|
|
584
|
+
signal.addEventListener("abort", () => {
|
|
585
|
+
controller.error(signal.reason);
|
|
586
|
+
reader.cancel(signal.reason).catch(() => {});
|
|
587
|
+
}, { once: true });
|
|
588
|
+
},
|
|
589
|
+
async pull(controller) {
|
|
590
|
+
const { value, done } = await reader.read();
|
|
591
|
+
if (done) {
|
|
592
|
+
finish();
|
|
593
|
+
controller.close();
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
controller.enqueue(value);
|
|
597
|
+
},
|
|
598
|
+
cancel(reason) {
|
|
599
|
+
finish();
|
|
600
|
+
return reader.cancel(reason);
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Abort the request when it takes longer than `ms` — counted from the start
|
|
606
|
+
* of the attempt until the body has been fully read, so a server that sends
|
|
607
|
+
* headers and then stalls still trips it. Rejects with a `TimeoutError` (a
|
|
608
|
+
* `DOMException`, like `AbortSignal.timeout()` would give); it is not an
|
|
609
|
+
* `ArthronError`, so error interceptors and `retry` leave it alone.
|
|
610
|
+
*
|
|
611
|
+
* Built on `AbortController` + `setTimeout` rather than `AbortSignal.any` /
|
|
612
|
+
* `AbortSignal.timeout` so it runs on Node 18.
|
|
613
|
+
*/
|
|
614
|
+
function timeout(ms) {
|
|
615
|
+
return {
|
|
616
|
+
name: "timeout",
|
|
617
|
+
middleware: async (ctx, next) => {
|
|
618
|
+
const controller = new AbortController();
|
|
619
|
+
const outer = ctx.init.signal;
|
|
620
|
+
const forward = () => controller.abort(outer?.reason);
|
|
621
|
+
if (outer?.aborted) forward();
|
|
622
|
+
else outer?.addEventListener("abort", forward, { once: true });
|
|
623
|
+
const timer = setTimeout(() => controller.abort(new DOMException(`arthron: request timed out after ${ms}ms`, "TimeoutError")), ms);
|
|
624
|
+
const finish = () => {
|
|
625
|
+
clearTimeout(timer);
|
|
626
|
+
outer?.removeEventListener("abort", forward);
|
|
627
|
+
};
|
|
628
|
+
let res;
|
|
629
|
+
try {
|
|
630
|
+
res = await next({
|
|
631
|
+
...ctx,
|
|
632
|
+
init: {
|
|
633
|
+
...ctx.init,
|
|
634
|
+
signal: controller.signal
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
} catch (thrown) {
|
|
638
|
+
finish();
|
|
639
|
+
throw thrown;
|
|
640
|
+
}
|
|
641
|
+
if (!res.body) {
|
|
642
|
+
finish();
|
|
643
|
+
return res;
|
|
644
|
+
}
|
|
645
|
+
return new Response(guardBody(res.body, controller.signal, finish), {
|
|
646
|
+
status: res.status,
|
|
647
|
+
statusText: res.statusText,
|
|
648
|
+
headers: res.headers
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
//#endregion
|
|
654
|
+
export { ArthronError, arthron, defineInterceptor, defineMiddleware, definePlugin, isArthronError, logging, retry, timeout };
|
|
431
655
|
|
|
432
656
|
//# sourceMappingURL=index.mjs.map
|