@askrjs/fetch 0.2.0 → 0.3.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 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,10 +69,25 @@ The built-in codecs are:
63
69
  - `empty()`
64
70
  - `content({ mediaType: codec })`
65
71
 
66
- A validator only needs a `safeParse(value)` method, so schema libraries with that contract can be
67
- used without an adapter. Validators run for request bodies, path parameters, query parameters,
68
- headers, and decoded response bodies. Successful validator transformations are used for
69
- serialization and returned data.
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
+
86
+ A validator only needs a `safeParse(value)` method whose failure result exposes either `error` or
87
+ `issues`, so `@askrjs/schema` and other schema libraries with that contract can be used without an
88
+ adapter. Validators run for request bodies, path parameters, query parameters, headers, and decoded
89
+ response bodies. Successful validator transformations are used for serialization and returned data;
90
+ the validator's `error` or `issues` value is preserved on failure.
70
91
 
71
92
  ```ts
72
93
  const positiveInteger = {
@@ -139,10 +160,12 @@ sequence is intentional. Logging redacts common credential names as well as arbi
139
160
  query names added by `apiKeyAuth()`.
140
161
 
141
162
  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.
163
+ `429`, `500`, `502`, `503`, and `504`. Valid `Retry-After` values are honored up to
164
+ `maxRetryAfter` (60 seconds by default); malformed values use the normal backoff. Cloneable
165
+ request bodies are replayed with the original bytes and headers. `ReadableStream` bodies are
166
+ explicitly single-attempt so retry does not buffer an unbounded stream. If an earlier middleware
167
+ has already consumed any body, retry also sends it once and does not surface an incidental cloning
168
+ error.
146
169
 
147
170
  Do not include a status such as `401` in `retry()` when an upstream authentication middleware
148
171
  already handles that status. The outer authentication layer cannot react until retry's complete
package/dist/client.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { AnyEndpointDescriptor, ApiDefinition, ClientOptions, Codec, EndpointDescriptor, FailureResult, FetchResult, HttpResult, InferCodec, ParameterMap, SuccessResult } from "./types.js";
2
2
  //#region src/client.d.ts
3
3
  /** A one-off, untyped request description accepted by {@link createFetch}'s returned function. */
4
- interface AdHocCall {
4
+ export interface AdHocCall {
5
5
  url: string;
6
6
  method?: string;
7
7
  headers?: HeadersInit;
@@ -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>>;
@@ -23,7 +25,7 @@ interface AdHocCall {
23
25
  * matching codec. Used internally by {@link createClient}, and usable directly for
24
26
  * requests without a full {@link EndpointDescriptor}.
25
27
  */
26
- declare function createFetch(options?: ClientOptions): (call: AdHocCall) => Promise<FetchResult>;
28
+ export declare function createFetch(options?: ClientOptions): (call: AdHocCall) => Promise<FetchResult>;
27
29
  type InputParts<D> = D extends EndpointDescriptor<infer P, infer Q, infer H, infer B, any, any> ? {
28
30
  params: P;
29
31
  query: Q;
@@ -33,24 +35,25 @@ 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
  };
39
42
  type Successes<D> = D extends EndpointDescriptor<any, any, any, any, infer R, any> ? { [S in keyof R & number]: SuccessResult<InferCodec<R[S]>, S>; }[keyof R & number] : never;
40
43
  type Errors<D> = D extends EndpointDescriptor<any, any, any, any, any, infer E> ? { [S in Exclude<keyof E, "default"> & number]: HttpResult<InferCodec<NonNullable<E[S]>>, S>; }[Exclude<keyof E, "default"> & number] | ("default" extends keyof E ? HttpResult<InferCodec<NonNullable<E["default"]>>, number> : never) : never;
41
44
  /** The possible results of calling a typed client method for endpoint descriptor `D`. */
42
- type ClientResult<D extends AnyEndpointDescriptor> = Successes<D> | Errors<D> | FailureResult;
45
+ export type ClientResult<D extends AnyEndpointDescriptor> = Successes<D> | Errors<D> | FailureResult;
43
46
  type ClientMethod<D extends AnyEndpointDescriptor> = RequiredKeys<EndpointInput<D>> extends never ? (input?: EndpointInput<D>) => Promise<ClientResult<D>> : (input: EndpointInput<D>) => Promise<ClientResult<D>>;
44
47
  /** A fully-typed client for an {@link ApiDefinition}, with one method per endpoint. */
45
- type ApiClient<A extends ApiDefinition> = Readonly<{ [K in keyof A["endpoints"]]: ClientMethod<A["endpoints"][K]>; }>;
48
+ export type ApiClient<A extends ApiDefinition> = Readonly<{ [K in keyof A["endpoints"]]: ClientMethod<A["endpoints"][K]>; }>;
46
49
  /**
47
50
  * Builds a typed {@link ApiClient} from an {@link ApiDefinition}. Each endpoint becomes
48
51
  * a method that fills in the path, query, header, and body parameters, executes the
49
52
  * request via {@link createFetch}, and returns a {@link ClientResult}.
50
53
  */
51
- declare function createClient<A extends ApiDefinition>(api: A, options?: ClientOptions): ApiClient<A>;
54
+ export declare function createClient<A extends ApiDefinition>(api: A, options?: ClientOptions): ApiClient<A>;
52
55
  /** An `Error` thrown by {@link unwrap} that wraps a failed (non-`ok`) {@link FetchResult}. */
53
- declare class FetchError extends Error {
56
+ export declare class FetchError extends Error {
54
57
  readonly result: Exclude<FetchResult, {
55
58
  ok: true;
56
59
  }>;
@@ -62,6 +65,5 @@ declare class FetchError extends Error {
62
65
  * Returns the data of a successful {@link FetchResult}, or throws a {@link FetchError}
63
66
  * wrapping the result if it was not `ok`.
64
67
  */
65
- declare function unwrap<T>(result: FetchResult<T>): T;
66
- //#endregion
67
- export { AdHocCall, ApiClient, ClientResult, FetchError, createClient, createFetch, unwrap };
68
+ export declare function unwrap<T>(result: FetchResult<T>): T;
69
+ //#endregion
package/dist/client.js CHANGED
@@ -1,6 +1,7 @@
1
1
  //#region src/client.ts
2
2
  const media = (response) => response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() ?? null;
3
3
  const compatible = (codec, type) => codec.kind === "empty" || codec.kind === "blob" || codec.kind === "arrayBuffer" || codec.kind === "stream" || !!type && codec.mediaTypes.some((expected) => expected === "*/*" || expected === type || expected === "text/*" && type.startsWith("text/") || expected === "+json" && type.endsWith("+json"));
4
+ const validationFailure = (result) => "error" in result ? result.error : result.issues;
4
5
  async function decode(response, codec) {
5
6
  const type = media(response);
6
7
  const selected = codec.kind === "content" ? codec.variants?.[type ?? ""] : codec;
@@ -33,15 +34,15 @@ async function decode(response, codec) {
33
34
  }
34
35
  if (selected.validator) {
35
36
  const parsed = selected.validator.safeParse(value);
36
- if (!parsed.success) throw parsed.error;
37
+ if (!parsed.success) throw validationFailure(parsed);
37
38
  return parsed.data;
38
39
  }
39
40
  return value;
40
41
  }
41
- function encode(value, codec, headers) {
42
+ function encode(value, codec, headers, requestedMediaType) {
42
43
  if (codec.validator) {
43
44
  const parsed = codec.validator.safeParse(value);
44
- if (!parsed.success) throw parsed.error;
45
+ if (!parsed.success) throw validationFailure(parsed);
45
46
  value = parsed.data;
46
47
  }
47
48
  switch (codec.kind) {
@@ -60,10 +61,14 @@ function encode(value, codec, headers) {
60
61
  case "arrayBuffer":
61
62
  case "stream": return value;
62
63
  case "content": {
63
- const [type, selected] = Object.entries(codec.variants ?? {})[0] ?? [];
64
- if (!selected) throw new TypeError("content() has no variants");
64
+ const variants = Object.entries(codec.variants ?? {});
65
+ if (variants.length === 0) throw new TypeError("content() has no variants");
66
+ const type = requestedMediaType?.split(";", 1)[0]?.trim().toLowerCase();
67
+ const [selectedType, selected] = type ? [type, codec.variants?.[type]] : variants.length === 1 ? variants[0] : [];
68
+ if (type && !selected) throw new TypeError(`No content() request variant for media type ${type}`);
69
+ if (!selected) throw new TypeError("Multi-variant content() request bodies require bodyMediaType");
65
70
  const body = encode(value, selected, headers);
66
- if (selected.kind !== "multipart") headers.set("content-type", type);
71
+ if (selected.kind !== "multipart") headers.set("content-type", selectedType);
67
72
  return body;
68
73
  }
69
74
  }
@@ -137,7 +142,7 @@ const parameterValue = (map, name, value) => {
137
142
  const validator = definition && typeof definition === "object" && "safeParse" in definition ? definition : definition?.validator;
138
143
  if (!validator) return value;
139
144
  const parsed = validator.safeParse(value);
140
- if (!parsed.success) throw parsed.error;
145
+ if (!parsed.success) throw validationFailure(parsed);
141
146
  return parsed.data;
142
147
  };
143
148
  /**
@@ -163,7 +168,7 @@ function createFetch(options = {}) {
163
168
  }
164
169
  let body;
165
170
  try {
166
- if (call.bodyCodec) body = encode(call.body, call.bodyCodec, headers);
171
+ if (call.bodyCodec) body = encode(call.body, call.bodyCodec, headers, call.bodyMediaType);
167
172
  } catch (error) {
168
173
  return failure("request", error, url);
169
174
  }
@@ -289,6 +294,7 @@ function createClient(api, options = {}) {
289
294
  querySpec: endpoint.query,
290
295
  body: input.body,
291
296
  bodyCodec: endpoint.body,
297
+ bodyMediaType: input.bodyMediaType,
292
298
  responses: endpoint.responses,
293
299
  errors: endpoint.errors,
294
300
  signal: input.signal,
package/dist/codecs.d.ts CHANGED
@@ -1,27 +1,26 @@
1
- import { Codec, Validator } from "./types.js";
1
+ import { Codec, InferValidator, Validator } from "./types.js";
2
2
  //#region src/codecs.d.ts
3
3
  /** Creates a JSON codec, matching `application/json` and `+json` suffixed media types. */
4
- declare function json<T = unknown>(): Codec<T>;
4
+ export declare function json<T = unknown>(): Codec<T>;
5
5
  /** Creates a JSON codec that validates/parses the decoded value with the given schema. */
6
- declare function json<V extends Validator>(schema: V): Codec<V extends Validator<infer T> ? T : never>;
6
+ export declare function json<V extends Validator>(schema: V): Codec<InferValidator<V>>;
7
7
  /** Creates a codec for plain text bodies (`text/*`), decoded as a string. */
