@zudojs/http 1.3.0 → 1.4.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.
Files changed (68) hide show
  1. package/README.md +221 -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 +10 -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,15 @@ 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 (letters, digits, `_`, `-`) still answers 400 to a malformed header
87
+ first unless it is tuned or turned off.
75
88
  - Signed-cookie signatures are compared with `@zudojs/crypto`'s constant-time
76
89
  `timingSafeEqualString`.
77
90
  - Contexts built by the stock adapters log through a `@zudojs/logger` console
@@ -82,12 +95,220 @@ answers 502 and never exposes the cause.
82
95
  - `HttpServer.stop()` gives in-flight requests the full
83
96
  `gracefulShutdownTimeout`.
84
97
 
98
+ ## Routes
99
+
100
+ A route handler returns what a server handler returns:
101
+
102
+ ```typescript
103
+ router.get("/health", () => ({ status: "ok" })); // 200, JSON body
104
+ router.get("/users/:id", async (ctx) => loadUser(ctx.params.id));
105
+ router.post("/users", () =>
106
+ createResponseContext({ status: 201, body: { created: true } }),
107
+ );
108
+ router.delete("/users/:id", () => undefined); // 204
109
+ ```
110
+
111
+ A plain value (object, array, string, number, boolean) is sent as `200`
112
+ with a JSON body; `undefined` or `null` is `204 No Content`; an
113
+ `HttpResponseContext` or a web `Response` is sent as built. (A plain
114
+ object used to be a type error and was sent as an empty `204`.) The
115
+ router and `RouteDispatcher` behave the same.
116
+
117
+ Route parameters are set on the request before route middleware runs, so
118
+ `ctx.request.getParam("id")` works in a guard or an `extractResource`
119
+ loader as well as in the handler (`ctx.params`).
120
+
121
+ ## Middleware errors
122
+
123
+ An error thrown by a middleware or handler propagates **as the error that
124
+ was thrown**. An outer middleware's `await next()` rejects with it, the
125
+ pipeline's `onError` receives it, and so does the server's `errorHandler`,
126
+ so `error instanceof NotFoundError` works in each. It used to arrive wrapped
127
+ in `HttpMiddlewareError` (inside a middleware) or
128
+ `HttpMiddlewarePipelineError` (in `errorHandler`), with the original only in
129
+ `cause` / `errors[0].cause`.
130
+
131
+ Code after `await next()` does not run when the chain below it throws,
132
+ unless the middleware catches the error:
133
+
134
+ ```typescript
135
+ pipeline.use(async (ctx, next) => {
136
+ const started = Date.now();
137
+ try {
138
+ return await next();
139
+ } finally {
140
+ log.info("request", { path: ctx.request.path, ms: Date.now() - started });
141
+ }
142
+ });
143
+ ```
144
+
145
+ If `onError` returns a response, that is the recovery; if it throws, what
146
+ it threw propagates (rethrow the error to pass it on, or throw a different
147
+ one to translate it).
148
+
149
+ `new HttpError(415, "No XML")` without a `code` gets its code from the
150
+ status (`"UNSUPPORTED_MEDIA_TYPE"`, `"NOT_FOUND"`, ...), matching the
151
+ `notFound()`-style factories, instead of `ERR_OPERATION_FAILED`.
152
+
153
+ ## HTTP client: retries and backoff
154
+
155
+ ```typescript
156
+ const client = new HttpClient({
157
+ timeout: 5_000,
158
+ retry: { retries: 3, retryDelay: 200, maxRetryDelay: 5_000 },
159
+ });
160
+ ```
161
+
162
+ - **What is retried:** responses with a status in `retryStatusCodes`
163
+ (default 429, 502, 503, 504); transport failures such as a refused
164
+ connection (`retryOnNetworkError`, default `true`); and requests that hit
165
+ `timeout` (`retryOnTimeout`, default: the `retryOnNetworkError` value).
166
+ Timeouts used to be excluded, so a `GET` with retries still failed on
167
+ the first timeout. Aborting through your own `signal` is never retried.
168
+ - **Which methods:** only `retryMethods` (default `GET`, `HEAD`,
169
+ `OPTIONS`). A `POST` that timed out may already have been processed, so
170
+ it is not replayed unless you list it.
171
+ - **Backoff:** the delay is `retryDelay` (default 1000 ms) times
172
+ `2^attempt` with `backoff: "exponential"` (the default), or `retryDelay`
173
+ every time with `"fixed"`, capped at `maxRetryDelay` (default 30 s).
174
+ - **Jitter:** each wait is drawn uniformly between 0 and that delay (full
175
+ jitter), so clients that failed together do not retry in lockstep.
176
+ `jitter: false` waits exactly the delay. Jitter used to add up to a fixed
177
+ second regardless of `retryDelay`.
178
+ - `retries` counts retries after the first attempt (default 0).
179
+
180
+ ## OpenAPI from your routes
181
+
182
+ Routes carry their own documentation through the `openapi` option, and the
183
+ document is generated from the routes the router actually registered — no
184
+ second list to keep in sync. Schemas may be `@zudojs/schema` schemas or raw
185
+ OpenAPI schemas.
186
+
187
+ ```typescript
188
+ import { objectSchema, stringSchema, numberSchema, optionalSchema } from "@zudojs/schema";
189
+ import { createRouter, generateOpenAPIDocument, mountOpenAPI } from "@zudojs/http";
190
+
191
+ const user = objectSchema({ id: stringSchema().uuid(), name: stringSchema() });
192
+
193
+ const router = createRouter();
194
+ router.get("/users/:id", getUser, {
195
+ openapi: {
196
+ summary: "Get a user",
197
+ tags: ["users"],
198
+ params: objectSchema({ id: stringSchema().uuid() }),
199
+ responses: { "200": { schema: user }, "404": { description: "No such user" } },
200
+ },
201
+ });
202
+ router.get("/users", listUsers, {
203
+ openapi: { query: objectSchema({ limit: optionalSchema(numberSchema().int()) }) },
204
+ });
205
+ router.post("/users", createUser, {
206
+ openapi: { body: objectSchema({ name: stringSchema() }), responses: { "201": { schema: user } } },
207
+ });
208
+ router.get("/health", health, { openapi: false }); // never documented
209
+
210
+ // One-off document:
211
+ const document = generateOpenAPIDocument(router, {
212
+ info: { title: "Users API", version: "1.0.0" },
213
+ exclude: ["/internal/*"],
214
+ validate: true,
215
+ });
216
+
217
+ // Or serve it: GET /openapi.json and a Swagger UI page at GET /docs.
218
+ mountOpenAPI(router, {
219
+ info: { title: "Users API", version: "1.0.0" },
220
+ yamlPath: "/openapi.yaml", // optional
221
+ ui: { renderer: "redoc" }, // optional; any renderOpenAPIUI option
222
+ });
223
+ ```
224
+
225
+ - `:id` and `{id}` become `{id}`; every template slot is documented even when
226
+ nothing declares it. A regex-constrained `:id(\d+)` becomes a parameter
227
+ with that `pattern`, an optional `:id?` is documented as both paths, and a
228
+ wildcard `*rest` becomes a `{rest}` slot (`wildcards: "exclude"` drops such
229
+ routes instead).
230
+ - Left out: `all()` routes, `CONNECT`, routes with `openapi: false` or
231
+ `{ hidden: true }`, and anything matched by `exclude` (exact path,
232
+ `"/prefix/*"`, a `RegExp`, or a predicate). The router's automatic `HEAD`
233
+ and `OPTIONS` answers are not registered routes and never appear.
234
+ `undocumented: "exclude"` documents only routes that declare `openapi`.
235
+ - Router groups pass `openapi` defaults to their routes: tags are unioned,
236
+ everything else is overridden by the route.
237
+ - The document follows the router: a route added later is in the next
238
+ `generateOpenAPIDocument` call and the next request to a mounted
239
+ `/openapi.json`. `createRouterOpenAPI(router, options)` gives the
240
+ underlying `OpenAPIManager`, re-read only when the route table changed.
241
+ - Paths are configurable (`path`, `docsPath: false` to disable the page,
242
+ `ui.specUrl` when served under a prefix), and `middleware` protects the
243
+ documentation routes.
244
+
245
+ ## Mounting web-standard handlers
246
+
247
+ `mountFetchHandler` serves any `(request: Request) => Response | Promise<Response>`
248
+ handler — an `@zudojs/rpc` server, `@zudojs/api` operations, another
249
+ fetch-style app — under a path of a router or router group.
250
+
251
+ ```typescript
252
+ import { mountFetchHandler } from "@zudojs/http";
253
+ import { createRPCFetchHandler } from "@zudojs/rpc";
254
+
255
+ const unmount = mountFetchHandler(router, "/rpc", createRPCFetchHandler(rpcServer));
256
+ // { methods: ["POST"], stripPrefix: false, middleware: [auth] } are optional
257
+ ```
258
+
259
+ The handler sees the original method, query, headers (connection-scoped ones
260
+ removed) and body; the mount path is stripped from its URL by default and
261
+ passed as `x-forwarded-prefix`. Its `Response` is streamed back with status,
262
+ status text and headers intact, each `Set-Cookie` kept separate. The
263
+ request's `signal` aborts when the client disconnects. A handler that throws
264
+ or returns something other than a `Response` fails the request like any
265
+ route (a generic 500 unless the error carries a status). `toWebRequest(context)`
266
+ does the request conversion on its own.
267
+
268
+ The origin of the handler's `request.url` comes from the client's `Host`
269
+ header (or `X-Forwarded-Host` from a trusted proxy) unless you pin it with
270
+ `{ origin: "https://api.example.com" }`. Pin it whenever the handler builds
271
+ absolute URLs or compares `Origin` against its own.
272
+
273
+ Every Node request context now carries that signal too: `request.signal` and
274
+ the router's `ctx.signal` abort when the client goes away, and a streamed
275
+ response body stops being read.
276
+
277
+ ## Guards that refuse a request
278
+
279
+ A middleware answers a request itself — 401, 403, 404 — by returning a
280
+ `GuardResponse` from `@zudojs/middleware`. The router, `HttpMiddlewarePipeline`
281
+ and `RouteDispatcher` send it with its own status, headers and body; headers an
282
+ outer middleware already set (CORS, for instance) are kept.
283
+
284
+ ```typescript
285
+ import { createGuardResponse } from "@zudojs/middleware";
286
+ import { authorize } from "@zudojs/permissions";
287
+
288
+ router.delete("/posts/:id", deletePost, {
289
+ middleware: [
290
+ authorize(engine, "post:delete", { extractActor }), // 401 / 403
291
+ async (ctx, next) =>
292
+ ctx.request.getHeader("x-confirm")
293
+ ? next()
294
+ : createGuardResponse({ status: 400, body: { error: "Confirm first" } }),
295
+ ],
296
+ });
297
+ ```
298
+
299
+ A route middleware's return value used to be ignored unless it was an
300
+ `HttpResponseContext` or a web `Response`, so `authorize()` and the tenancy
301
+ middleware refused requests with `200`. Only the branded object is honoured: an ordinary
302
+ object with a `status` key keeps its old meaning.
303
+
85
304
  ## Features
86
305
 
87
306
  - Runtime-independent HTTP server abstraction
88
307
  - Request/response wrappers with full Web API compatibility
89
308
  - Middleware pipeline with error handling
90
309
  - Router with parameter extraction
310
+ - OpenAPI documents generated from the registered routes
311
+ - Mounting of web-standard fetch handlers
91
312
  - CORS, security headers, and content negotiation
92
313
  - HTTP client with interceptors
93
314
 
@@ -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