@zudojs/http 1.3.0 → 1.4.2

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.
Files changed (68) hide show
  1. package/README.md +222 -0
  2. package/dist/httpAdapter/node/httpNode.adapter.d.ts +2 -1
  3. package/dist/httpAdapter/node/httpNode.adapter.js +17 -2
  4. package/dist/httpAdapter/node/httpNode.request.js +6 -0
  5. package/dist/httpAdapter/node/httpNode.type.d.ts +14 -0
  6. package/dist/httpClient/httpClient.retry.d.ts +17 -12
  7. package/dist/httpClient/httpClient.retry.js +35 -10
  8. package/dist/httpClient/httpClient.type.d.ts +14 -0
  9. package/dist/httpErrors/httpError.base.js +2 -2
  10. package/dist/httpErrors/httpError.util.d.ts +8 -0
  11. package/dist/httpErrors/httpError.util.js +12 -0
  12. package/dist/httpFetchMount/httpFetchMount.core.d.ts +25 -0
  13. package/dist/httpFetchMount/httpFetchMount.core.js +84 -0
  14. package/dist/httpFetchMount/httpFetchMount.request.d.ts +21 -0
  15. package/dist/httpFetchMount/httpFetchMount.request.js +100 -0
  16. package/dist/httpFetchMount/httpFetchMount.type.d.ts +56 -0
  17. package/dist/httpFetchMount/httpFetchMount.type.js +5 -0
  18. package/dist/httpFetchMount/index.d.ts +11 -0
  19. package/dist/httpFetchMount/index.js +10 -0
  20. package/dist/httpMiddleware/builtin/rateLimit/httpMiddleware.rateLimit.d.ts +6 -3
  21. package/dist/httpMiddleware/builtin/rateLimit/httpMiddleware.rateLimit.js +34 -6
  22. package/dist/httpMiddleware/httpMiddleware.type.d.ts +9 -1
  23. package/dist/httpMiddleware/pipeline/httpPipeline.execution.js +22 -45
  24. package/dist/httpMiddleware/pipeline/httpPipeline.guardResponse.d.ts +36 -0
  25. package/dist/httpMiddleware/pipeline/httpPipeline.guardResponse.js +57 -0
  26. package/dist/httpMiddleware/pipeline/httpPipeline.helper.d.ts +2 -1
  27. package/dist/httpMiddleware/pipeline/httpPipeline.helper.js +15 -0
  28. package/dist/httpMiddleware/pipeline/index.d.ts +1 -0
  29. package/dist/httpMiddleware/pipeline/index.js +1 -0
  30. package/dist/httpOpenApi/httpOpenApi.document.d.ts +44 -0
  31. package/dist/httpOpenApi/httpOpenApi.document.js +61 -0
  32. package/dist/httpOpenApi/httpOpenApi.mount.d.ts +31 -0
  33. package/dist/httpOpenApi/httpOpenApi.mount.js +58 -0
  34. package/dist/httpOpenApi/httpOpenApi.type.d.ts +54 -0
  35. package/dist/httpOpenApi/httpOpenApi.type.js +5 -0
  36. package/dist/httpOpenApi/index.d.ts +13 -0
  37. package/dist/httpOpenApi/index.js +12 -0
  38. package/dist/httpOpenApi/routeTable/index.d.ts +11 -0
  39. package/dist/httpOpenApi/routeTable/index.js +11 -0
  40. package/dist/httpOpenApi/routeTable/routeTable.collect.d.ts +19 -0
  41. package/dist/httpOpenApi/routeTable/routeTable.collect.js +89 -0
  42. package/dist/httpOpenApi/routeTable/routeTable.merge.d.ts +15 -0
  43. package/dist/httpOpenApi/routeTable/routeTable.merge.js +37 -0
  44. package/dist/httpOpenApi/routeTable/routeTable.template.d.ts +30 -0
  45. package/dist/httpOpenApi/routeTable/routeTable.template.js +67 -0
  46. package/dist/httpRequest/httpRequest.context.d.ts +8 -0
  47. package/dist/httpRequest/httpRequest.context.js +12 -0
  48. package/dist/httpRequest/index.d.ts +1 -0
  49. package/dist/httpRequest/index.js +1 -0
  50. package/dist/httpRequest/requestId/httpRequest.requestId.d.ts +25 -0
  51. package/dist/httpRequest/requestId/httpRequest.requestId.js +34 -0
  52. package/dist/httpRequest/requestId/index.d.ts +7 -0
  53. package/dist/httpRequest/requestId/index.js +7 -0
  54. package/dist/httpResponse/httpResponse.writer.js +15 -0
  55. package/dist/httpRouter/core/factory/httpRoute.factory.base.d.ts +6 -3
  56. package/dist/httpRouter/core/factory/httpRoute.factory.base.js +24 -11
  57. package/dist/httpRouter/core/group/httpRouterGroup.core.js +13 -0
  58. package/dist/httpRouter/core/register/httpRouter.register.js +4 -1
  59. package/dist/httpRouter/core/types/httpRouter.type.d.ts +31 -1
  60. package/dist/httpRouter/core/util/httpRoute.util.d.ts +8 -0
  61. package/dist/httpRouter/core/util/httpRoute.util.js +16 -0
  62. package/dist/httpRouter/dispatch/httpRoute.dispatcher.js +20 -3
  63. package/dist/httpSecurity/httpSecurity.config.js +4 -1
  64. package/dist/httpServer/factory/httpServer.factory.d.ts +10 -9
  65. package/dist/httpServer/factory/httpServer.factory.js +8 -0
  66. package/dist/index.d.ts +2 -0
  67. package/dist/index.js +2 -0
  68. package/package.json +11 -8