8
- declare const text: () => Codec<string>;
8
+ export declare const text: () => Codec<string>;
9
9
  /** Creates a codec for `application/x-www-form-urlencoded` bodies, decoded as `URLSearchParams`. */
10
- declare const urlEncoded: () => Codec<URLSearchParams>;
10
+ export declare const urlEncoded: () => Codec<URLSearchParams>;
11
11
  /** Creates a codec for `multipart/form-data` bodies, decoded as `FormData`. */
12
- declare const multipart: () => Codec<FormData>;
12
+ export declare const multipart: () => Codec<FormData>;
13
13
  /** Creates a codec that decodes any response body as a `Blob`. */
14
- declare const blob: () => Codec<Blob>;
14
+ export declare const blob: () => Codec<Blob>;
15
15
  /** Creates a codec that decodes any response body as an `ArrayBuffer`. */
16
- declare const arrayBuffer: () => Codec<ArrayBuffer>;
16
+ export declare const arrayBuffer: () => Codec<ArrayBuffer>;
17
17
  /** Creates a codec that exposes the raw response body as a `ReadableStream`, without buffering it. */
18
- declare const stream: () => Codec<ReadableStream<Uint8Array>>;
18
+ export declare const stream: () => Codec<ReadableStream<Uint8Array>>;
19
19
  /** Creates a codec for bodies expected to be empty (e.g. 204 No Content), decoded as `undefined`. */
