@askrjs/fetch 0.0.4 → 0.0.6

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
@@ -108,6 +108,10 @@ get("/search")
108
108
  .returns(json());
109
109
  ```
110
110
 
111
+ Query serialization treats `null` as the literal `null` and `NaN` as the literal `NaN`, whether
112
+ the value is scalar or array-wrapped. `undefined` is omitted in both positions. This avoids
113
+ silently turning scalar `null` into an empty string or array-wrapped `undefined` into text.
114
+
111
115
  ## Middleware
112
116
 
113
117
  Import middleware from the dedicated subpath:
@@ -119,21 +123,32 @@ import { bearerAuth, logging, retry, telemetry } from "@askrjs/fetch/middleware"
119
123
  const client = createClient(api, {
120
124
  baseUrl: "https://api.example.com",
121
125
  middleware: [
126
+ retry({ attempts: 3 }),
122
127
  bearerAuth({ token: () => session.accessToken }),
123
128
  logging(),
124
129
  telemetry(hooks),
125
- retry({ attempts: 3 }),
126
130
  ],
127
131
  });
128
132
  ```
129
133
 
130
- Middleware runs outward in declaration order. The package includes bearer and API-key auth,
131
- idempotent-method retries, redacted structured logging, and telemetry hooks. Logging redacts common
132
- credential names as well as arbitrary header or query names added by `apiKeyAuth()`.
134
+ Middleware is a linear onion and runs outward in declaration order. Middleware after `retry()`
135
+ runs once per attempt; middleware before it runs once around the complete retry sequence. Put
136
+ token-refreshing authentication, per-attempt logging, and per-attempt telemetry after `retry()` as
137
+ shown above. Put aggregate timing or logging before `retry()` when one observation for the complete
138
+ sequence is intentional. Logging redacts common credential names as well as arbitrary header or
139
+ query names added by `apiKeyAuth()`.
133
140
 
134
141
  Retries default to `GET`, `HEAD`, `PUT`, `DELETE`, and `OPTIONS`, and to statuses `408`, `425`,
135
- `429`, `500`, `502`, `503`, and `504`. `Retry-After` is honored when present. Requests with bodies
136
- are not retried automatically.
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.
146
+
147
+ Do not include a status such as `401` in `retry()` when an upstream authentication middleware
148
+ already handles that status. The outer authentication layer cannot react until retry's complete
149
+ inner attempt loop returns, so including `401` would spend the retry budget on the same upstream
150
+ credentials. Prefer the order above so credentials resolve per attempt, or let the authentication
151
+ middleware own `401` without adding it to retry's statuses.
137
152
 
138
153
  ## Cancellation and timeouts
139
154
 
