@askrjs/fetch 0.0.6 → 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 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 20.19 or newer.
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` is honored when present. Cloneable request
143
- bodies are replayed with the original bytes and headers. `ReadableStream` bodies are explicitly
144
- single-attempt so retry does not buffer an unbounded stream. If an earlier middleware has already
145
- consumed any body, retry also sends it once and does not surface an incidental cloning error.
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 [type, selected] = Object.entries(codec.variants ?? {})[0] ?? [];
64
- if (!selected) throw new TypeError("content() has no variants");
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", 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
  }
@@ -236,7 +240,7 @@ function createFetch(options = {}) {
236
240
  }
237
241
  };
238
242
  const middleware = options.middleware ?? [];
239
- const dispatch = (index, context) => index === middleware.length ? terminal(context) : Promise.resolve().then(() => middleware[index](context, (next = context) => dispatch(index + 1, next))).catch((error) => failure("middleware", error, url));
243
+ const dispatch = (index, context) => index === middleware.length ? terminal(context) : Promise.resolve().then(() => middleware[index](context, (next = context) => dispatch(index + 1, next)));
240
244
  try {
241
245
  return await dispatch(0, Object.freeze({
242
246
  request,
@@ -247,6 +251,8 @@ function createFetch(options = {}) {
247
251
  ...body === void 0 ? {} : { replayableBody: !(body instanceof ReadableStream) },
248
252
  ...timeout === void 0 ? {} : { deadline: Date.now() + timeout }
249
253
  }));
254
+ } catch (error) {
255
+ return failure("middleware", error, url);
250
256
  } finally {
251
257
  cleanup();
252
258
  }
@@ -287,6 +293,7 @@ function createClient(api, options = {}) {
287
293
  querySpec: endpoint.query,
288
294
  body: input.body,
289
295
  bodyCodec: endpoint.body,
296
+ bodyMediaType: input.bodyMediaType,
290
297
  responses: endpoint.responses,
291
298
  errors: endpoint.errors,
292
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>;
@@ -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
@@ -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 wait = retryAfter ? /^\d+$/.test(retryAfter) ? Number(retryAfter) * 1e3 : Math.max(0, Date.parse(retryAfter) - Date.now()) : options.delay?.(attempt) ?? 100 * 2 ** (attempt - 2);
88
- if (context.deadline && Date.now() + wait >= context.deadline) break;
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askrjs/fetch",
3
- "version": "0.0.6",
3
+ "version": "0.2.1",
4
4
  "description": "Function-first typed HTTP contracts and clients for Askr.",
5
5
  "keywords": [
6
6
  "askr",