@dunx/http 2.3.0 → 2.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.
@@ -3,9 +3,9 @@
3
3
  * finishes every `onInit` before `listen()` binds, so a connection refused *is* "not
4
4
  * started yet" and a third endpoint would restate it.
5
5
  *
6
- * `@Public()` because a probe has no credentials, and `@ApiHidden()` because these
7
- * are for the orchestrator rather than for an API consumer. Both are the existing
8
- * route metadata; there is nothing health-specific about either.
6
+ * `@Public()` because a probe has no credentials. Both routes are documented, under
7
+ * the `Health` tag; `HealthModule.forRoot({ documented: false })` mounts
8
+ * {@link HiddenHealthController} instead.
9
9
  */
10
10
  export declare class HealthController {
11
11
  #private;
@@ -19,3 +19,15 @@ export declare class HealthController {
19
19
  /** Should the process receive traffic. Fails from the moment the drain starts. */
20
20
  ready(): Promise<Response>;
21
21
  }
22
+ /**
23
+ * The same two routes, kept out of the OpenAPI document.
24
+ *
25
+ * A subclass rather than a flag read when the module is registered, because
26
+ * `@ApiHidden()` writes to the class and a class is shared by every app in the
27
+ * process: `examples/full` boots a second container to demonstrate the websocket
28
+ * relay, so setting the flag at `forRoot` time would leak into the other one. The
29
+ * prefix and both handlers resolve through the prototype chain, so this is the
30
+ * whole implementation.
31
+ */
32
+ export declare class HiddenHealthController extends HealthController {
33
+ }
@@ -28,13 +28,14 @@ export declare class HealthModule {
28
28
  * });