package/dist/client.d.ts CHANGED
@@ -1,5 +1,6 @@
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
+ /** A one-off, untyped request description accepted by {@link createFetch}'s returned function. */
3
4
  interface AdHocCall {
4
5
  url: string;
5
6
  method?: string;
@@ -16,6 +17,12 @@ interface AdHocCall {
16
17
  endpoint?: EndpointDescriptor;
17
18
  operationId?: string;
18
19
  }
20
+ /**
21
+ * Creates a low-level fetch function that builds a `Request` from an {@link AdHocCall},
22
+ * runs it through the configured middleware chain, and decodes the response with the
23
+ * matching codec. Used internally by {@link createClient}, and usable directly for
24
+ * requests without a full {@link EndpointDescriptor}.
25
+ */
19
26
  declare function createFetch(options?: ClientOptions): (call: AdHocCall) => Promise<FetchResult>;
20
27
  type InputParts<D> = D extends EndpointDescriptor<infer P, infer Q, infer H, infer B, any, any> ? {
21
28
  params: P;
@@ -31,10 +38,18 @@ type EndpointInput<D extends AnyEndpointDescriptor> = Container<"params", InputP
31
38
  };
32
39
  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;
33
40
  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
+ /** The possible results of calling a typed client method for endpoint descriptor `D`. */
34
42
  type ClientResult<D extends AnyEndpointDescriptor> = Successes<D> | Errors<D> | FailureResult;
35
43
  type ClientMethod<D extends AnyEndpointDescriptor> = RequiredKeys<EndpointInput<D>> extends never ? (input?: EndpointInput<D>) => Promise<ClientResult<D>> : (input: EndpointInput<D>) => Promise<ClientResult<D>>;
44
+ /** A fully-typed client for an {@link ApiDefinition}, with one method per endpoint. */
36
45
  type ApiClient<A extends ApiDefinition> = Readonly<{ [K in keyof A["endpoints"]]: ClientMethod<A["endpoints"][K]>; }>;
46
+ /**
47
+ * Builds a typed {@link ApiClient} from an {@link ApiDefinition}. Each endpoint becomes
48
+ * a method that fills in the path, query, header, and body parameters, executes the
49
+ * request via {@link createFetch}, and returns a {@link ClientResult}.
50
+ */
37
51
  declare function createClient<A extends ApiDefinition>(api: A, options?: ClientOptions): ApiClient<A>;
52
+ /** An `Error` thrown by {@link unwrap} that wraps a failed (non-`ok`) {@link FetchResult}. */
38
53
  declare class FetchError extends Error {
39
54
  readonly result: Exclude<FetchResult, {
40
55
  ok: true;
@@ -43,6 +58,10 @@ declare class FetchError extends Error {
43
58
  ok: true;
44
59
  }>);
45
60
  }
61
+ /**
62
+ * Returns the data of a successful {@link FetchResult}, or throws a {@link FetchError}
63
+ * wrapping the result if it was not `ok`.
64
+ */
46
65
  declare function unwrap<T>(result: FetchResult<T>): T;
47
66
  //#endregion
48
67
  export { AdHocCall, ApiClient, ClientResult, FetchError, createClient, createFetch, unwrap };
package/dist/client.js CHANGED
@@ -98,8 +98,10 @@ function pathParameter(name, value, spec = {}) {
98
98
  function queryParameter(output, name, value, spec = {}) {
99
99
  const style = spec.style ?? "form";
100
100
  const explode = spec.explode ?? true;
101
- const object = entries(value);
101
+ const object = entries(value).filter(([, item]) => item !== void 0);
102
102
  const array = Array.isArray(value);
103
+ const emptyArray = array && value.length === 0;
104
+ const items = values(value).filter((item) => item !== void 0).map(String);
103
105
  const append = (key, item) => output.append(key, String(item));
104
106
  if (style === "deepObject") {
105
107
  if (!object.length) throw new TypeError(`deepObject query parameter ${name} must be an object`);
@@ -108,17 +110,17 @@ function queryParameter(output, name, value, spec = {}) {
108
110
  }
109
111
  if (style === "spaceDelimited" || style === "pipeDelimited") {
110
112
  if (!array) throw new TypeError(`${style} query parameter ${name} must be an array`);
111
- append(name, values(value).join(style === "spaceDelimited" ? " " : "|"));
113
+ if (items.length || emptyArray) append(name, items.join(style === "spaceDelimited" ? " " : "|"));
112
114
  return;
113
115
  }
114
116
  if (style !== "form") throw new TypeError(`Unsupported query parameter style: ${style}`);
115
117
  if (object.length) {
116
118
  if (explode) for (const [key, item] of object) append(key, item);
117
- else append(name, object.flatMap(([key, item]) => [key, item]).join(","));
119
+ else append(name, object.flatMap(([key, item]) => [key, String(item)]).join(","));
118
120
  return;
119
121
  }
120
- if (array && explode) for (const item of values(value)) append(name, item);
121
- else append(name, values(value).join(","));
122
+ if (array && explode) for (const item of items) append(name, item);
123
+ else if (items.length || emptyArray) append(name, items.join(","));
122
124
  }
123
125
  function headerParameter(value, spec = {}) {
124
126
  if ((spec.style ?? "simple") !== "simple") throw new TypeError(`Unsupported header parameter style: ${spec.style}`);
@@ -138,6 +140,12 @@ const parameterValue = (map, name, value) => {
138
140
  if (!parsed.success) throw parsed.error;
139
141
  return parsed.data;
140
142
  };
143
+ /**
144
+ * Creates a low-level fetch function that builds a `Request` from an {@link AdHocCall},
145
+ * runs it through the configured middleware chain, and decodes the response with the
146
+ * matching codec. Used internally by {@link createClient}, and usable directly for
147
+ * requests without a full {@link EndpointDescriptor}.
148
+ */
141
149
  function createFetch(options = {}) {
142
150
  return async (call) => {
143
151
  let url = `${options.baseUrl?.replace(/\/$/, "") ?? ""}${call.url}`;
@@ -236,6 +244,7 @@ function createFetch(options = {}) {
236
244
  operationId: call.operationId,
237
245
  security: call.endpoint?.security,
238
246
  attempt: 1,
247
+ ...body === void 0 ? {} : { replayableBody: !(body instanceof ReadableStream) },
239
248
  ...timeout === void 0 ? {} : { deadline: Date.now() + timeout }
240
249
  }));
241
250
  } finally {
@@ -243,6 +252,11 @@ function createFetch(options = {}) {
243
252
  }
244
253
  };
245
254
  }
255
+ /**
256
+ * Builds a typed {@link ApiClient} from an {@link ApiDefinition}. Each endpoint becomes
257
+ * a method that fills in the path, query, header, and body parameters, executes the
258
+ * request via {@link createFetch}, and returns a {@link ClientResult}.
259
+ */
246
260
  function createClient(api, options = {}) {
247
261
  const execute = createFetch(options);
248
262
  const methods = Object.fromEntries(Object.entries(api.endpoints).map(([id, endpoint]) => [id, async (input = {}) => {
@@ -283,6 +297,7 @@ function createClient(api, options = {}) {
283
297
  }]));
284
298
  return Object.freeze(methods);
285
299
  }
300
+ /** An `Error` thrown by {@link unwrap} that wraps a failed (non-`ok`) {@link FetchResult}. */
286
301
  var FetchError = class extends Error {
287
302
  result;
288
303
  constructor(result) {
@@ -291,6 +306,10 @@ var FetchError = class extends Error {
291
306
  this.name = "FetchError";
292
307
  }
293
308
  };
309
+ /**
310
+ * Returns the data of a successful {@link FetchResult}, or throws a {@link FetchError}
311
+ * wrapping the result if it was not `ok`.
312
+ */
294
313
  function unwrap(result) {
295
314
  if (!result.ok) throw new FetchError(result);
296
315
  return result.data;
package/dist/codecs.d.ts CHANGED
@@ -1,14 +1,27 @@
1
1
  import { Codec, Validator } from "./types.js";
2
2
  //#region src/codecs.d.ts
3
+ /** Creates a JSON codec, matching `application/json` and `+json` suffixed media types. */
3
4
  declare function json<T = unknown>(): Codec<T>;
5
+ /** Creates a JSON codec that validates/parses the decoded value with the given schema. */
4
6
  declare function json<V extends Validator>(schema: V): Codec<V extends Validator<infer T> ? T : never>;
7
+ /** Creates a codec for plain text bodies (`text/*`), decoded as a string. */
5
8
  declare const text: () => Codec<string>;
9
+ /** Creates a codec for `application/x-www-form-urlencoded` bodies, decoded as `URLSearchParams`. */
6
10
  declare const urlEncoded: () => Codec<URLSearchParams>;
11
+ /** Creates a codec for `multipart/form-data` bodies, decoded as `FormData`. */
7
12
  declare const multipart: () => Codec<FormData>;
13
+ /** Creates a codec that decodes any response body as a `Blob`. */
8
14
  declare const blob: () => Codec<Blob>;
15
+ /** Creates a codec that decodes any response body as an `ArrayBuffer`. */
9
16
  declare const arrayBuffer: () => Codec<ArrayBuffer>;
17
+ /** Creates a codec that exposes the raw response body as a `ReadableStream`, without buffering it. */
10
18
  declare const stream: () => Codec<ReadableStream<Uint8Array>>;
19
+ /** Creates a codec for bodies expected to be empty (e.g. 204 No Content), decoded as `undefined`. */
11
20
  declare const empty: () => Codec<undefined>;
21
+ /**
22
+ * Creates a content-negotiated codec that selects among `variants` by the response's
23
+ * media type, decoding with whichever variant matches.
24
+ */
12
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]>;
13
26
  //#endregion
14
27
  export { arrayBuffer, blob, content, empty, json, multipart, stream, text, urlEncoded };
package/dist/codecs.js CHANGED
@@ -7,13 +7,24 @@ const codec = (kind, mediaTypes, validator) => Object.freeze({
7
7
  function json(schema) {
8
8
  return codec("json", ["application/json", "+json"], schema);
9
9
  }
10
+ /** Creates a codec for plain text bodies (`text/*`), decoded as a string. */
10
11
  const text = () => codec("text", ["text/*"]);
12
+ /** Creates a codec for `application/x-www-form-urlencoded` bodies, decoded as `URLSearchParams`. */
11
13
  const urlEncoded = () => codec("urlEncoded", ["application/x-www-form-urlencoded"]);
14
+ /** Creates a codec for `multipart/form-data` bodies, decoded as `FormData`. */
12
15
  const multipart = () => codec("multipart", ["multipart/form-data"]);
16
+ /** Creates a codec that decodes any response body as a `Blob`. */
13
17
  const blob = () => codec("blob", ["*/*"]);
18
+ /** Creates a codec that decodes any response body as an `ArrayBuffer`. */
14
19
  const arrayBuffer = () => codec("arrayBuffer", ["*/*"]);
20
+ /** Creates a codec that exposes the raw response body as a `ReadableStream`, without buffering it. */
15
21
  const stream = () => codec("stream", ["*/*"]);
22
+ /** Creates a codec for bodies expected to be empty (e.g. 204 No Content), decoded as `undefined`. */
16
23
  const empty = () => codec("empty", []);
24
+ /**
25
+ * Creates a content-negotiated codec that selects among `variants` by the response's
26
+ * media type, decoding with whichever variant matches.
27
+ */
17
28
  const content = (variants) => Object.freeze({
18
29
  kind: "content",
19
30
  mediaTypes: Object.freeze(Object.keys(variants).sort()),
package/dist/dsl.d.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import { AnyEndpointDescriptor, ApiDefinition, ApiMetadata, Codec, EndpointDescriptor, ParameterMap } from "./types.js";
2
2
  //#region src/dsl.d.ts
3
+ /**
4
+ * Extracts the ordered list of `{param}` placeholder names from an endpoint path.
5
+ *
6
+ * @throws {TypeError} If the path uses colon/wildcard syntax, has malformed
7
+ * `{...}` placeholders, or contains a duplicate parameter name.
8
+ */
3
9
  declare function pathNames(path: string): string[];
4
10
  type Responses = Record<number, Codec>;
5
11
  type Errors = Partial<Record<number | "default", Codec>>;
@@ -8,6 +14,12 @@ type State = Omit<EndpointDescriptor, "responses" | "errors"> & {
8
14
  errors: Errors;
9
15
  };
10
16
  declare const STATE: unique symbol;
17
+ /**
18
+ * Fluent, immutable builder for describing a single endpoint's params, query,
19
+ * headers, body, responses, errors, and security requirements. Each method
20
+ * returns a new builder reflecting the added configuration; the accumulated
21
+ * state is finalized into an {@link EndpointDescriptor} by {@link defineApi}.
22
+ */
11
23
  interface EndpointBuilder<P = undefined, Q = undefined, H = undefined, B = undefined, R extends Responses = {}, E extends Errors = {}> {
12
24
  readonly [STATE]: State;
13
25
  params<T extends Record<string, unknown>>(spec?: ParameterMap): EndpointBuilder<T, Q, H, B, R, E>;
@@ -19,15 +31,32 @@ interface EndpointBuilder<P = undefined, Q = undefined, H = undefined, B = undef
19
31
  errors<T extends Errors>(spec: T): EndpointBuilder<P, Q, H, B, R, E & T>;
20
32
  security(requirements: readonly Readonly<Record<string, readonly string[]>>[]): EndpointBuilder<P, Q, H, B, R, E>;
21
33
  }
34
+ /** Starts building a GET endpoint at the given path. */
22
35
  declare const get: (path: string) => EndpointBuilder<undefined, undefined, undefined, undefined, {}, {}>;
36
+ /** Starts building a POST endpoint at the given path. */
23
37
  declare const post: (path: string) => EndpointBuilder<undefined, undefined, undefined, undefined, {}, {}>;
38
+ /** Starts building a PUT endpoint at the given path. */
24
39
  declare const put: (path: string) => EndpointBuilder<undefined, undefined, undefined, undefined, {}, {}>;
40
+ /** Starts building a PATCH endpoint at the given path. */
25
41
  declare const patch: (path: string) => EndpointBuilder<undefined, undefined, undefined, undefined, {}, {}>;
42
+ /** Starts building a DELETE endpoint at the given path. */
26
43
  declare const del: (path: string) => EndpointBuilder<undefined, undefined, undefined, undefined, {}, {}>;
44
+ /** Starts building a HEAD endpoint at the given path. */
27
45
  declare const head: (path: string) => EndpointBuilder<undefined, undefined, undefined, undefined, {}, {}>;
46
+ /** Starts building an OPTIONS endpoint at the given path. */
28
47
  declare const options: (path: string) => EndpointBuilder<undefined, undefined, undefined, undefined, {}, {}>;
29
48
  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;
30
49
  type Defined<E extends Record<string, AnyEndpointDescriptor | EndpointBuilder>> = { [K in keyof E]: DescriptorOf<E[K]>; };
50
+ /**
51
+ * Finalizes a map of {@link EndpointBuilder}s and/or raw {@link EndpointDescriptor}s into
52
+ * a frozen {@link ApiDefinition}, stamping each endpoint's `operationId` from its key and
53
+ * deep-freezing its parameters, security, responses, and errors.
54
+ *
55
+ * @example
56
+ * const api = defineApi({
57
+ * getUser: get("/users/{id}").returns(json()),
58
+ * });
59
+ */
31
60
  declare function defineApi<E extends Record<string, AnyEndpointDescriptor | EndpointBuilder>>(endpoints: E, metadata?: ApiMetadata): ApiDefinition<Defined<E>>;
32
61
  //#endregion
33
62
  export { EndpointBuilder, defineApi, del, get, head, options, patch, pathNames, post, put };
package/dist/dsl.js CHANGED
@@ -1,5 +1,11 @@
1
1
  //#region src/dsl.ts
2
2
  const PATH_NAME = /^[A-Za-z_$][\w$]*$/;
3
+ /**
4
+ * Extracts the ordered list of `{param}` placeholder names from an endpoint path.
5
+ *
6
+ * @throws {TypeError} If the path uses colon/wildcard syntax, has malformed
7
+ * `{...}` placeholders, or contains a duplicate parameter name.
8
+ */
3
9
  function pathNames(path) {
4
10
  if (path.includes(":") || path.includes("*")) throw new TypeError(`Invalid path syntax: ${path}`);
5
11
  const names = [];
@@ -69,12 +75,19 @@ const method = (value) => (path) => {
69
75
  errors: {}
70
76
  });
71
77
  };
78
+ /** Starts building a GET endpoint at the given path. */
72
79
  const get = method("GET");
80
+ /** Starts building a POST endpoint at the given path. */
73
81
  const post = method("POST");
82
+ /** Starts building a PUT endpoint at the given path. */
74
83
  const put = method("PUT");
84
+ /** Starts building a PATCH endpoint at the given path. */
75
85
  const patch = method("PATCH");
86
+ /** Starts building a DELETE endpoint at the given path. */
76
87
  const del = method("DELETE");
88
+ /** Starts building a HEAD endpoint at the given path. */
77
89
  const head = method("HEAD");
90
+ /** Starts building an OPTIONS endpoint at the given path. */
78
91
  const options = method("OPTIONS");
79
92
  const snapshotParameters = (parameters) => parameters ? Object.freeze(Object.fromEntries(Object.entries(parameters).map(([name, definition]) => [name, definition && typeof definition === "object" && !("safeParse" in definition) ? Object.freeze({ ...definition }) : definition]))) : void 0;
80
93
  const snapshotSecurity = (security) => security ? Object.freeze(security.map((requirement) => Object.freeze(Object.fromEntries(Object.entries(requirement).map(([name, scopes]) => [name, Object.freeze([...scopes])]))))) : void 0;
@@ -84,6 +97,16 @@ const snapshotMetadata = (metadata) => Object.freeze({
84
97
  ...metadata.tags ? { tags: Object.freeze([...metadata.tags]) } : {},
85
98
  ...metadata.securitySchemes ? { securitySchemes: Object.freeze({ ...metadata.securitySchemes }) } : {}
86
99
  });
100
+ /**
101
+ * Finalizes a map of {@link EndpointBuilder}s and/or raw {@link EndpointDescriptor}s into
102
+ * a frozen {@link ApiDefinition}, stamping each endpoint's `operationId` from its key and
103
+ * deep-freezing its parameters, security, responses, and errors.
104
+ *
105
+ * @example
106
+ * const api = defineApi({
107
+ * getUser: get("/users/{id}").returns(json()),
108
+ * });
109
+ */
87
110
  function defineApi(endpoints, metadata) {
88
111
  const frozen = Object.fromEntries(Object.entries(endpoints).map(([id, value]) => {
89
112
  const endpoint = STATE in value ? value[STATE] : value;
@@ -1,28 +1,64 @@
1
1
  import { FetchResult, Middleware, RequestContext } from "./types.js";
2
2
  //#region src/middleware.d.ts
3
+ /**
4
+ * Middleware that attaches an `Authorization: Bearer <token>` header to every request.
5
+ * `token` may be a static string or a (possibly async) function resolved on each request.
6
+ */
3
7
  declare const bearerAuth: ({ token }: {
4
8
  token: string | (() => string | Promise<string>);
5
9
  }) => Middleware;
10
+ /**
11
+ * Middleware that attaches an API key to every request, either as a header or a query
12
+ * parameter (`in`, default `"header"`). `value` may be a static string or a (possibly
13
+ * async) function resolved on each request. The key is marked sensitive so the
14
+ * {@link logging} middleware redacts it.
15
+ */
6
16
  declare function apiKeyAuth({ key, value, in: location }: {
7
17
  key: string;
8
18
  value: string | (() => string | Promise<string>);
9
19
  in?: "header" | "query";
10
20
  }): Middleware;
21
+ /** Options controlling the {@link retry} middleware's behavior. */
11
22
  interface RetryOptions {
23
+ /** Maximum number of attempts, including the first. Defaults to 3. */
12
24
  attempts?: number;
25
+ /** HTTP methods eligible for retry. Defaults to idempotent-by-convention methods. */
13
26
  methods?: readonly string[];
27
+ /** Response statuses that trigger a retry. Defaults to common transient failure codes. */
14
28
  statuses?: readonly number[];
29
+ /** Computes the delay (ms) before the given retry attempt, if no `Retry-After` header is present. */
15
30
  delay?: (attempt: number) => number;
16
31
  }
32
+ /**
33
+ * Middleware that retries failed requests. Retries eligible methods on network failures
34
+ * or eligible response statuses, honoring a `Retry-After` header when present and
35
+ * stopping early if the request's deadline would be exceeded. A non-streaming request body
36
+ * is replayed only when it can be cloned before the first attempt; streaming, already-consumed,
37
+ * or otherwise non-cloneable bodies are sent once without retrying.
38
+ */
17
39
  declare function retry(options?: RetryOptions): Middleware;
40
+ /**
41
+ * Middleware that logs a `"request"` event before and a `"response"` event after each
42
+ * request, via the given logger (defaults to `console`). Headers and query parameters
43
+ * marked sensitive (e.g. by {@link apiKeyAuth}) or matching common sensitive-name
44
+ * patterns (authorization, cookie, token, secret, password, api key) are redacted.
45
+ */
18
46
  declare function logging(logger?: {
19
47
  log(event: Record<string, unknown>): void;
20
48
  }): Middleware;
49
+ /** Hooks invoked by the {@link telemetry} middleware around each request. */
21
50
  interface TelemetryHooks {
51
+ /** Called before the request proceeds; its return value (a "span") is passed to `end`/`error`. */
22
52
  start?(context: RequestContext): unknown;
53
+ /** Called after the request completes successfully (from this middleware's perspective). */
23
54
  end?(span: unknown, result: FetchResult): void;
55
+ /** Called if a downstream middleware or the transport throws. */
24
56
  error?(span: unknown, error: unknown): void;
25
57
  }
58
+ /**
59
+ * Middleware that wraps each request with {@link TelemetryHooks}, calling `start` before
60
+ * the request, `end` after it completes, and `error` (then rethrowing) if it throws.
61
+ */
26
62
  declare function telemetry(hooks: TelemetryHooks): Middleware;
27
63
  //#endregion
28
64
  export { RetryOptions, TelemetryHooks, apiKeyAuth, bearerAuth, logging, retry, telemetry };
@@ -21,10 +21,20 @@ const replaceHeaders = (context, mutate) => {
21
21
  request: new Request(context.request, { headers })
22
22
  });
23
23
  };
24
+ /**
25
+ * Middleware that attaches an `Authorization: Bearer <token>` header to every request.
26
+ * `token` may be a static string or a (possibly async) function resolved on each request.
27
+ */
24
28
  const bearerAuth = ({ token }) => async (context, next) => {
25
29
  const resolved = typeof token === "function" ? await token() : token;
26
30
  return next(replaceHeaders(context, (headers) => headers.set("authorization", `Bearer ${resolved}`)));
27
31
  };
32
+ /**
33
+ * Middleware that attaches an API key to every request, either as a header or a query
34
+ * parameter (`in`, default `"header"`). `value` may be a static string or a (possibly
35
+ * async) function resolved on each request. The key is marked sensitive so the
36
+ * {@link logging} middleware redacts it.
37
+ */
28
38
  function apiKeyAuth({ key, value, in: location = "header" }) {
29
39
  return async (context, next) => {
30
40
  const resolved = typeof value === "function" ? await value() : value;
@@ -37,6 +47,13 @@ function apiKeyAuth({ key, value, in: location = "header" }) {
37
47
  }), "query", key));
38
48
  };
39
49
  }
50
+ /**
51
+ * Middleware that retries failed requests. Retries eligible methods on network failures
52
+ * or eligible response statuses, honoring a `Retry-After` header when present and
53
+ * stopping early if the request's deadline would be exceeded. A non-streaming request body
54
+ * is replayed only when it can be cloned before the first attempt; streaming, already-consumed,
55
+ * or otherwise non-cloneable bodies are sent once without retrying.
56
+ */
40
57
  function retry(options = {}) {
41
58
  const attempts = options.attempts ?? 3;
42
59
  const methods = options.methods ?? [
@@ -56,7 +73,14 @@ function retry(options = {}) {
56
73
  504
57
74
  ];
58
75
  return async (context, next) => {
59
- if (!methods.includes(context.request.method) || context.request.body) return next(context);
76
+ if (!methods.includes(context.request.method)) return next(context);
77
+ if (context.replayableBody === false) return next(context);
78
+ let replayable;
79
+ if (context.request.body) try {
80
+ replayable = context.request.clone();
81
+ } catch {
82
+ return next(context);
83
+ }
60
84
  let result = await next(context);
61
85
  for (let attempt = 2; attempt <= attempts && !result.ok && (result.kind === "network" || result.response !== void 0 && statuses.includes(result.status)); attempt++) {
62
86
  const retryAfter = "response" in result ? result.response?.headers.get("retry-after") : null;
@@ -81,10 +105,16 @@ function retry(options = {}) {
81
105
  headers: new Headers(),
82
106
  url: context.request.url
83
107
  };
108
+ let request;
109
+ try {
110
+ request = (replayable ?? context.request).clone();
111
+ } catch {
112
+ break;
113
+ }
84
114
  result = await next(Object.freeze({
85
115
  ...context,
86
116
  attempt,
87
- request: context.request.clone()
117
+ request
88
118
  }));
89
119
  }
90
120
  return result;
@@ -101,6 +131,12 @@ const redactedUrl = (value, names = []) => {
101
131
  }
102
132
  return url.toString();
103
133
  };
134
+ /**
135
+ * Middleware that logs a `"request"` event before and a `"response"` event after each
136
+ * request, via the given logger (defaults to `console`). Headers and query parameters
137
+ * marked sensitive (e.g. by {@link apiKeyAuth}) or matching common sensitive-name
138
+ * patterns (authorization, cookie, token, secret, password, api key) are redacted.
139
+ */
104
140
  function logging(logger = console) {
105
141
  return async (context, next) => {
106
142
  const sensitive = context[SENSITIVE];
@@ -123,6 +159,10 @@ function logging(logger = console) {
123
159
  return result;
124
160
  };
125
161
  }
162
+ /**
163
+ * Middleware that wraps each request with {@link TelemetryHooks}, calling `start` before
164
+ * the request, `end` after it completes, and `error` (then rethrowing) if it throws.
165
+ */
126
166
  function telemetry(hooks) {
127
167
  return async (context, next) => {
128
168
  const span = hooks.start?.(context);
package/dist/types.d.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  //#region src/types.d.ts
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).
5
+ */
2
6
  interface Validator<T = unknown> {
3
7
  safeParse(value: unknown): {
4
8
  success: true;
@@ -8,22 +12,38 @@ interface Validator<T = unknown> {
8
12
  error: unknown;
9
13
  };
10
14
  }
15
+ /** Infers the parsed output type `T` from a {@link Validator}. */
11
16
  type InferValidator<V> = V extends Validator<infer T> ? T : never;
17
+ /**
18
+ * Describes how a request or response body is serialized/deserialized:
19
+ * which wire format (`kind`), which media types it matches, and optionally
20
+ * a {@link Validator} to parse/validate the decoded value.
21
+ */
12
22
  interface Codec<T = unknown> {
13
23
  readonly kind: "json" | "text" | "urlEncoded" | "multipart" | "blob" | "arrayBuffer" | "stream" | "empty" | "content";
14
24
  readonly mediaTypes: readonly string[];
15
25
  readonly validator?: Validator<T>;
26
+ /** For `kind: "content"`, the per-media-type codec variants. */
16
27
  readonly variants?: Readonly<Record<string, Codec<unknown>>>;
17
28
  }
29
+ /** Infers the decoded value type `T` from a {@link Codec}. */
18
30
  type InferCodec<C> = C extends Codec<infer T> ? T : never;
31
+ /** The set of HTTP methods supported by endpoint descriptors. */
19
32
  type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
33
+ /** OpenAPI-style serialization styles for path, query, and header parameters. */
20
34
  type ParameterStyle = "simple" | "label" | "matrix" | "form" | "spaceDelimited" | "pipeDelimited" | "deepObject";
35
+ /** Serialization and validation settings for a single path, query, or header parameter. */
21
36
  interface ParameterSpec<T = unknown> {
22
37
  readonly validator?: Validator<T>;
23
38
  readonly style?: ParameterStyle;
24
39
  readonly explode?: boolean;
25
40
  }
41
+ /**
42
+ * Maps parameter names to either a bare {@link Validator} or a full
43
+ * {@link ParameterSpec} describing serialization and validation.
44
+ */
26
45
  type ParameterMap = Readonly<Record<string, Validator<unknown> | ParameterSpec | undefined>>;
46
+ /** Describes a single API endpoint: its method, path, parameters, body, and possible responses. */
27
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>>> {
28
48
  readonly method: HttpMethod;
29
49
  readonly path: string;
@@ -42,7 +62,9 @@ interface EndpointDescriptor<P = undefined, Q = undefined, H = undefined, B = un
42
62
  body: B;
43
63
  };
44
64
  }
65
+ /** An {@link EndpointDescriptor} with its type parameters erased, for use in generic contexts. */
45
66
  type AnyEndpointDescriptor = EndpointDescriptor<any, any, any, any, any, any>;
67
+ /** API-level (rather than per-endpoint) metadata, such as servers and security schemes. */
46
68
  interface ApiMetadata {
47
69
  readonly servers?: readonly (string | {
48
70
  readonly url: string;
@@ -50,10 +72,12 @@ interface ApiMetadata {
50
72
  readonly tags?: readonly unknown[];
51
73
  readonly securitySchemes?: Readonly<Record<string, unknown>>;
52
74
  }
75
+ /** A named collection of endpoints plus optional API metadata, as produced by {@link defineApi}. */
53
76
  interface ApiDefinition<E extends Record<string, AnyEndpointDescriptor> = Record<string, AnyEndpointDescriptor>> {
54
77
  readonly endpoints: E;
55
78
  readonly metadata?: ApiMetadata;
56
79
  }
80
+ /** A successful (2xx) fetch outcome, carrying the decoded response data. */
57
81
  type SuccessResult<T = unknown, S extends number = number> = {
58
82
  ok: true;
59
83
  kind: "success";
@@ -64,6 +88,7 @@ type SuccessResult<T = unknown, S extends number = number> = {
64
88
  url: string;
65
89
  response: Response;
66
90
  };
91
+ /** A non-2xx fetch outcome where the server responded with a decodable error body. */
67
92
  type HttpResult<T = unknown, S extends number = number> = {
68
93
  ok: false;
69
94
  kind: "http";
@@ -74,7 +99,9 @@ type HttpResult<T = unknown, S extends number = number> = {
74
99
  url: string;
75
100
  response: Response;
76
101
  };
102
+ /** Categorizes why a fetch could not produce an {@link HttpResult} or {@link SuccessResult}. */
77
103
  type FailureKind = "request" | "network" | "timeout" | "abort" | "decode" | "middleware";
104
+ /** A fetch outcome that failed before or independently of receiving a decodable HTTP response. */
78
105
  type FailureResult = {
79
106
  ok: false;
80
107
  kind: FailureKind;
@@ -84,23 +111,35 @@ type FailureResult = {
84
111
  url: string;
85
112
  response?: Response;
86
113
  };
114
+ /** The outcome of a fetch call: success, an HTTP-level error, or another kind of failure. */
87
115
  type FetchResult<T = unknown> = SuccessResult<T> | HttpResult<T> | FailureResult;
116
+ /** The mutable-by-replacement state threaded through the middleware chain for a single request. */
88
117
  interface RequestContext {
89
118
  readonly request: Request;
90
119
  readonly endpoint?: AnyEndpointDescriptor;
91
120
  readonly operationId?: string;
92
121
  readonly security?: AnyEndpointDescriptor["security"];
122
+ /** 1 on the first attempt, incremented by middleware (e.g. {@link retry}) on subsequent attempts. */
93
123
  readonly attempt: number;
124
+ /** Whether a body was encoded from a replayable value; streaming bodies set this to `false`. */
125
+ readonly replayableBody?: boolean;
126
+ /** Absolute timestamp (ms since epoch) by which the request must complete, if a timeout is set. */
94
127
  readonly deadline?: number;
95
128
  }
129
+ /** Invokes the next middleware in the chain, optionally passing a replacement context. */
96
130
  type Next = (context?: RequestContext) => Promise<FetchResult>;
131
+ /** A function that can inspect/replace the request context and/or the result of calling `next`. */
97
132
  type Middleware = (context: RequestContext, next: Next) => Promise<FetchResult>;
133
+ /** Options controlling how a {@link createClient} or {@link createFetch} client makes requests. */
98
134
  interface ClientOptions {
99
135
  baseUrl?: string;
136
+ /** Custom transport, defaulting to the global `fetch`. */
100
137
  fetch?: (request: Request) => Promise<Response>;
101
138
  headers?: HeadersInit;
102
139
  credentials?: RequestCredentials;
140
+ /** Request timeout in milliseconds. */
103
141
  timeout?: number;
142
+ /** Middleware chain applied to every request, in order. */
104
143
  middleware?: readonly Middleware[];
105
144
  }
106
145
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askrjs/fetch",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "Function-first typed HTTP contracts and clients for Askr.",
5
5
  "keywords": [
6
6
  "askr",
@@ -61,10 +61,10 @@
61
61
  "prepublishOnly": "npm run check"
62
62
  },
63
63
  "devDependencies": {
64
- "@types/node": "^26.1.1",
65
- "publint": "^0.3.21",
66
- "typescript": "^6.0.3",
67
- "vite-plus": "^0.2.4",
64
+ "@types/node": "^26.2.0",
65
+ "publint": "^0.3.23",
66
+ "typescript": "^7.0.2",
67
+ "vite-plus": "^0.2.8",
68
68
  "vitest": "^4.1.10"
69
69
  },
70
70
  "engines": {