@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.
- package/README.md +221 -0
- package/dist/httpAdapter/node/httpNode.adapter.d.ts +2 -1
- package/dist/httpAdapter/node/httpNode.adapter.js +17 -2
- package/dist/httpAdapter/node/httpNode.request.js +6 -0
- package/dist/httpAdapter/node/httpNode.type.d.ts +14 -0
- package/dist/httpClient/httpClient.retry.d.ts +17 -12
- package/dist/httpClient/httpClient.retry.js +35 -10
- package/dist/httpClient/httpClient.type.d.ts +14 -0
- package/dist/httpErrors/httpError.base.js +2 -2
- package/dist/httpErrors/httpError.util.d.ts +8 -0
- package/dist/httpErrors/httpError.util.js +12 -0
- package/dist/httpFetchMount/httpFetchMount.core.d.ts +25 -0
- package/dist/httpFetchMount/httpFetchMount.core.js +84 -0
- package/dist/httpFetchMount/httpFetchMount.request.d.ts +21 -0
- package/dist/httpFetchMount/httpFetchMount.request.js +100 -0
- package/dist/httpFetchMount/httpFetchMount.type.d.ts +56 -0
- package/dist/httpFetchMount/httpFetchMount.type.js +5 -0
- package/dist/httpFetchMount/index.d.ts +11 -0
- package/dist/httpFetchMount/index.js +10 -0
- package/dist/httpMiddleware/builtin/rateLimit/httpMiddleware.rateLimit.d.ts +6 -3
- package/dist/httpMiddleware/builtin/rateLimit/httpMiddleware.rateLimit.js +34 -6
- package/dist/httpMiddleware/httpMiddleware.type.d.ts +9 -1
- package/dist/httpMiddleware/pipeline/httpPipeline.execution.js +22 -45
- package/dist/httpMiddleware/pipeline/httpPipeline.guardResponse.d.ts +36 -0
- package/dist/httpMiddleware/pipeline/httpPipeline.guardResponse.js +57 -0
- package/dist/httpMiddleware/pipeline/httpPipeline.helper.d.ts +2 -1
- package/dist/httpMiddleware/pipeline/httpPipeline.helper.js +10 -0
- package/dist/httpMiddleware/pipeline/index.d.ts +1 -0
- package/dist/httpMiddleware/pipeline/index.js +1 -0
- package/dist/httpOpenApi/httpOpenApi.document.d.ts +44 -0
- package/dist/httpOpenApi/httpOpenApi.document.js +61 -0
- package/dist/httpOpenApi/httpOpenApi.mount.d.ts +31 -0
- package/dist/httpOpenApi/httpOpenApi.mount.js +58 -0
- package/dist/httpOpenApi/httpOpenApi.type.d.ts +54 -0
- package/dist/httpOpenApi/httpOpenApi.type.js +5 -0
- package/dist/httpOpenApi/index.d.ts +13 -0
- package/dist/httpOpenApi/index.js +12 -0
- package/dist/httpOpenApi/routeTable/index.d.ts +11 -0
- package/dist/httpOpenApi/routeTable/index.js +11 -0
- package/dist/httpOpenApi/routeTable/routeTable.collect.d.ts +19 -0
- package/dist/httpOpenApi/routeTable/routeTable.collect.js +89 -0
- package/dist/httpOpenApi/routeTable/routeTable.merge.d.ts +15 -0
- package/dist/httpOpenApi/routeTable/routeTable.merge.js +37 -0
- package/dist/httpOpenApi/routeTable/routeTable.template.d.ts +30 -0
- package/dist/httpOpenApi/routeTable/routeTable.template.js +67 -0
- package/dist/httpRequest/httpRequest.context.d.ts +8 -0
- package/dist/httpRequest/httpRequest.context.js +12 -0
- package/dist/httpRequest/index.d.ts +1 -0
- package/dist/httpRequest/index.js +1 -0
- package/dist/httpRequest/requestId/httpRequest.requestId.d.ts +25 -0
- package/dist/httpRequest/requestId/httpRequest.requestId.js +34 -0
- package/dist/httpRequest/requestId/index.d.ts +7 -0
- package/dist/httpRequest/requestId/index.js +7 -0
- package/dist/httpResponse/httpResponse.writer.js +15 -0
- package/dist/httpRouter/core/factory/httpRoute.factory.base.d.ts +6 -3
- package/dist/httpRouter/core/factory/httpRoute.factory.base.js +24 -11
- package/dist/httpRouter/core/group/httpRouterGroup.core.js +13 -0
- package/dist/httpRouter/core/register/httpRouter.register.js +4 -1
- package/dist/httpRouter/core/types/httpRouter.type.d.ts +31 -1
- package/dist/httpRouter/core/util/httpRoute.util.d.ts +8 -0
- package/dist/httpRouter/core/util/httpRoute.util.js +16 -0
- package/dist/httpRouter/dispatch/httpRoute.dispatcher.js +20 -3
- package/dist/httpSecurity/httpSecurity.config.js +4 -1
- package/dist/httpServer/factory/httpServer.factory.d.ts +10 -9
- package/dist/httpServer/factory/httpServer.factory.js +8 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- 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 {
|
|
8
|
-
|
|
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
|
|
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
|
|
43
|
-
* indistinguishable from one that never arrived, so
|
|
44
|
-
* non-idempotent method duplicates its side effects.
|
|
45
|
-
* for exactly this and was honoured only on the
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
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
|