29
29
  * ```
30
30
  *
31
- * `routes` is read from the init here too, but the controller is mounted from the
32
- * static shape rather than from the awaited options: a route table is folded into
33
- * one closure per route when the server binds, so it cannot wait on a factory.
34
- * Pass `routes: false` and mount your own if that matters.
31
+ * `routes` and `documented` are read from the init here too, but the controller is
32
+ * mounted from the static shape rather than from the awaited options: a route
33
+ * table is folded into one closure per route when the server binds, so it cannot
34
+ * wait on a factory. Pass `routes: false` and mount your own if that matters.
35
35
  */
36
36
  static forRootAsync<const D extends Deps>(config: FactoryProvider<HealthOptionsInit, D> & {
37
37
  readonly imports?: DynamicModule['imports'];
38
38
  readonly routes?: boolean;
39
+ readonly documented?: boolean;
39
40
  }): DynamicModule;
40
41
  }
@@ -28,6 +28,8 @@ export declare class HealthOptions {
28
28
  readonly timeoutMs: number;
29
29
  /** Mount `/health/live` and `/health/ready`. Default `true`. */
30
30
  readonly routes: boolean;
31
+ /** Include the two routes in the OpenAPI document. Default `true`. */
32
+ readonly documented: boolean;
31
33
  /** How long to fail readiness before the server stops accepting. Default `0`. */
32
34
  readonly drainDelayMs: number;
33
35
  constructor(init?: HealthOptionsInit);
@@ -40,6 +42,12 @@ export interface HealthOptionsInit {
40
42
  /** Per-indicator budget. Default `2000`. */
41
43
  readonly timeoutMs?: number;
42
44
  readonly routes?: boolean;
45
+ /**
46
+ * `false` mounts `HiddenHealthController`, so the probes are served and left out
47
+ * of the document. They are documented by default: the paths and the report are
48
+ * worth finding in the reference, and an orchestrator reads neither.
49
+ */
50
+ readonly documented?: boolean;
43
51
  /** Passed through to {@link ReadinessOptions}. */
44
52
  readonly drainDelayMs?: number;
45
53
  }
@@ -0,0 +1,14 @@
1
+ import type { JsonSchema } from '../route/schema.js';
2
+ /**
3
+ * `HealthReport`, as a JSON Schema literal.
4
+ *
5
+ * A hand-written schema rather than a zod one, because `RouteSchemas.response`
6
+ * accepts either and this package has no validator dependency to spend on
7
+ * documenting two routes. `$id` hoists it into `components/schemas` once, so both
8
+ * probes and both statuses reference the same definition.
9
+ *
10
+ * It restates the `HealthReport` and `HealthCheckReport` interfaces, which is the
11
+ * one duplicate here: a type is erased and a document needs the shape at runtime.
12
+ * `health.test.ts` asserts the two agree.
13
+ */
14
+ export declare const HEALTH_REPORT_SCHEMA: JsonSchema;
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { Controller, Delete, Get, Patch, Post, Put, } from './route/decorators.j
2
2
  export { discoverRoutes, joinPath, type DiscoveredRoute, } from './route/discover.js';
3
3
  export type { HttpMethod, RouteMeta, RoutePath } from './route/marker.js';
4
4
  export { ApiHidden, guardsOf, HIDDEN, meta, metaKey, metaOf, mergeMeta, Public, PUBLIC, Roles, ROLES, UNMATCHED, UseGuards, type MetaKey, type MetaRecord, } from './route/metadata.js';
5
- export type { InferOutput, Input, RouteInput, RouteSchemas, StandardSchemaIssue, StandardSchemaResult, StandardSchemaV1, } from './route/schema.js';
5
+ export type { InferOutput, Input, JsonSchema, RouteInput, RouteSchemas, StandardSchemaIssue, StandardSchemaResult, StandardSchemaV1, } from './route/schema.js';
6
6
  export { gatewaysOf, routesOf, type GatewayHandler, type GatewayNode, type RouteInputs, type RouteNode, } from './inspect.js';
7
7
  export { ClientAddress } from './server/client-address.js';
8
8
  export { buildContext, type RouteContext } from './server/context.js';
@@ -36,8 +36,9 @@ export { decodeRelay, DEFAULT_RELAY_CHANNEL, encodeRelay, type PubSubRelay, type
36
36
  export { buildGateways, buildRuntime, type GatewayRuntime, } from './ws/runtime.js';
37
37
  export type { Socket, SocketData, SocketErrorHandler, SocketOptions, } from './ws/socket.js';
38
38
  export { HealthIndicator, PingProbe, QueryProbe, type ProbeResult, type ProbeState, } from './health/contracts.js';
39
- export { HealthController } from './health/controller.js';
39
+ export { HealthController, HiddenHealthController, } from './health/controller.js';
40
40
  export { DatabaseIndicator, DiskIndicator, DiskOptions, MemoryIndicator, MemoryOptions, RedisIndicator, type DiskOptionsInit, type MemoryOptionsInit, } from './health/indicators.js';
41
41
  export { HealthModule } from './health/module.js';
42
+ export { HEALTH_REPORT_SCHEMA } from './health/report-schema.js';
42
43
  export { Readiness, ReadinessOptions } from './health/readiness.js';
43
44
  export { HealthOptions, HealthRegistry, type HealthCheckReport, type HealthOptionsInit, type HealthReport, } from './health/registry.js';
package/dist/index.js CHANGED
@@ -281,6 +281,7 @@ var buildContext = (route) => {
281
281
  handler: route.handlerName,
282
282
  method: route.method,
283
283
  path: route.path,
284
+ parsesBody: route.options?.body !== undefined,
284
285
  get: (key) => record.get(key.id)
285
286
  });
286
287
  };
@@ -970,6 +971,25 @@ import {
970
971
  RequestContext as RequestContext2
971
972
  } from "@dunx/core";
972
973
 
974
+ // src/server/raw-body.ts
975
+ var WANTED = Symbol.for("dunx.http.rawBody.wanted");
976
+ var TEXT = Symbol.for("dunx.http.rawBody.text");
977
+
978
+ class RawBody {
979
+ static want(req) {
980
+ req[WANTED] = true;
981
+ }
982
+ static wanted(req) {
983
+ return req[WANTED] === true;
984
+ }
985
+ static record(req, text) {
986
+ req[TEXT] = text;
987
+ }
988
+ static read(req) {
989
+ return req[TEXT];
990
+ }
991
+ }
992
+
973
993
  // src/server/request-id.ts
974
994
  var REQUEST_ID_HEADER = "x-request-id";
975
995
  var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -1051,14 +1071,14 @@ class RequestLoggingMiddleware {
1051
1071
  flow: "http",
1052
1072
  context: `${ctx.controller}.${ctx.handler}`
1053
1073
  };
1054
- return this.#correlate ? this.context.runWithContext(scope, () => this.#begin(req, url, mark, path, requestId, started, next, undefined)) : this.#begin(req, url, mark, path, requestId, started, next, scope);
1074
+ return this.#correlate ? this.context.runWithContext(scope, () => this.#begin(req, ctx, url, mark, path, requestId, started, next, undefined)) : this.#begin(req, ctx, url, mark, path, requestId, started, next, scope);
1055
1075
  }
1056
- #begin(req, url, mark, path, requestId, started, next, scope) {
1076
+ #begin(req, ctx, url, mark, path, requestId, started, next, scope) {
1057
1077
  const request = {};
1058
1078
  if (mark !== -1) {
1059
1079
  request["query"] = Object.fromEntries(new URLSearchParams(url.slice(mark + 1)));
1060
1080
  }
1061
- const body = this.#body(req);
1081
+ const body = this.#body(req, ctx);
1062
1082
  if (body === undefined) {
1063
1083
  request["userAgent"] = req.headers.get("user-agent");
1064
1084
  return this.#dispatch(req, path, requestId, started, request, next, scope);
@@ -1070,6 +1090,18 @@ class RequestLoggingMiddleware {
1070
1090
  return this.#dispatch(req, path, requestId, started, request, next, scope);
1071
1091
  });
1072
1092
  }
1093
+ #shared(req, request) {
1094
+ if (!this.#requestBody)
1095
+ return;
1096
+ if (request["body"] !== undefined)
1097
+ return;
1098
+ const text = RawBody.read(req);
1099
+ if (text === undefined)
1100
+ return;
1101
+ const value = parse(text, this.#limit);
1102
+ if (value !== undefined)
1103
+ request["body"] = value;
1104
+ }
1073
1105
  #correlated(req, ctx, path, next) {
1074
1106
  const requestId = RequestIds.assign(req);
1075
1107
  const stamp = (response) => {
@@ -1100,6 +1132,7 @@ class RequestLoggingMiddleware {
1100
1132
  });
1101
1133
  }
1102
1134
  #failed(req, path, started, request, error, scope) {
1135
+ this.#shared(req, request);
1103
1136
  const status = error instanceof HttpError ? error.status : HttpStatusCode.INTERNAL_SERVER_ERROR;
1104
1137
  const entry = {
1105
1138
  ...scope,
@@ -1116,6 +1149,7 @@ class RequestLoggingMiddleware {
1116
1149
  }
1117
1150
  }
1118
1151
  #succeeded(req, path, requestId, started, request, response, scope) {
1152
+ this.#shared(req, request);
1119
1153
  const body = this.#responseFields(response);
1120
1154
  if (body === undefined) {
1121
1155
  this.logger.info(`${req.method} ${path} ${response.status}`, {
@@ -1139,7 +1173,7 @@ class RequestLoggingMiddleware {
1139
1173
  return response;
1140
1174
  });
1141
1175
  }
1142
- #body(req) {
1176
+ #body(req, ctx) {
1143
1177
  if (!this.#requestBody)
1144
1178
  return;
1145
1179
  if (req.method === "GET" || req.method === "HEAD")
@@ -1147,6 +1181,10 @@ class RequestLoggingMiddleware {
1147
1181
  if (!(req.headers.get("content-type") ?? "").includes("application/json")) {
1148
1182
  return;
1149
1183
  }
1184
+ if (ctx.parsesBody) {
1185
+ RawBody.want(req);
1186
+ return;
1187
+ }
1150
1188
  return req.clone().text().then((text) => parse(text, this.#limit));
1151
1189
  }
1152
1190
  #responseFields(response) {
@@ -1230,7 +1268,11 @@ var bodyFill = (schema) => (draft) => {
1230
1268
  if (parse2 === undefined) {
1231
1269
  throw new HttpError(HttpStatusCode.UNSUPPORTED_MEDIA_TYPE, `Unsupported content type "${media}". Declared bodies accept ` + "application/json, application/x-www-form-urlencoded, multipart/form-data or text/*.");
1232
1270
  }
1233
- return parse2(draft.req).then((value) => fillWith(draft, "body", schema, value), (error) => {
1271
+ const read = parse2 === asJson && RawBody.wanted(draft.req) ? draft.req.text().then((text) => {
1272
+ RawBody.record(draft.req, text);
1273
+ return JSON.parse(text);
1274
+ }) : parse2(draft.req);
1275
+ return read.then((value) => fillWith(draft, "body", schema, value), (error) => {
1234
1276
  throw new HttpError(HttpStatusCode.BAD_REQUEST, `Malformed ${media} body`, { cause: error });
1235
1277
  });
1236
1278
  };
@@ -1309,6 +1351,7 @@ var unmatchedContext = (req, isPublic) => Object.freeze({
1309
1351
  handler: "(none)",
1310
1352
  method: req.method,
1311
1353
  path: new URL(req.url).pathname,
1354
+ parsesBody: false,
1312
1355
  get: (key) => {
1313
1356
  if (key.id === UNMATCHED.id)
1314
1357
  return true;
@@ -2128,6 +2171,50 @@ class QueryProbe {
2128
2171
  // src/health/controller.ts
2129
2172
  import { inject } from "@dunx/core";
2130
2173
 
2174
+ // src/health/report-schema.ts
2175
+ var state = {
2176
+ type: "string",
2177
+ enum: ["up", "down", "unknown"],
2178
+ description: "`unknown` is not `down`: a probe that timed out has told you nothing."
2179
+ };
2180
+ var HEALTH_REPORT_SCHEMA = Object.freeze({
2181
+ $id: "HealthReport",
2182
+ type: "object",
2183
+ description: "What the probe found. `up` answers 200 and anything else answers 503.",
2184
+ properties: {
2185
+ status: state,
2186
+ draining: {
2187
+ type: "boolean",
2188
+ description: "The process is shutting down, or something holds it out."
2189
+ },
2190
+ uptimeMs: {
2191
+ type: "integer",
2192
+ description: "Measured on a monotonic clock, so it never goes backwards."
2193
+ },
2194
+ checks: {
2195
+ type: "array",
2196
+ items: {
2197
+ type: "object",
2198
+ properties: {
2199
+ name: { type: "string" },
2200
+ state,
2201
+ critical: {
2202
+ type: "boolean",
2203
+ description: "A failure here sheds traffic. Memory and disk do not."
2204
+ },
2205
+ ms: { type: "integer", description: "How long the check took." },
2206
+ detail: {
2207
+ type: "string",
2208
+ description: "A latency, a version, or a failure message."
2209
+ }
2210
+ },
2211
+ required: ["name", "state", "critical", "ms"]
2212
+ }
2213
+ }
2214
+ },
2215
+ required: ["status", "draining", "uptimeMs", "checks"]
2216
+ });
2217
+
2131
2218
  // src/health/registry.ts
2132
2219
  var bounded = async (indicator, timeoutMs) => {
2133
2220
  let timer;
@@ -2162,12 +2249,14 @@ class HealthOptions {
2162
2249
  readiness;
2163
2250
  timeoutMs;
2164
2251
  routes;
2252
+ documented;
2165
2253
  drainDelayMs;
2166
2254
  constructor(init = {}) {
2167
2255
  this.liveness = init.liveness ?? [];
2168
2256
  this.readiness = init.readiness ?? [];
2169
2257
  this.timeoutMs = init.timeoutMs ?? 2000;
2170
2258
  this.routes = init.routes ?? true;
2259
+ this.documented = init.documented ?? true;
2171
2260
  this.drainDelayMs = Math.max(0, init.drainDelayMs ?? 0);
2172
2261
  }
2173
2262
  }
@@ -2230,18 +2319,20 @@ Object.defineProperty(HealthRegistry, Symbol.for("dunx.deps"), {
2230
2319
  });
2231
2320
 
2232
2321
  // src/health/controller.ts
2322
+ var probeResponses = {
2323
+ response: { 200: HEALTH_REPORT_SCHEMA, 503: HEALTH_REPORT_SCHEMA }
2324
+ };
2233
2325
  var answer = (report) => Response.json(report, { status: report.status === "up" ? 200 : 503 });
2234
2326
  var _dec = [
2235
- Controller("health"),
2236
- ApiHidden()
2327
+ Controller("health")
2237
2328
  ];
2238
2329
  var _dec2 = [
2239
2330
  Public(),
2240
- Get("/live")
2331
+ Get("/live", probeResponses)
2241
2332
  ];
2242
2333
  var _dec3 = [
2243
2334
  Public(),
2244
- Get("/ready")
2335
+ Get("/ready", probeResponses)
2245
2336
  ];
2246
2337
  var _health = new WeakMap;
2247
2338
  var _init = __decoratorStart(undefined);
@@ -2264,6 +2355,18 @@ HealthController = __decorateElement(_init, 0, "HealthController", _dec, HealthC
2264
2355
  __runInitializers(_init, 1, HealthController);
2265
2356
  __decoratorMetadata(_init, HealthController);
2266
2357
  let _HealthController = HealthController;
2358
+ var _dec = [
2359
+ ApiHidden()
2360
+ ];
2361
+ var _base = HealthController;
2362
+ var _init = __decoratorStart(_base);
2363
+
2364
+ class HiddenHealthController extends _base {
2365
+ }
2366
+ HiddenHealthController = __decorateElement(_init, 0, "HiddenHealthController", _dec, HiddenHealthController);
2367
+ __runInitializers(_init, 1, HiddenHealthController);
2368
+ __decoratorMetadata(_init, HiddenHealthController);
2369
+ let _HiddenHealthController = HiddenHealthController;
2267
2370
  // src/health/indicators.ts
2268
2371
  import { statfs } from "fs/promises";
2269
2372
  var ms = (started) => Math.round(performance.now() - started);
@@ -2430,6 +2533,7 @@ var wiring = (options) => [
2430
2533
  })
2431
2534
  ];
2432
2535
  var surface = [HealthOptions, HealthRegistry, Readiness];
2536
+ var controllerFor = (documented) => documented ? HealthController : HiddenHealthController;
2433
2537
  var _dec = [
2434
2538
  Module3({})
2435
2539
  ];
@@ -2440,7 +2544,7 @@ class HealthModule {
2440
2544
  const options = new HealthOptions(init);
2441
2545
  return {
2442
2546
  module: HealthModule,
2443
- ...options.routes ? { controllers: [HealthController] } : {},
2547
+ ...options.routes ? { controllers: [controllerFor(options.documented)] } : {},
2444
2548
  exports: surface,
2445
2549
  providers: wiring([provide4(HealthOptions, { useValue: options })])
2446
2550
  };
@@ -2449,7 +2553,7 @@ class HealthModule {
2449
2553
  return {
2450
2554
  module: HealthModule,
2451
2555
  ...config.imports ? { imports: config.imports } : {},
2452
- ...config.routes ?? true ? { controllers: [HealthController] } : {},
2556
+ ...config.routes ?? true ? { controllers: [controllerFor(config.documented ?? true)] } : {},
2453
2557
  exports: surface,
2454
2558
  providers: wiring([
2455
2559
  provide4(HealthOptions, {
@@ -2476,6 +2580,7 @@ export {
2476
2580
  ErrorFilter,
2477
2581
  Gateway,
2478
2582
  Get,
2583
+ HEALTH_REPORT_SCHEMA,
2479
2584
  HIDDEN,
2480
2585
  HandlerKind,
2481
2586
  HealthController,
@@ -2483,6 +2588,7 @@ export {
2483
2588
  HealthModule,
2484
2589
  HealthOptions,
2485
2590
  HealthRegistry,
2591
+ HiddenHealthController,
2486
2592
  HttpError,
2487
2593
  HttpFactory,
2488
2594
  HttpStatusCode,
@@ -2566,5 +2672,5 @@ export {
2566
2672
  withUpgradeRoutes
2567
2673
  };
2568
2674
 
2569
- //# debugId=30A653C61AAD581564756E2164756E21
2675
+ //# debugId=1A8278039B82BC0664756E2164756E21
2570
2676
  //# sourceMappingURL=index.js.map