20
- declare const empty: () => Codec<undefined>;
20
+ export declare const empty: () => Codec<undefined>;
21
21
  /**
22
22
  * Creates a content-negotiated codec that selects among `variants` by the response's
23
23
  * media type, decoding with whichever variant matches.
24
24
  */
25
- declare const content: <T extends Record<string, Codec>>(variants: T) => Codec<{ [K in keyof T]: T[K] extends Codec<infer V> ? V : never; }[keyof T]>;
26
- //#endregion
27
- export { arrayBuffer, blob, content, empty, json, multipart, stream, text, urlEncoded };
25
+ export declare const content: <T extends Record<string, Codec>>(variants: T) => Codec<{ [K in keyof T]: T[K] extends Codec<infer V> ? V : never; }[keyof T]>;
26
+ //#endregion
package/dist/dsl.d.ts CHANGED
@@ -6,7 +6,7 @@ import { AnyEndpointDescriptor, ApiDefinition, ApiMetadata, Codec, EndpointDescr
6
6
  * @throws {TypeError} If the path uses colon/wildcard syntax, has malformed
7
7
  * `{...}` placeholders, or contains a duplicate parameter name.
8
8
  */
9
- declare function pathNames(path: string): string[];
9
+ export declare function pathNames(path: string): string[];
10
10
  type Responses = Record<number, Codec>;
11
11
  type Errors = Partial<Record<number | "default", Codec>>;
12
12
  type State = Omit<EndpointDescriptor, "responses" | "errors"> & {
@@ -20,8 +20,12 @@ declare const STATE: unique symbol;
20
20
  * returns a new builder reflecting the added configuration; the accumulated
21
21
  * state is finalized into an {@link EndpointDescriptor} by {@link defineApi}.
22
22
  */
23
- interface EndpointBuilder<P = undefined, Q = undefined, H = undefined, B = undefined, R extends Responses = {}, E extends Errors = {}> {
23
+ export 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>;
@@ -32,19 +36,19 @@ interface EndpointBuilder<P = undefined, Q = undefined, H = undefined, B = undef
32
36
  security(requirements: readonly Readonly<Record<string, readonly string[]>>[]): EndpointBuilder<P, Q, H, B, R, E>;
33
37
  }
34
38
  /** Starts building a GET endpoint at the given path. */
35
- declare const get: (path: string) => EndpointBuilder<undefined, undefined, undefined, undefined, {}, {}>;
39
+ export declare const get: (path: string) => EndpointBuilder<undefined, undefined, undefined, undefined, {}, {}>;
36
40
  /** Starts building a POST endpoint at the given path. */
37
- declare const post: (path: string) => EndpointBuilder<undefined, undefined, undefined, undefined, {}, {}>;
41
+ export declare const post: (path: string) => EndpointBuilder<undefined, undefined, undefined, undefined, {}, {}>;
38
42
  /** Starts building a PUT endpoint at the given path. */
39
- declare const put: (path: string) => EndpointBuilder<undefined, undefined, undefined, undefined, {}, {}>;
43
+ export declare const put: (path: string) => EndpointBuilder<undefined, undefined, undefined, undefined, {}, {}>;
40
44
  /** Starts building a PATCH endpoint at the given path. */
41
- declare const patch: (path: string) => EndpointBuilder<undefined, undefined, undefined, undefined, {}, {}>;
45
+ export declare const patch: (path: string) => EndpointBuilder<undefined, undefined, undefined, undefined, {}, {}>;
42
46
  /** Starts building a DELETE endpoint at the given path. */
43
- declare const del: (path: string) => EndpointBuilder<undefined, undefined, undefined, undefined, {}, {}>;
47
+ export declare const del: (path: string) => EndpointBuilder<undefined, undefined, undefined, undefined, {}, {}>;
44
48
  /** Starts building a HEAD endpoint at the given path. */
45
- declare const head: (path: string) => EndpointBuilder<undefined, undefined, undefined, undefined, {}, {}>;
49
+ export declare const head: (path: string) => EndpointBuilder<undefined, undefined, undefined, undefined, {}, {}>;
46
50
  /** Starts building an OPTIONS endpoint at the given path. */
47
- declare const options: (path: string) => EndpointBuilder<undefined, undefined, undefined, undefined, {}, {}>;
51
+ export declare const options: (path: string) => EndpointBuilder<undefined, undefined, undefined, undefined, {}, {}>;
48
52
  type DescriptorOf<T> = T extends EndpointBuilder<infer P, infer Q, infer H, infer B, infer R, infer E> ? EndpointDescriptor<P, Q, H, B, R, E> : T extends AnyEndpointDescriptor ? T : never;
49
53
  type Defined<E extends Record<string, AnyEndpointDescriptor | EndpointBuilder>> = { [K in keyof E]: DescriptorOf<E[K]>; };
50
54
  /**
@@ -57,6 +61,5 @@ type Defined<E extends Record<string, AnyEndpointDescriptor | EndpointBuilder>>
57
61
  * getUser: get("/users/{id}").returns(json()),
58
62
  * });
59
63
  */
60
- declare function defineApi<E extends Record<string, AnyEndpointDescriptor | EndpointBuilder>>(endpoints: E, metadata?: ApiMetadata): ApiDefinition<Defined<E>>;
61
- //#endregion
62
- export { EndpointBuilder, defineApi, del, get, head, options, patch, pathNames, post, put };
64
+ export declare function defineApi<E extends Record<string, AnyEndpointDescriptor | EndpointBuilder>>(endpoints: E, metadata?: ApiMetadata): ApiDefinition<Defined<E>>;
65
+ //#endregion
@@ -4,7 +4,7 @@ import { FetchResult, Middleware, RequestContext } from "./types.js";
4
4
  * Middleware that attaches an `Authorization: Bearer <token>` header to every request.
5
5
  * `token` may be a static string or a (possibly async) function resolved on each request.
6
6
  */
7
- declare const bearerAuth: ({ token }: {
7
+ export declare const bearerAuth: ({ token }: {
8
8
  token: string | (() => string | Promise<string>);
9
9
  }) => Middleware;
10
10
  /**
@@ -13,13 +13,13 @@ declare const bearerAuth: ({ token }: {
13
13
  * async) function resolved on each request. The key is marked sensitive so the
14
14
  * {@link logging} middleware redacts it.
15
15
  */
16
- declare function apiKeyAuth({ key, value, in: location }: {
16
+ export declare function apiKeyAuth({ key, value, in: location }: {
17
17
  key: string;
18
18
  value: string | (() => string | Promise<string>);
19
19
  in?: "header" | "query";
20
20
  }): Middleware;
21
21
  /** Options controlling the {@link retry} middleware's behavior. */
22
- interface RetryOptions {
22
+ export interface RetryOptions {
23
23
  /** Maximum number of attempts, including the first. Defaults to 3. */
24
24
  attempts?: number;
25
25
  /** HTTP methods eligible for retry. Defaults to idempotent-by-convention methods. */
@@ -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
@@ -36,18 +38,18 @@ interface RetryOptions {
36
38
  * is replayed only when it can be cloned before the first attempt; streaming, already-consumed,
37
39
  * or otherwise non-cloneable bodies are sent once without retrying.
38
40
  */
39
- declare function retry(options?: RetryOptions): Middleware;
41
+ export declare function retry(options?: RetryOptions): Middleware;
40
42
  /**
41
43
  * Middleware that logs a `"request"` event before and a `"response"` event after each
42
44
  * request, via the given logger (defaults to `console`). Headers and query parameters
43
45
  * marked sensitive (e.g. by {@link apiKeyAuth}) or matching common sensitive-name
44
46
  * patterns (authorization, cookie, token, secret, password, api key) are redacted.
45
47
  */
46
- declare function logging(logger?: {
48
+ export declare function logging(logger?: {
47
49
  log(event: Record<string, unknown>): void;
48
50
  }): Middleware;
49
51
  /** Hooks invoked by the {@link telemetry} middleware around each request. */
50
- interface TelemetryHooks {
52
+ export interface TelemetryHooks {
51
53
  /** Called before the request proceeds; its return value (a "span") is passed to `end`/`error`. */
52
54
  start?(context: RequestContext): unknown;
53
55
  /** Called after the request completes successfully (from this middleware's perspective). */
@@ -59,6 +61,5 @@ interface TelemetryHooks {
59
61
  * Middleware that wraps each request with {@link TelemetryHooks}, calling `start` before
60
62
  * the request, `end` after it completes, and `error` (then rethrowing) if it throws.
61
63
  */
62
- declare function telemetry(hooks: TelemetryHooks): Middleware;
63
- //#endregion
64
- export { RetryOptions, TelemetryHooks, apiKeyAuth, bearerAuth, logging, retry, telemetry };
64
+ export declare function telemetry(hooks: TelemetryHooks): Middleware;
65
+ //#endregion
@@ -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/dist/types.d.ts CHANGED
@@ -1,25 +1,36 @@
1
1
  //#region src/types.d.ts
2
2
  /**
3
- * A minimal schema-validation contract, compatible with libraries such as Zod
4
- * that expose a `safeParse` method (e.g. via a thin adapter).
3
+ * A minimal schema-validation contract, compatible with `safeParse` methods
4
+ * that report failures through either `error` or `issues`.
5
5
  */
6
- interface Validator<T = unknown> {
6
+ export interface Validator<T = unknown> {
7
7
  safeParse(value: unknown): {
8
- success: true;
9
- data: T;
8
+ readonly success: true;
9
+ readonly data: T;
10
10
  } | {
11
- success: false;
12
- error: unknown;
11
+ readonly success: false;
12
+ readonly error: unknown;
13
+ readonly issues?: unknown;
14
+ } | {
15
+ readonly success: false;
16
+ readonly error?: unknown;
17
+ readonly issues: unknown;
13
18
  };
14
19
  }
15
20
  /** Infers the parsed output type `T` from a {@link Validator}. */
16
- type InferValidator<V> = V extends Validator<infer T> ? T : never;
21
+ export type InferValidator<V> = V extends {
22
+ safeParse(value: unknown): infer Result;
23
+ } ? Extract<Result, {
24
+ readonly success: true;
25
+ }> extends {
26
+ readonly data: infer T;
27
+ } ? T : never : never;
17
28
  /**
18
29
  * Describes how a request or response body is serialized/deserialized:
19
30
  * which wire format (`kind`), which media types it matches, and optionally
20
31
  * a {@link Validator} to parse/validate the decoded value.
21
32
  */
22
- interface Codec<T = unknown> {
33
+ export interface Codec<T = unknown> {
23
34
  readonly kind: "json" | "text" | "urlEncoded" | "multipart" | "blob" | "arrayBuffer" | "stream" | "empty" | "content";
24
35
  readonly mediaTypes: readonly string[];
25
36
  readonly validator?: Validator<T>;
@@ -27,13 +38,13 @@ interface Codec<T = unknown> {
27
38
  readonly variants?: Readonly<Record<string, Codec<unknown>>>;
28
39
  }
29
40
  /** Infers the decoded value type `T` from a {@link Codec}. */
30
- type InferCodec<C> = C extends Codec<infer T> ? T : never;
41
+ export type InferCodec<C> = C extends Codec<infer T> ? T : never;
31
42
  /** The set of HTTP methods supported by endpoint descriptors. */
32
- type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
43
+ export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
33
44
  /** OpenAPI-style serialization styles for path, query, and header parameters. */
34
- type ParameterStyle = "simple" | "label" | "matrix" | "form" | "spaceDelimited" | "pipeDelimited" | "deepObject";
45
+ export type ParameterStyle = "simple" | "label" | "matrix" | "form" | "spaceDelimited" | "pipeDelimited" | "deepObject";
35
46
  /** Serialization and validation settings for a single path, query, or header parameter. */
36
- interface ParameterSpec<T = unknown> {
47
+ export interface ParameterSpec<T = unknown> {
37
48
  readonly validator?: Validator<T>;
38
49
  readonly style?: ParameterStyle;
39
50
  readonly explode?: boolean;
@@ -42,9 +53,9 @@ interface ParameterSpec<T = unknown> {
42
53
  * Maps parameter names to either a bare {@link Validator} or a full
43
54
  * {@link ParameterSpec} describing serialization and validation.
44
55
  */
45
- type ParameterMap = Readonly<Record<string, Validator<unknown> | ParameterSpec | undefined>>;
56
+ export type ParameterMap = Readonly<Record<string, Validator<unknown> | ParameterSpec | undefined>>;
46
57
  /** Describes a single API endpoint: its method, path, parameters, body, and possible responses. */
47
- interface EndpointDescriptor<P = undefined, Q = undefined, H = undefined, B = undefined, R extends Record<number, Codec> = Record<number, Codec>, E extends Partial<Record<number | "default", Codec>> = Partial<Record<number | "default", Codec>>> {
58
+ export interface EndpointDescriptor<P = undefined, Q = undefined, H = undefined, B = undefined, R extends Record<number, Codec> = Record<number, Codec>, E extends Partial<Record<number | "default", Codec>> = Partial<Record<number | "default", Codec>>> {
48
59
  readonly method: HttpMethod;
49
60
  readonly path: string;
50
61
  readonly params?: ParameterMap;
@@ -63,9 +74,9 @@ interface EndpointDescriptor<P = undefined, Q = undefined, H = undefined, B = un
63
74
  };
64
75
  }
65
76
  /** An {@link EndpointDescriptor} with its type parameters erased, for use in generic contexts. */
66
- type AnyEndpointDescriptor = EndpointDescriptor<any, any, any, any, any, any>;
77
+ export type AnyEndpointDescriptor = EndpointDescriptor<any, any, any, any, any, any>;
67
78
  /** API-level (rather than per-endpoint) metadata, such as servers and security schemes. */
68
- interface ApiMetadata {
79
+ export interface ApiMetadata {
69
80
  readonly servers?: readonly (string | {
70
81
  readonly url: string;
71
82
  })[];
@@ -73,12 +84,12 @@ interface ApiMetadata {
73
84
  readonly securitySchemes?: Readonly<Record<string, unknown>>;
74
85
  }
75
86
  /** A named collection of endpoints plus optional API metadata, as produced by {@link defineApi}. */
76
- interface ApiDefinition<E extends Record<string, AnyEndpointDescriptor> = Record<string, AnyEndpointDescriptor>> {
87
+ export interface ApiDefinition<E extends Record<string, AnyEndpointDescriptor> = Record<string, AnyEndpointDescriptor>> {
77
88
  readonly endpoints: E;
78
89
  readonly metadata?: ApiMetadata;
79
90
  }
80
91
  /** A successful (2xx) fetch outcome, carrying the decoded response data. */
81
- type SuccessResult<T = unknown, S extends number = number> = {
92
+ export type SuccessResult<T = unknown, S extends number = number> = {
82
93
  ok: true;
83
94
  kind: "success";
84
95
  status: S;
@@ -89,7 +100,7 @@ type SuccessResult<T = unknown, S extends number = number> = {
89
100
  response: Response;
90
101
  };
91
102
  /** A non-2xx fetch outcome where the server responded with a decodable error body. */
92
- type HttpResult<T = unknown, S extends number = number> = {
103
+ export type HttpResult<T = unknown, S extends number = number> = {
93
104
  ok: false;
94
105
  kind: "http";
95
106
  status: S;
@@ -100,9 +111,9 @@ type HttpResult<T = unknown, S extends number = number> = {
100
111
  response: Response;
101
112
  };
102
113
  /** Categorizes why a fetch could not produce an {@link HttpResult} or {@link SuccessResult}. */
103
- type FailureKind = "request" | "network" | "timeout" | "abort" | "decode" | "middleware";
114
+ export type FailureKind = "request" | "network" | "timeout" | "abort" | "decode" | "middleware";
104
115
  /** A fetch outcome that failed before or independently of receiving a decodable HTTP response. */
105
- type FailureResult = {
116
+ export type FailureResult = {
106
117
  ok: false;
107
118
  kind: FailureKind;
108
119
  status: number;
@@ -112,9 +123,9 @@ type FailureResult = {
112
123
  response?: Response;
113
124
  };
114
125
  /** The outcome of a fetch call: success, an HTTP-level error, or another kind of failure. */
115
- type FetchResult<T = unknown> = SuccessResult<T> | HttpResult<T> | FailureResult;
126
+ export type FetchResult<T = unknown> = SuccessResult<T> | HttpResult<T> | FailureResult;
116
127
  /** The mutable-by-replacement state threaded through the middleware chain for a single request. */
117
- interface RequestContext {
128
+ export interface RequestContext {
118
129
  readonly request: Request;
119
130
  readonly endpoint?: AnyEndpointDescriptor;
120
131
  readonly operationId?: string;
@@ -127,11 +138,11 @@ interface RequestContext {
127
138
  readonly deadline?: number;
128
139
  }
129
140
  /** Invokes the next middleware in the chain, optionally passing a replacement context. */
130
- type Next = (context?: RequestContext) => Promise<FetchResult>;
141
+ export type Next = (context?: RequestContext) => Promise<FetchResult>;
131
142
  /** A function that can inspect/replace the request context and/or the result of calling `next`. */
132
- type Middleware = (context: RequestContext, next: Next) => Promise<FetchResult>;
143
+ export type Middleware = (context: RequestContext, next: Next) => Promise<FetchResult>;
133
144
  /** Options controlling how a {@link createClient} or {@link createFetch} client makes requests. */
134
- interface ClientOptions {
145
+ export interface ClientOptions {
135
146
  baseUrl?: string;
136
147
  /** Custom transport, defaulting to the global `fetch`. */
137
148
  fetch?: (request: Request) => Promise<Response>;
@@ -142,5 +153,4 @@ interface ClientOptions {
142
153
  /** Middleware chain applied to every request, in order. */
143
154
  middleware?: readonly Middleware[];
144
155
  }
145
- //#endregion
146
- export { AnyEndpointDescriptor, ApiDefinition, ApiMetadata, ClientOptions, Codec, EndpointDescriptor, FailureKind, FailureResult, FetchResult, HttpMethod, HttpResult, InferCodec, InferValidator, Middleware, Next, ParameterMap, ParameterSpec, ParameterStyle, RequestContext, SuccessResult, Validator };
156
+ //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askrjs/fetch",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Function-first typed HTTP contracts and clients for Askr.",
5
5
  "keywords": [
6
6
  "askr",
@@ -61,11 +61,12 @@
61
61
  "prepublishOnly": "npm run check"
62
62
  },
63
63
  "devDependencies": {
64
- "@types/node": "^26.2.0",
65
- "publint": "^0.3.23",
64
+ "@askrjs/schema": "^0.3.0",
65
+ "@types/node": "^26.3.0",
66
+ "publint": "^0.3.24",
66
67
  "typescript": "^7.0.2",
67
- "vite-plus": "^0.2.8",
68
- "vitest": "^4.1.10"
68
+ "vite-plus": "^0.3.1",
69
+ "vitest": "^4.1.11"
69
70
  },
70
71
  "engines": {
71
72
  "node": ">=24.0.0"