@askrjs/fetch 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -5
- package/dist/client.d.ts +3 -0
- package/dist/client.js +10 -5
- package/dist/dsl.d.ts +4 -0
- package/dist/middleware.d.ts +2 -0
- package/dist/middleware.js +6 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
Function-first HTTP contracts, typed clients, codecs, and middleware for Askr. The package is
|
|
7
7
|
ESM-only, has no runtime dependencies, and works with the standard Fetch APIs in modern browsers
|
|
8
|
-
and Node.js
|
|
8
|
+
and Node.js 24 or newer.
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
@@ -46,6 +46,12 @@ if (result.ok) {
|
|
|
46
46
|
Paths use OpenAPI-style `{name}` parameters. Colon parameters and wildcards are rejected. A path
|
|
47
47
|
parameter declaration must exactly match the names in the path.
|
|
48
48
|
|
|
49
|
+
Path values are runtime-validated or transformed only when their `.params()` entry includes a
|
|
50
|
+
validator. The generic in `.params<T>()` is a compile-time contract and is erased at runtime; using
|
|
51
|
+
`.params<T>()` without validators, or relying on auto-derived path parameters, only checks that
|
|
52
|
+
required names are present. Supply a validator for every path value when runtime safety or coercion
|
|
53
|
+
is required.
|
|
54
|
+
|
|
49
55
|
Every call returns a discriminated result instead of throwing for request, transport, HTTP, or
|
|
50
56
|
decode failures. Use `unwrap(result)` when exception-based control flow is more convenient.
|
|
51
57
|
|
|
@@ -63,6 +69,20 @@ The built-in codecs are:
|
|
|
63
69
|
- `empty()`
|
|
64
70
|
- `content({ mediaType: codec })`
|
|
65
71
|
|
|
72
|
+
For outbound bodies with more than one `content()` variant, pass
|
|
73
|
+
`bodyMediaType` explicitly. Single-variant codecs select their only variant
|
|
74
|
+
automatically:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
await client.createDocument({
|
|
78
|
+
body: "plain text",
|
|
79
|
+
bodyMediaType: "text/plain",
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
An omitted or unknown media type produces a `request` failure, so declaration
|
|
84
|
+
order never selects a multi-variant request format.
|
|
85
|
+
|
|
66
86
|
A validator only needs a `safeParse(value)` method, so schema libraries with that contract can be
|
|
67
87
|
used without an adapter. Validators run for request bodies, path parameters, query parameters,
|
|
68
88
|
headers, and decoded response bodies. Successful validator transformations are used for
|
|
@@ -139,10 +159,12 @@ sequence is intentional. Logging redacts common credential names as well as arbi
|
|
|
139
159
|
query names added by `apiKeyAuth()`.
|
|
140
160
|
|
|
141
161
|
Retries default to `GET`, `HEAD`, `PUT`, `DELETE`, and `OPTIONS`, and to statuses `408`, `425`,
|
|
142
|
-
`429`, `500`, `502`, `503`, and `504`. `Retry-After`
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
162
|
+
`429`, `500`, `502`, `503`, and `504`. Valid `Retry-After` values are honored up to
|
|
163
|
+
`maxRetryAfter` (60 seconds by default); malformed values use the normal backoff. Cloneable
|
|
164
|
+
request bodies are replayed with the original bytes and headers. `ReadableStream` bodies are
|
|
165
|
+
explicitly single-attempt so retry does not buffer an unbounded stream. If an earlier middleware
|
|
166
|
+
has already consumed any body, retry also sends it once and does not surface an incidental cloning
|
|
167
|
+
error.
|
|
146
168
|
|
|
147
169
|
Do not include a status such as `401` in `retry()` when an upstream authentication middleware
|
|
148
170
|
already handles that status. The outer authentication layer cannot react until retry's complete
|
package/dist/client.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ interface AdHocCall {
|
|
|
9
9
|
querySpec?: ParameterMap;
|
|
10
10
|
body?: unknown;
|
|
11
11
|
bodyCodec?: Codec;
|
|
12
|
+
/** Explicit media type used to encode a request body with a multi-variant `content()` codec. */
|
|
13
|
+
bodyMediaType?: string;
|
|
12
14
|
response?: Codec;
|
|
13
15
|
responses?: Readonly<Record<number, Codec>>;
|
|
14
16
|
errors?: Partial<Record<number | "default", Codec>>;
|
|
@@ -33,6 +35,7 @@ type InputParts<D> = D extends EndpointDescriptor<infer P, infer Q, infer H, inf
|
|
|
33
35
|
type RequiredKeys<T> = { [K in keyof T]-?: {} extends Pick<T, K> ? never : K; }[keyof T];
|
|
34
36
|
type Container<K extends string, T, Always extends boolean = false> = [T] extends [undefined] ? {} : Always extends true ? { [P in K]: T; } : RequiredKeys<T> extends never ? { [P in K]?: T; } : { [P in K]: T; };
|
|
35
37
|
type EndpointInput<D extends AnyEndpointDescriptor> = Container<"params", InputParts<D>["params"], true> & Container<"query", InputParts<D>["query"]> & Container<"headers", InputParts<D>["headers"]> & Container<"body", InputParts<D>["body"], true> & {
|
|
38
|
+
bodyMediaType?: string;
|
|
36
39
|
signal?: AbortSignal;
|
|
37
40
|
timeout?: number;
|
|
38
41
|
};
|
package/dist/client.js
CHANGED
|
@@ -38,7 +38,7 @@ async function decode(response, codec) {
|
|
|
38
38
|
}
|
|
39
39
|
return value;
|
|
40
40
|
}
|
|
41
|
-
function encode(value, codec, headers) {
|
|
41
|
+
function encode(value, codec, headers, requestedMediaType) {
|
|
42
42
|
if (codec.validator) {
|
|
43
43
|
const parsed = codec.validator.safeParse(value);
|
|
44
44
|
if (!parsed.success) throw parsed.error;
|
|
@@ -60,10 +60,14 @@ function encode(value, codec, headers) {
|
|
|
60
60
|
case "arrayBuffer":
|
|
61
61
|
case "stream": return value;
|
|
62
62
|
case "content": {
|
|
63
|
-
const
|
|
64
|
-
if (
|
|
63
|
+
const variants = Object.entries(codec.variants ?? {});
|
|
64
|
+
if (variants.length === 0) throw new TypeError("content() has no variants");
|
|
65
|
+
const type = requestedMediaType?.split(";", 1)[0]?.trim().toLowerCase();
|
|
66
|
+
const [selectedType, selected] = type ? [type, codec.variants?.[type]] : variants.length === 1 ? variants[0] : [];
|
|
67
|
+
if (type && !selected) throw new TypeError(`No content() request variant for media type ${type}`);
|
|
68
|
+
if (!selected) throw new TypeError("Multi-variant content() request bodies require bodyMediaType");
|
|
65
69
|
const body = encode(value, selected, headers);
|
|
66
|
-
if (selected.kind !== "multipart") headers.set("content-type",
|
|
70
|
+
if (selected.kind !== "multipart") headers.set("content-type", selectedType);
|
|
67
71
|
return body;
|
|
68
72
|
}
|
|
69
73
|
}
|
|
@@ -163,7 +167,7 @@ function createFetch(options = {}) {
|
|
|
163
167
|
}
|
|
164
168
|
let body;
|
|
165
169
|
try {
|
|
166
|
-
if (call.bodyCodec) body = encode(call.body, call.bodyCodec, headers);
|
|
170
|
+
if (call.bodyCodec) body = encode(call.body, call.bodyCodec, headers, call.bodyMediaType);
|
|
167
171
|
} catch (error) {
|
|
168
172
|
return failure("request", error, url);
|
|
169
173
|
}
|
|
@@ -289,6 +293,7 @@ function createClient(api, options = {}) {
|
|
|
289
293
|
querySpec: endpoint.query,
|
|
290
294
|
body: input.body,
|
|
291
295
|
bodyCodec: endpoint.body,
|
|
296
|
+
bodyMediaType: input.bodyMediaType,
|
|
292
297
|
responses: endpoint.responses,
|
|
293
298
|
errors: endpoint.errors,
|
|
294
299
|
signal: input.signal,
|
package/dist/dsl.d.ts
CHANGED
|
@@ -22,6 +22,10 @@ declare const STATE: unique symbol;
|
|
|
22
22
|
*/
|
|
23
23
|
interface EndpointBuilder<P = undefined, Q = undefined, H = undefined, B = undefined, R extends Responses = {}, E extends Errors = {}> {
|
|
24
24
|
readonly [STATE]: State;
|
|
25
|
+
/**
|
|
26
|
+
* Declares path parameter types and optional runtime validators. Generic types are erased;
|
|
27
|
+
* runtime validation occurs only for entries whose specification includes a validator.
|
|
28
|
+
*/
|
|
25
29
|
params<T extends Record<string, unknown>>(spec?: ParameterMap): EndpointBuilder<T, Q, H, B, R, E>;
|
|
26
30
|
query<T extends Record<string, unknown>>(spec?: ParameterMap): EndpointBuilder<P, T, H, B, R, E>;
|
|
27
31
|
headers<T extends Record<string, unknown>>(spec?: ParameterMap): EndpointBuilder<P, Q, T, B, R, E>;
|
package/dist/middleware.d.ts
CHANGED
|
@@ -28,6 +28,8 @@ interface RetryOptions {
|
|
|
28
28
|
statuses?: readonly number[];
|
|
29
29
|
/** Computes the delay (ms) before the given retry attempt, if no `Retry-After` header is present. */
|
|
30
30
|
delay?: (attempt: number) => number;
|
|
31
|
+
/** Maximum delay (ms) accepted from `Retry-After`. Defaults to 60 seconds. */
|
|
32
|
+
maxRetryAfter?: number;
|
|
31
33
|
}
|
|
32
34
|
/**
|
|
33
35
|
* Middleware that retries failed requests. Retries eligible methods on network failures
|
package/dist/middleware.js
CHANGED
|
@@ -72,6 +72,8 @@ function retry(options = {}) {
|
|
|
72
72
|
503,
|
|
73
73
|
504
|
|
74
74
|
];
|
|
75
|
+
const maxRetryAfter = options.maxRetryAfter ?? 6e4;
|
|
76
|
+
if (!Number.isFinite(maxRetryAfter) || maxRetryAfter < 0) throw new TypeError("maxRetryAfter must be a non-negative finite number");
|
|
75
77
|
return async (context, next) => {
|
|
76
78
|
if (!methods.includes(context.request.method)) return next(context);
|
|
77
79
|
if (context.replayableBody === false) return next(context);
|
|
@@ -84,8 +86,10 @@ function retry(options = {}) {
|
|
|
84
86
|
let result = await next(context);
|
|
85
87
|
for (let attempt = 2; attempt <= attempts && !result.ok && (result.kind === "network" || result.response !== void 0 && statuses.includes(result.status)); attempt++) {
|
|
86
88
|
const retryAfter = "response" in result ? result.response?.headers.get("retry-after") : null;
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
+
const fallback = () => options.delay?.(attempt) ?? 100 * 2 ** (attempt - 2);
|
|
90
|
+
const parsedRetryAfter = retryAfter ? /^\d+$/.test(retryAfter) ? Number(retryAfter) * 1e3 : Math.max(0, Date.parse(retryAfter) - Date.now()) : void 0;
|
|
91
|
+
const wait = parsedRetryAfter === void 0 || !Number.isFinite(parsedRetryAfter) ? fallback() : Math.min(parsedRetryAfter, maxRetryAfter);
|
|
92
|
+
if (context.deadline !== void 0 && Date.now() + wait >= context.deadline) break;
|
|
89
93
|
if (!await new Promise((resolve) => {
|
|
90
94
|
const onAbort = () => {
|
|
91
95
|
clearTimeout(timer);
|