package/README.md CHANGED
@@ -33,6 +33,10 @@ const server = createHttpServer({
33
33
  await server.start();
34
34
  ```
35
35
 
36
+ `createHttpServer` takes `HttpServerOptions`, so `request` in the handler
37
+ above is typed `HttpRequestContext` without an annotation (the options used
38
+ to be typed `unknown`, which failed `strict` builds with TS7006).
39
+
36
40
  A handler receives an `HttpRequestContext` and may return an
37
41
  `HttpResponseContext` or any JSON value. **Every value that is not an
38
42
  `HttpResponseContext` is data**: a plain object such as `{ status: "ok" }` is
@@ -72,6 +76,16 @@ answers 502 and never exposes the cause.
72
76
  wraps `@zudojs/security`'s `createRateLimiter`. Requests with no usable
73
77
  client address share one bucket (`UNKNOWN_CLIENT_RATE_LIMIT_IP`,
74
78
  `0.0.0.0`): they are limited together, never unlimited and never a 500.
79
+ The 429 carries a JSON body sent as `application/json` and always a
80
+ `Retry-After` header, even when a custom limiter handler omits it.
81
+ - **Request ids.** `request.id` reuses the client's `x-request-id` when it is
82
+ 1-128 characters of `[A-Za-z0-9._:-]`; any other value is ignored and a
83
+ UUID is generated, so an id copied into logs can never carry spaces,
84
+ quotes or control characters. `createNodeHttpAdapter({ trustRequestId:
85
+ false })` always generates one. The request guard's own `X-Request-Id`
86
+ check uses the same character set and length, so an id the adapter would
87
+ reuse is never refused first; anything else is answered with 400 unless the
88
+ guard is tuned or turned off.
75
89
  - Signed-cookie signatures are compared with `@zudojs/crypto`'s constant-time
76
90
  `timingSafeEqualString`.
77
91
  - Contexts built by the stock adapters log through a `@zudojs/logger` console
@@ -82,12 +96,220 @@ answers 502 and never exposes the cause.
82
96
  - `HttpServer.stop()` gives in-flight requests the full
83
97
  `gracefulShutdownTimeout`.
84
98
 
99
+ ## Routes
100
+
101
+ A route handler returns what a server handler returns:
102
+
103
+ ```typescript
104
+ router.get("/health", () => ({ status: "ok" })); // 200, JSON body
105
+ router.get("/users/:id", async (ctx) => loadUser(ctx.params.id));
106
+ router.post("/users", () =>
107
+ createResponseContext({ status: 201, body: { created: true } }),
108
+ );
109
+ router.delete("/users/:id", () => undefined); // 204
110
+ ```
111
+
112
+ A plain value (object, array, string, number, boolean) is sent as `200`
113
+ with a JSON body; `undefined` or `null` is `204 No Content`; an
114
+ `HttpResponseContext` or a web `Response` is sent as built. (A plain
115
+ object used to be a type error and was sent as an empty `204`.) The
116
+ router and `RouteDispatcher` behave the same.
117
+
118
+ Route parameters are set on the request before route middleware runs, so
119
+ `ctx.request.getParam("id")` works in a guard or an `extractResource`
120
+ loader as well as in the handler (`ctx.params`).
121
+
122
+ ## Middleware errors
123
+
124
+ An error thrown by a middleware or handler propagates **as the error that
125
+ was thrown**. An outer middleware's `await next()` rejects with it, the
126
+ pipeline's `onError` receives it, and so does the server's `errorHandler`,
127
+ so `error instanceof NotFoundError` works in each. It used to arrive wrapped
128
+ in `HttpMiddlewareError` (inside a middleware) or
129
+ `HttpMiddlewarePipelineError` (in `errorHandler`), with the original only in
130
+ `cause` / `errors[0].cause`.
131
+
132
+ Code after `await next()` does not run when the chain below it throws,
133
+ unless the middleware catches the error:
134
+
135
+ ```typescript
136
+ pipeline.use(async (ctx, next) => {
137
+ const started = Date.now();
138
+ try {
139
+ return await next();
140
+ } finally {
141
+ log.info("request", { path: ctx.request.path, ms: Date.now() - started });
142
+ }
143
+ });
144
+ ```
145
+
146
+ If `onError` returns a response, that is the recovery; if it throws, what
147
+ it threw propagates (rethrow the error to pass it on, or throw a different
148
+ one to translate it).
149
+
150
+ `new HttpError(415, "No XML")` without a `code` gets its code from the
151
+ status (`"UNSUPPORTED_MEDIA_TYPE"`, `"NOT_FOUND"`, ...), matching the
152
+ `notFound()`-style factories, instead of `ERR_OPERATION_FAILED`.
153
+
154
+ ## HTTP client: retries and backoff
155
+
156
+ ```typescript
157
+ const client = new HttpClient({
158
+ timeout: 5_000,
159
+ retry: { retries: 3, retryDelay: 200, maxRetryDelay: 5_000 },
160
+ });
161
+ ```
162
+
163
+ - **What is retried:** responses with a status in `retryStatusCodes`
164
+ (default 429, 502, 503, 504); transport failures such as a refused
165
+ connection (`retryOnNetworkError`, default `true`); and requests that hit
166
+ `timeout` (`retryOnTimeout`, default: the `retryOnNetworkError` value).
167
+ Timeouts used to be excluded, so a `GET` with retries still failed on
168
+ the first timeout. Aborting through your own `signal` is never retried.
169
+ - **Which methods:** only `retryMethods` (default `GET`, `HEAD`,
170
+ `OPTIONS`). A `POST` that timed out may already have been processed, so
171
+ it is not replayed unless you list it.
172
+ - **Backoff:** the delay is `retryDelay` (default 1000 ms) times
173
+ `2^attempt` with `backoff: "exponential"` (the default), or `retryDelay`
174
+ every time with `"fixed"`, capped at `maxRetryDelay` (default 30 s).
175
+ - **Jitter:** each wait is drawn uniformly between 0 and that delay (full
176
+ jitter), so clients that failed together do not retry in lockstep.
177
+ `jitter: false` waits exactly the delay. Jitter used to add up to a fixed
178
+ second regardless of `retryDelay`.
179
+ - `retries` counts retries after the first attempt (default 0).
180
+
181
+ ## OpenAPI from your routes
182
+
183
+ Routes carry their own documentation through the `openapi` option, and the
184
+ document is generated from the routes the router actually registered — no
185
+ second list to keep in sync. Schemas may be `@zudojs/schema` schemas or raw
186
+ OpenAPI schemas.
187
+
188
+ ```typescript
189
+ import { objectSchema, stringSchema, numberSchema, optionalSchema } from "@zudojs/schema";
190
+ import { createRouter, generateOpenAPIDocument, mountOpenAPI } from "@zudojs/http";
191
+
192
+ const user = objectSchema({ id: stringSchema().uuid(), name: stringSchema() });
193
+
194
+ const router = createRouter();
195
+ router.get("/users/:id", getUser, {
196
+ openapi: {
197
+ summary: "Get a user",
198
+ tags: ["users"],
199
+ params: objectSchema({ id: stringSchema().uuid() }),
200
+ responses: { "200": { schema: user }, "404": { description: "No such user" } },
201
+ },
202
+ });
203
+ router.get("/users", listUsers, {
204
+ openapi: { query: objectSchema({ limit: optionalSchema(numberSchema().int()) }) },
205
+ });
206
+ router.post("/users", createUser, {
207
+ openapi: { body: objectSchema({ name: stringSchema() }), responses: { "201": { schema: user } } },
208
+ });
209
+ router.get("/health", health, { openapi: false }); // never documented
210
+
211
+ // One-off document:
212
+ const document = generateOpenAPIDocument(router, {
213
+ info: { title: "Users API", version: "1.0.0" },
214
+ exclude: ["/internal/*"],
215
+ validate: true,
216
+ });
217
+
218
+ // Or serve it: GET /openapi.json and a Swagger UI page at GET /docs.
219
+ mountOpenAPI(router, {
220
+ info: { title: "Users API", version: "1.0.0" },
221
+ yamlPath: "/openapi.yaml", // optional
222
+ ui: { renderer: "redoc" }, // optional; any renderOpenAPIUI option
223
+ });
224
+ ```
225
+
226
+ - `:id` and `{id}` become `{id}`; every template slot is documented even when
227
+ nothing declares it. A regex-constrained `:id(\d+)` becomes a parameter
228
+ with that `pattern`, an optional `:id?` is documented as both paths, and a
229
+ wildcard `*rest` becomes a `{rest}` slot (`wildcards: "exclude"` drops such
230
+ routes instead).
231
+ - Left out: `all()` routes, `CONNECT`, routes with `openapi: false` or
232
+ `{ hidden: true }`, and anything matched by `exclude` (exact path,
233
+ `"/prefix/*"`, a `RegExp`, or a predicate). The router's automatic `HEAD`
234
+ and `OPTIONS` answers are not registered routes and never appear.
235
+ `undocumented: "exclude"` documents only routes that declare `openapi`.
236
+ - Router groups pass `openapi` defaults to their routes: tags are unioned,
237
+ everything else is overridden by the route.
238
+ - The document follows the router: a route added later is in the next
239
+ `generateOpenAPIDocument` call and the next request to a mounted
240
+ `/openapi.json`. `createRouterOpenAPI(router, options)` gives the
241
+ underlying `OpenAPIManager`, re-read only when the route table changed.
242
+ - Paths are configurable (`path`, `docsPath: false` to disable the page,
243
+ `ui.specUrl` when served under a prefix), and `middleware` protects the
244
+ documentation routes.
245
+
246
+ ## Mounting web-standard handlers
247
+
248
+ `mountFetchHandler` serves any `(request: Request) => Response | Promise<Response>`
249
+ handler — an `@zudojs/rpc` server, `@zudojs/api` operations, another
250
+ fetch-style app — under a path of a router or router group.
251
+
252
+ ```typescript
253
+ import { mountFetchHandler } from "@zudojs/http";
254
+ import { createRPCFetchHandler } from "@zudojs/rpc";
255
+
256
+ const unmount = mountFetchHandler(router, "/rpc", createRPCFetchHandler(rpcServer));
257
+ // { methods: ["POST"], stripPrefix: false, middleware: [auth] } are optional
258
+ ```
259
+
260
+ The handler sees the original method, query, headers (connection-scoped ones
261
+ removed) and body; the mount path is stripped from its URL by default and
262
+ passed as `x-forwarded-prefix`. Its `Response` is streamed back with status,
263
+ status text and headers intact, each `Set-Cookie` kept separate. The
264
+ request's `signal` aborts when the client disconnects. A handler that throws
265
+ or returns something other than a `Response` fails the request like any
266
+ route (a generic 500 unless the error carries a status). `toWebRequest(context)`
267
+ does the request conversion on its own.
268
+
269
+ The origin of the handler's `request.url` comes from the client's `Host`
270
+ header (or `X-Forwarded-Host` from a trusted proxy) unless you pin it with
271
+ `{ origin: "https://api.example.com" }`. Pin it whenever the handler builds
272
+ absolute URLs or compares `Origin` against its own.
273
+
274
+ Every Node request context now carries that signal too: `request.signal` and
275
+ the router's `ctx.signal` abort when the client goes away, and a streamed
276
+ response body stops being read.
277
+
278
+ ## Guards that refuse a request
279
+
280
+ A middleware answers a request itself — 401, 403, 404 — by returning a
281
+ `GuardResponse` from `@zudojs/middleware`. The router, `HttpMiddlewarePipeline`
282
+ and `RouteDispatcher` send it with its own status, headers and body; headers an
283
+ outer middleware already set (CORS, for instance) are kept.
284
+
285
+ ```typescript
286
+ import { createGuardResponse } from "@zudojs/middleware";
287
+ import { authorize } from "@zudojs/permissions";
288
+
289
+ router.delete("/posts/:id", deletePost, {
290
+ middleware: [
291
+ authorize(engine, "post:delete", { extractActor }), // 401 / 403
292
+ async (ctx, next) =>
293
+ ctx.request.getHeader("x-confirm")
294
+ ? next()
295
+ : createGuardResponse({ status: 400, body: { error: "Confirm first" } }),
296
+ ],
297
+ });
298
+ ```
299
+
300
+ A route middleware's return value used to be ignored unless it was an
301
+ `HttpResponseContext` or a web `Response`, so `authorize()` and the tenancy
302
+ middleware refused requests with `200`. Only the branded object is honoured: an ordinary
303
+ object with a `status` key keeps its old meaning.
304
+
85
305
  ## Features
86
306
 
87
307
  - Runtime-independent HTTP server abstraction
88
308
  - Request/response wrappers with full Web API compatibility
89
309
  - Middleware pipeline with error handling
90
310
  - Router with parameter extraction
311
+ - OpenAPI documents generated from the registered routes
312
+ - Mounting of web-standard fetch handlers
91
313
  - CORS, security headers, and content negotiation
92
314
  - HTTP client with interceptors
93
315
 
@@ -22,6 +22,7 @@ export declare class NodeHttpAdapter extends BaseHttpAdapter {
22
22
  private readonly keepAliveTimeout;
23
23
  private readonly connectionTimeout;
24
24
  private readonly trustProxy;
25
+ private readonly trustRequestId;
25
26
  private readonly maxConnections;
26
27
  private readonly connectionsCheckingInterval;
27
28
  private readonly shutdownGraceMs;
@@ -39,7 +40,7 @@ export declare class NodeHttpAdapter extends BaseHttpAdapter {
39
40
  constructor(options?: NodeAdapterOptions);
40
41
  get httpServer(): Server | undefined;
41
42
  get address(): NodeServerAddress | undefined;
42
- createRequest(input: unknown): HttpRequestContext;
43
+ createRequest(input: unknown, signal?: AbortSignal): HttpRequestContext;
43
44
  createResponse(input?: unknown): HttpResponseContext;
44
45
  createWriter(response: unknown): HttpResponseWriter;
45
46
  handle(input: unknown): Promise<void>;
@@ -30,6 +30,7 @@ export class NodeHttpAdapter extends BaseHttpAdapter {
30
30
  keepAliveTimeout;
31
31
  connectionTimeout;
32
32
  trustProxy;
33
+ trustRequestId;
33
34
  maxConnections;
34
35
  connectionsCheckingInterval;
35
36
  shutdownGraceMs;
@@ -76,6 +77,7 @@ export class NodeHttpAdapter extends BaseHttpAdapter {
76
77
  Math.min(30_000, this.headersTimeout);
77
78
  this.shutdownGraceMs = options.shutdownGraceMs;
78
79
  this.trustProxy = options.trustProxy;
80
+ this.trustRequestId = options.trustRequestId ?? true;
79
81
  if (options.trustProxy !== undefined) {
80
82
  compileTrustProxy(options.trustProxy);
81
83
  }
@@ -109,13 +111,15 @@ export class NodeHttpAdapter extends BaseHttpAdapter {
109
111
  /* ------------------------------------------------------------------------ */
110
112
  /* Request / Response */
111
113
  /* ------------------------------------------------------------------------ */
112
- createRequest(input) {
114
+ createRequest(input, signal) {
113
115
  if (!isIncomingMessage(input)) {
114
116
  throw new TypeError("NodeHttpAdapter.createRequest expected an IncomingMessage.");
115
117
  }
116
118
  return createNodeRequestContext(input, {
117
119
  maxBodySize: this.maxBodySize,
118
120
  trustProxy: this.trustProxy,
121
+ trustRequestId: this.trustRequestId,
122
+ signal,
119
123
  });
120
124
  }
121
125
  createResponse(input) {
@@ -144,8 +148,19 @@ export class NodeHttpAdapter extends BaseHttpAdapter {
144
148
  return;
145
149
  }
146
150
  let context;
151
+ const disconnect = new AbortController();
152
+ /*
153
+ * `close` before the response finished means the client went away.
154
+ * Handlers see it as `request.signal` / the router's `ctx.signal`, and a
155
+ * streamed response body stops being pulled.
156
+ */
157
+ response.once("close", () => {
158
+ if (!response.writableFinished) {
159
+ disconnect.abort();
160
+ }
161
+ });
147
162
  try {
148
- context = this.createRequest(request);
163
+ context = this.createRequest(request, disconnect.signal);
149
164
  }
150
165
  catch (error) {
151
166
  /*
@@ -8,6 +8,7 @@ import { getClientIp, isTrustedProxy, } from "../../httpTrustProxy/httpTrustProx
8
8
  import { removePort, extractPort } from "./httpNode.server.js";
9
9
  import { parseQueryString } from "../../httpQuery/index.js";
10
10
  import { findRequestTargetViolation } from "../../httpRequest/target/httpRequest.target.js";
11
+ import { resolveIncomingRequestId } from "../../httpRequest/requestId/httpRequest.requestId.js";
11
12
  /* -------------------------------------------------------------------------- */
12
13
  /* Proxy Trust */
13
14
  /* -------------------------------------------------------------------------- */
@@ -159,7 +160,11 @@ export function createNodeRequestContext(request, options = {}) {
159
160
  const port = getNodeRequestPort(request, options);
160
161
  const remoteAddress = getNodeRemoteAddress(request, options);
161
162
  const query = parseNodeQuery(request);
163
+ const id = options.trustRequestId === false
164
+ ? undefined
165
+ : resolveIncomingRequestId(request.headers["x-request-id"]);
162
166
  return createRequestContext({
167
+ ...(id === undefined ? {} : { id }),
163
168
  method: request.method?.toUpperCase() ?? "GET",
164
169
  url,
165
170
  protocol,
@@ -168,6 +173,7 @@ export function createNodeRequestContext(request, options = {}) {
168
173
  headers,
169
174
  query,
170
175
  remoteAddress,
176
+ signal: options.signal,
171
177
  });
172
178
  }
173
179
  //# sourceMappingURL=httpNode.request.js.map
@@ -13,6 +13,11 @@ export interface NodeAdapterOptions extends HttpAdapterOptions, NodeAdapterSecur
13
13
  readonly server?: Server;
14
14
  readonly maxBodySize?: number;
15
15
  readonly trustProxy?: boolean | number | string | readonly string[];
16
+ /**
17
+ * Whether `request.id` reuses a well-formed incoming `x-request-id`
18
+ * header (default: `true`); see {@link NodeRequestOptions.trustRequestId}.
19
+ */
20
+ readonly trustRequestId?: boolean;
16
21
  readonly requestTimeout?: number;
17
22
  readonly headersTimeout?: number;
18
23
  readonly keepAliveTimeout?: number;
@@ -54,6 +59,15 @@ export interface NodeRequestOptions {
54
59
  * - string[]: Trust specific IP addresses
55
60
  */
56
61
  readonly trustProxy?: TrustProxy;
62
+ /** Signal carried by the context; aborted when the client disconnects. */
63
+ readonly signal?: AbortSignal;
64
+ /**
65
+ * Whether `request.id` reuses the client's `x-request-id` header
66
+ * (default: `true`). The header is used only when it is at most 128
67
+ * characters of `[A-Za-z0-9._:-]`; any other value is ignored and an id
68
+ * is generated. `false` always generates one.
69
+ */
70
+ readonly trustRequestId?: boolean;
57
71
  }
58
72
  export interface NodeServerAddress {
59
73
  readonly host: string;
@@ -4,17 +4,8 @@
4
4
  * Handles retry configuration, status-based retry decisions,
5
5
  * exponential backoff, and delay utilities.
6
6
  */
7
- import type { HttpClientMethod } from "./httpClient.type.js";
8
- /** Retry options for the HTTP client. */
9
- export interface HttpRetryOptions {
10
- readonly retries?: number;
11
- readonly retryDelay?: number;
12
- readonly maxRetryDelay?: number;
13
- readonly retryStatusCodes?: readonly number[];
14
- readonly retryMethods?: readonly HttpClientMethod[];
15
- readonly retryOnNetworkError?: boolean;
16
- readonly backoff?: "fixed" | "exponential";
17
- }
7
+ import type { HttpRetryOptions } from "./httpClient.type.js";
8
+ export type { HttpRetryOptions } from "./httpClient.type.js";
18
9
  /**
19
10
  * Normalize retry options with defaults.
20
11
  */
@@ -25,10 +16,24 @@ export declare function normalizeRetryOptions(options?: HttpRetryOptions): HttpR
25
16
  export declare function shouldRetryStatus(status: number, method: string, retry?: HttpRetryOptions): boolean;
26
17
  /**
27
18
  * Check if an error should trigger a retry.
19
+ *
20
+ * Transport failures are retried when `retryOnNetworkError` is on, and
21
+ * timeouts when `retryOnTimeout` is on (it defaults to
22
+ * `retryOnNetworkError`). Both apply only to `retryMethods`, which default
23
+ * to the idempotent `GET`, `HEAD` and `OPTIONS`. A caller's own abort is
24
+ * never retried.
28
25
  */
29
26
  export declare function shouldRetryError(error: unknown, method: string, retry?: HttpRetryOptions): boolean;
30
27
  /**
31
- * Calculate retry delay with exponential backoff.
28
+ * Calculate the wait before retry number `attempt + 1`.
29
+ *
30
+ * The delay is `retryDelay` (fixed backoff) or `retryDelay * 2^attempt`
31
+ * (exponential), capped at `maxRetryDelay`. With `jitter` (the default) the
32
+ * wait is drawn uniformly from 0 up to that delay ("full jitter"), so a
33
+ * burst of clients that failed together does not retry in lockstep;
34
+ * `jitter: false` waits exactly the delay. Jitter used to add up to a
35
+ * fixed 1000 ms whatever `retryDelay` was, so `retryDelay: 50` could wait
36
+ * a second.
32
37
  */
33
38
  export declare function calculateRetryDelay(attempt: number, retry: HttpRetryOptions): number;
34
39
  /**
@@ -4,6 +4,7 @@
4
4
  * Handles retry configuration, status-based retry decisions,
5
5
  * exponential backoff, and delay utilities.
6
6
  */
7
+ import { HttpClientTimeoutError } from "./httpClient.error.js";
7
8
  /**
8
9
  * Normalize retry options with defaults.
9
10
  */
@@ -17,7 +18,9 @@ export function normalizeRetryOptions(options) {
17
18
  retryStatusCodes: options.retryStatusCodes ?? [429, 502, 503, 504],
18
19
  retryMethods: options.retryMethods ?? ["GET", "HEAD", "OPTIONS"],
19
20
  retryOnNetworkError: options.retryOnNetworkError ?? true,
21
+ retryOnTimeout: options.retryOnTimeout ?? options.retryOnNetworkError ?? true,
20
22
  backoff: options.backoff ?? "exponential",
23
+ jitter: options.jitter ?? true,
21
24
  };
22
25
  }
23
26
  /**
@@ -32,20 +35,35 @@ export function shouldRetryStatus(status, method, retry) {
32
35
  }
33
36
  /**
34
37
  * Check if an error should trigger a retry.
38
+ *
39
+ * Transport failures are retried when `retryOnNetworkError` is on, and
40
+ * timeouts when `retryOnTimeout` is on (it defaults to
41
+ * `retryOnNetworkError`). Both apply only to `retryMethods`, which default
42
+ * to the idempotent `GET`, `HEAD` and `OPTIONS`. A caller's own abort is
43
+ * never retried.
35
44
  */
36
45
  export function shouldRetryError(error, method, retry) {
37
46
  if (!retry?.retries)
38
47
  return false;
39
- if (!retry.retryOnNetworkError)
40
- return false;
41
48
  /*
42
- * A connection that drops after the server processed the request is
43
- * indistinguishable from one that never arrived, so replaying a
44
- * non-idempotent method duplicates its side effects. `retryMethods` exists
45
- * for exactly this and was honoured only on the status path.
49
+ * A connection that drops (or a request that times out) after the server
50
+ * processed it is indistinguishable from one that never arrived, so
51
+ * replaying a non-idempotent method duplicates its side effects.
52
+ * `retryMethods` exists for exactly this and was honoured only on the
53
+ * status path.
46
54
  */
47
55
  if (!retry.retryMethods?.includes(method))
48
56
  return false;
57
+ /*
58
+ * Timeouts were never retried: `isRetryableNetworkError` does not match
59
+ * `HttpClientTimeoutError`, so `retryOnNetworkError` had no effect on the
60
+ * most common transient failure.
61
+ */
62
+ if (error instanceof HttpClientTimeoutError) {
63
+ return retry.retryOnTimeout ?? retry.retryOnNetworkError ?? true;
64
+ }
65
+ if (!retry.retryOnNetworkError)
66
+ return false;
49
67
  return isRetryableNetworkError(error);
50
68
  }
51
69
  /**
@@ -71,15 +89,22 @@ function isRetryableNetworkError(error) {
71
89
  return !/body|disturbed|already (been )?(used|read)/i.test(error.message);
72
90
  }
73
91
  /**
74
- * Calculate retry delay with exponential backoff.
92
+ * Calculate the wait before retry number `attempt + 1`.
93
+ *
94
+ * The delay is `retryDelay` (fixed backoff) or `retryDelay * 2^attempt`
95
+ * (exponential), capped at `maxRetryDelay`. With `jitter` (the default) the
96
+ * wait is drawn uniformly from 0 up to that delay ("full jitter"), so a
97
+ * burst of clients that failed together does not retry in lockstep;
98
+ * `jitter: false` waits exactly the delay. Jitter used to add up to a
99
+ * fixed 1000 ms whatever `retryDelay` was, so `retryDelay: 50` could wait
100
+ * a second.
75
101
  */
76
102
  export function calculateRetryDelay(attempt, retry) {
77
103
  const base = retry.retryDelay ?? 1000;
78
104
  const max = retry.maxRetryDelay ?? 30_000;
79
105
  const ms = retry.backoff === "fixed" ? base : base * Math.pow(2, attempt);
80
- /* Clamp *after* jitter, and on the fixed branch too, or `maxRetryDelay`
81
- * is not actually a maximum. */
82
- return Math.min(ms + Math.random() * 1000, max);
106
+ const capped = Math.max(0, Math.min(ms, max));
107
+ return retry.jitter === false ? capped : Math.random() * capped;
83
108
  }
84
109
  /**
85
110
  * Delay for a given number of milliseconds.
@@ -8,14 +8,28 @@ export type HttpClientBody = BodyInit | Record<string, unknown> | readonly unkno
8
8
  export type HttpClientQueryValue = string | number | boolean | bigint | null | undefined | readonly (string | number | boolean | bigint)[];
9
9
  export type HttpClientQuery = Readonly<Record<string, HttpClientQueryValue>> | URLSearchParams;
10
10
  export type HttpResponseType = "auto" | "json" | "text" | "arrayBuffer" | "blob" | "response";
11
+ /**
12
+ * Retry policy for the HTTP client. See the README's "Retries and backoff".
13
+ */
11
14
  export interface HttpRetryOptions {
15
+ /** Retries after the first attempt (default 0: no retries). */
12
16
  readonly retries?: number;
17
+ /** Base delay in ms (default 1000). */
13
18
  readonly retryDelay?: number;
19
+ /** Upper bound on any single wait, in ms (default 30000). */
14
20
  readonly maxRetryDelay?: number;
21
+ /** Statuses that are retried (default 429, 502, 503, 504). */
15
22
  readonly retryStatusCodes?: readonly number[];
23
+ /** Methods that may be retried at all (default GET, HEAD, OPTIONS). */
16
24
  readonly retryMethods?: readonly HttpClientMethod[];
25
+ /** Retry transport failures such as a refused connection (default true). */
17
26
  readonly retryOnNetworkError?: boolean;
27
+ /** Retry a request that hit `timeout` (default: `retryOnNetworkError`). */
28
+ readonly retryOnTimeout?: boolean;
29
+ /** `"exponential"` doubles the delay per attempt (default); `"fixed"` does not. */
18
30
  readonly backoff?: "fixed" | "exponential";
31
+ /** Wait a random 0..delay instead of exactly the delay (default true). */
32
+ readonly jitter?: boolean;
19
33
  }
20
34
  export interface HttpClientResponse<T = unknown> {
21
35
  readonly data: T;
@@ -7,7 +7,7 @@
7
7
  * @module httpErrors/base
8
8
  */
9
9
  import { HttpError as BaseHttpError } from "@zudojs/errors";
10
- import { normalizeHeaders, getStatusText } from "./httpError.util.js";
10
+ import { defaultErrorCode, normalizeHeaders, getStatusText, } from "./httpError.util.js";
11
11
  /**
12
12
  * HTTP error with response-specific properties.
13
13
  *
@@ -31,7 +31,7 @@ export class HttpError extends BaseHttpError {
31
31
  const statusText = getStatusText(status);
32
32
  super(message ?? statusText, {
33
33
  statusCode: status,
34
- code: options.code,
34
+ code: options.code ?? defaultErrorCode(status),
35
35
  expose: options.expose ?? status < 500,
36
36
  metadata: options.metadata,
37
37
  cause: options.cause,
@@ -19,4 +19,12 @@ export declare function normalizeHeaders(headers: Record<string, string> | undef
19
19
  * `statusText` and `message` as "Unknown Status".
20
20
  */
21
21
  export declare function getStatusText(status: number): string;
22
+ /**
23
+ * The default error code for a status: its symbolic name, as the factories
24
+ * use (`415` gives `"UNSUPPORTED_MEDIA_TYPE"`, `404` `"NOT_FOUND"`).
25
+ * `undefined` for a status with no name, which keeps the shared default.
26
+ * `new HttpError(415, msg)` without a code used to report
27
+ * `ERR_OPERATION_FAILED`.
28
+ */
29
+ export declare function defaultErrorCode(status: number): string | undefined;
22
30
  //# sourceMappingURL=httpError.util.d.ts.map
@@ -8,6 +8,7 @@
8
8
  * @module httpErrors/util
9
9
  */
10
10
  import { getStatusText as lookupStatusText } from "../httpStatus/httpStatus.lookup.js";
11
+ import { statusName } from "../httpStatus/httpStatus.name.js";
11
12
  /**
12
13
  * Normalizes header keys to lowercase.
13
14
  */
@@ -33,4 +34,15 @@ export function normalizeHeaders(headers) {
33
34
  export function getStatusText(status) {
34
35
  return lookupStatusText(status);
35
36
  }
37
+ /**
38
+ * The default error code for a status: its symbolic name, as the factories
39
+ * use (`415` gives `"UNSUPPORTED_MEDIA_TYPE"`, `404` `"NOT_FOUND"`).
40
+ * `undefined` for a status with no name, which keeps the shared default.
41
+ * `new HttpError(415, msg)` without a code used to report
42
+ * `ERR_OPERATION_FAILED`.
43
+ */
44
+ export function defaultErrorCode(status) {
45
+ const name = statusName(status);
46
+ return name === "UNKNOWN" ? undefined : name;
47
+ }
36
48
  //# sourceMappingURL=httpError.util.js.map
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Serving web-standard fetch handlers from an `@zudojs/http` router.
3
+ */
4
+ import type { HttpFetchHandler, HttpFetchMountTarget, MountFetchHandlerOptions } from "./httpFetchMount.type.js";
5
+ /**
6
+ * Serves a web-standard `(request: Request) => Promise<Response>` handler —
7
+ * an `@zudojs/rpc` server, `@zudojs/api` operations, any fetch-style app —
8
+ * under `basePath` of an `@zudojs/http` router or router group.
9
+ *
10
+ * The handler receives a `Request` with the original method, headers
11
+ * (connection-scoped ones removed), body and query, and a `signal` that
12
+ * aborts when the client disconnects. Its `Response` is streamed back with
13
+ * status, status text and headers intact, every `Set-Cookie` kept separate.
14
+ * A handler that throws, or returns something that is not a `Response`,
15
+ * fails the request like any other route (500 unless the error carries a
16
+ * status).
17
+ *
18
+ * ```ts
19
+ * mountFetchHandler(router, "/rpc", createRPCFetchHandler(rpcServer));
20
+ * ```
21
+ *
22
+ * @returns A function that removes the mount.
23
+ */
24
+ export declare function mountFetchHandler(target: HttpFetchMountTarget, basePath: string, handler: HttpFetchHandler, options?: MountFetchHandlerOptions): () => void;
25
+ //# sourceMappingURL=httpFetchMount.core.d.ts.map