@dunx/http 2.0.0 → 2.1.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 CHANGED
@@ -144,13 +144,17 @@ behind `~standard` changed, and reports what each one costs per request:
144
144
  | Valibot | 0.89 µs | built in |
145
145
  | zod | 0.94 µs | built in |
146
146
 
147
- **`await req.json()` on the same request costs 3.10 µs**, which is more than all of
148
- them put together. So validation is not where a slow endpoint's time goes, and
149
- swapping zod for a compiled validator buys about 7% of a small request - worth having
150
- if a profile points at it, not worth restructuring for. zod is what `@dunx/openapi`
151
- reads schemas from (via `z.toJSONSchema`), and it is the default for that reason
152
- rather than a performance one. Three fields, though: a deeply nested schema would very
153
- likely separate these engines much further.
147
+ **`await req.json()` on the same request costs 3.10 µs**, which is more than
148
+ all of them put together.
149
+
150
+ So validation is not where a slow endpoint's time goes, and swapping zod for a
151
+ compiled validator buys about 7% of a small request - worth having if a profile
152
+ points at it, though not worth restructuring for. zod is what `@dunx/openapi`
153
+ reads schemas from (via `z.toJSONSchema`), and it is the default for that
154
+ reason rather than a performance one.
155
+
156
+ Three fields, though: a deeply nested schema would very likely separate these
157
+ engines much further.
154
158
 
155
159
  Two of the five ship no `~standard` property. Bridging one is small enough to inline -
156
160
  this is the whole of it:
@@ -181,7 +185,7 @@ Full numbers, methodology and the ajv version:
181
185
  ## Route metadata and scoped middleware
182
186
 
183
187
  A decorator annotates a route; a guard reads the annotation back. Metadata on its
184
- own enforces nothing - which is why `@Roles` needs a guard that looks at it, and
188
+ own enforces nothing, so `@Roles` needs a guard that looks at it, and
185
189
  why one global guard plus `@Public()` is the combination worth learning.
186
190
 
187
191
  ```ts
@@ -317,7 +321,7 @@ Every request produces **one** structured entry, request and response together:
317
321
  }
318
322
  ```
319
323
 
320
- One entry, not two, is the point. The common arrangement logs on the way in from a middleware and on
324
+ One entry per request, never two. The common arrangement logs on the way in from a middleware and on
321
325
  the way out from an interceptor, because they are different classes and the
322
326
  interceptor cannot see what the middleware saw. Here they are the same closure, so
323
327
  there is no pair to correlate by `requestId` to find out how a call ended. A 4xx
@@ -371,12 +375,14 @@ HttpFactory.create(AppModule, {
371
375
  });
372
376
  ```
373
377
 
374
- **Even at its cheapest, a log line is not free.** `internal/bench` carries `dunx` and
375
- `dunx-logging` as separate subjects for exactly this reason: with logging off dunx
376
- runs at 81-100% of raw `Bun.serve` depending on the scenario, and with it on, 40-45%.
378
+ **Even at its cheapest, a log line is not free.** `internal/bench` carries
379
+ `dunx` and `dunx-logging` as separate subjects for exactly this reason: with
380
+ logging off dunx runs at 81-100% of raw `Bun.serve` depending on the scenario,
381
+ and with it on, 40-45%.
382
+
377
383
  The remainder is `JSON.stringify` plus a `write` per request inside an
378
- `AsyncLocalStorage` scope. If you need the last of the throughput, turn it off and
379
- sample at the edge instead - but know what you gave up.
384
+ `AsyncLocalStorage` scope. If you need the last of the throughput, turn it off
385
+ and sample at the edge instead - but know what you gave up.
380
386
 
381
387
  ### Unmatched paths are logged too
382
388
 
@@ -392,10 +398,11 @@ fallback runs only after it has decided nothing matched.
392
398
 
393
399
  A route with **no middleware and no CORS** is dispatched by a handler in which
394
400
  nothing is `async`. It returns a `Response` rather than a `Promise<Response>`
395
- wherever it has nothing to wait for - Bun accepts either. The general path awaits the
396
- input reader, the handler and the response coercion, and for most shapes those awaits
397
- are on values that were never thenable, each costing an async frame and a microtask
398
- tick for nothing.
401
+ wherever it has nothing to wait for - Bun accepts either.
402
+
403
+ The general path awaits the input reader, the handler and the response
404
+ coercion, and for most shapes those awaits are on values that were never
405
+ thenable, each costing an async frame and a microtask tick for nothing.
399
406
 
400
407
  | Route shape | What it costs |
401
408
  | ---------------------------------------- | ----------------------------------------- |
@@ -412,6 +419,84 @@ writing sync code.
412
419
  Adding middleware - including `requestLogging` - opts a route back into the async
413
420
  path, because middleware is `async` by contract.
414
421
 
422
+ ## Health checks and draining
423
+
424
+ Two routes, `/health/live` and `/health/ready`, and the drain phase that makes the
425
+ second one worth having.
426
+
427
+ ```ts
428
+ import {
429
+ DatabaseIndicator,
430
+ HealthModule,
431
+ MemoryIndicator,
432
+ MemoryOptions,
433
+ RedisIndicator,
434
+ } from '@dunx/http';
435
+
436
+ HealthModule.forRootAsync({
437
+ useFactory: (db: DbConnection, redis: RedisConnection) => ({
438
+ readiness: [new DatabaseIndicator(db), new RedisIndicator(redis)],
439
+ liveness: [
440
+ new MemoryIndicator(new MemoryOptions({ maxRssBytes: 512 * 1024 ** 2 })),
441
+ ],
442
+ drainDelayMs: 15_000,
443
+ }),
444
+ inject: [DbConnection, RedisConnection],
445
+ });
446
+ ```
447
+
448
+ The report is one list, so finding the unhappy check is one place to look:
449
+
450
+ ```json
451
+ {
452
+ "status": "up",
453
+ "draining": false,
454
+ "uptimeMs": 41233,
455
+ "checks": [{ "name": "database", "state": "up", "critical": true, "ms": 1 }]
456
+ }
457
+ ```
458
+
459
+ `up` is 200 and anything else is 503, which is what an orchestrator reads to stop
460
+ routing without restarting.
461
+
462
+ **Readiness fails before the port closes**, and that ordering is the feature.
463
+ `Readiness` implements `@dunx/core`'s `OnDrain`, which runs while the server is
464
+ still accepting. Every `onShutdown` hook runs after `server.stop()` has resolved, so
465
+ a probe answering from one answers on a closed socket.
466
+
467
+ `drainDelayMs` keeps readiness failing for a few probe intervals before the socket
468
+ goes, because a load balancer notices on its own schedule.
469
+
470
+ Liveness deliberately keeps passing while draining. A pod that is shutting down does
471
+ not need restarting, and reporting `down` there invites a SIGKILL mid-drain.
472
+
473
+ **Three states, not two.** A check that throws is `down`; one that outruns
474
+ `timeoutMs` is `unknown`, because a probe that did not answer has told you nothing.
475
+ `unknown` on a critical check fails readiness and on a non-critical one it does not.
476
+
477
+ **`critical: false` reports without shedding traffic.** `MemoryIndicator` and
478
+ `DiskIndicator` ship that way: a disk at 91% is worth seeing, and pulling the pod
479
+ out of rotation does not help, since no other pod's disk is emptier. A memory
480
+ ceiling belongs on liveness, where the orchestrator restarts the process.
481
+
482
+ `Readiness` is injectable, so a handler can `hold('migrating')` and `release()` to
483
+ take the pod out of rotation without shutting down. A `release()` does not undo a
484
+ shutdown.
485
+
486
+ There is no startup probe. The port already answers that: `create()` finishes every
487
+ `onInit` before `listen()` binds, so connection refused *is* "not started yet".
488
+
489
+ `MemoryIndicator` uses `process.memoryUsage()` at 5.96 us. `jsc.heapStats()` walks
490
+ every live object at 2.2 ms and up, and `Bun.generateHeapSnapshot()` is hundreds of
491
+ milliseconds, so neither belongs on a path scraped every two seconds.
492
+
493
+ Bun ships no disk API, so `DiskIndicator` is `node:fs`'s **async** `statfs` at 167 us
494
+ rather than `statfsSync` at 1.85 us. A stalled network mount blocks the loop for as
495
+ long as it stalls, and a health check is what gets called while one is stalling.
496
+
497
+ `routes: false` binds everything and mounts nothing, for an app answering on its own
498
+ paths.
499
+
415
500
  ## App-level configuration
416
501
 
417
502
  `create()` boots the container and discovers routes; `listen()` is what builds the
@@ -448,25 +533,26 @@ could only ever be a silent no-op - the failure mode worth trading for an error.
448
533
  - **Port**: the `listen(port)` argument, else `HttpOptions.port`, else `3000`.
449
534
  - **Error mapper**: `HttpOptions.onError`; there is no imperative equivalent.
450
535
  - **Overrides**: `HttpOptions.overrides` is core's `AppOptions.overrides`, passed
451
- straight through - bindings replaced in place, which is what `@dunx/testing`'s
536
+ straight through, bindings replaced in place, as `@dunx/testing`'s
452
537
  `createTestServer` uses.
453
538
  - **Repeated calls**: `setGlobalPrefix`, `set` and `enableCors` all replace, so the
454
539
  last call wins. `use()` appends.
455
540
  - **Collisions**: rejected at `create()`, and re-checked at `listen()` against the
456
541
  final prefixed paths. A uniform prefix cannot introduce a collision the
457
- unprefixed paths did not already have, which is why the early check is complete.
542
+ unprefixed paths did not already have, so the early check is complete.
458
543
 
459
544
  ### CORS and preflight
460
545
 
461
- `Bun.serve({ routes })` answers a method miss with `404`, so a preflight can never
462
- be inferred - `enableCors()` mounts an explicit `OPTIONS` handler on every path,
463
- built at boot from the methods that path actually declares. `origin` takes a
464
- string, a list, or a predicate; anything not allowed gets **no** CORS headers at
465
- all, which is what makes the browser block it. `'*'` is the default, and because a
466
- browser rejects `*` alongside credentials, `credentials: true` reflects the
467
- caller's origin instead. `allowedHeaders` defaults to echoing
468
- `Access-Control-Request-Headers`. Headers are applied outside the error mapper, so
469
- a mapped `500` still carries them.
546
+ `Bun.serve({ routes })` answers a method miss with `404`, so a preflight can
547
+ never be inferred - `enableCors()` mounts an explicit `OPTIONS` handler on
548
+ every path, built at boot from the methods that path actually declares.
549
+ `origin` takes a string, a list, or a predicate; anything not allowed gets
550
+ **no** CORS headers at all, and the absence is what makes the browser block it.
551
+
552
+ `'*'` is the default, and because a browser rejects `*` alongside credentials,
553
+ `credentials: true` reflects the caller's origin instead. `allowedHeaders`
554
+ defaults to echoing `Access-Control-Request-Headers`. Headers are applied
555
+ outside the error mapper, so a mapped `500` still carries them.
470
556
 
471
557
  ### Client IP
472
558
 
@@ -611,20 +697,21 @@ smallest one that works:
611
697
  { "event": "chat.say", "data": { "room": "general", "text": "hi" } }
612
698
  ```
613
699
 
614
- It is **opt-in**: a frame is only parsed for a gateway that declares at least one
615
- `@OnMessage(event)` handler. A gateway with only a raw `@OnMessage()` never sees
616
- JSON it did not ask for. Binary frames, invalid JSON, a non-object, a missing
617
- `event`, and an event no handler claims all fall through to the raw handler - and
618
- are ignored if there is none. Nothing is ever replied to the sender that a handler
619
- did not return.
700
+ It is **opt-in**: a frame is only parsed for a gateway that declares at least
701
+ one `@OnMessage(event)` handler. A gateway with only a raw `@OnMessage()` never
702
+ sees JSON it did not ask for.
703
+
704
+ Binary frames, invalid JSON, a non-object, a missing `event`, and an event no
705
+ handler claims all fall through to the raw handler - and are ignored if there
706
+ is none. Nothing is ever replied to the sender that a handler did not return.
620
707
 
621
708
  `encode(event, data)` and `decode(frame)` are exported, so a client can share them.
622
- A handler's payload parameter type is what you expect to receive, not a runtime
709
+ A handler's payload parameter type states what you expect to receive, with no runtime
623
710
  guarantee: the frame's `data` is handed over as it arrived.
624
711
 
625
712
  ### Pub/sub
626
713
 
627
- Topics live in Bun, not in a JavaScript map. A socket joins one with
714
+ Topics live in Bun rather than in a JavaScript map. A socket joins one with
628
715
  `socket.subscribe(topic)` and leaves with `socket.unsubscribe(topic)`; both are
629
716
  native methods on the socket you already hold.
630
717
 
@@ -675,12 +762,14 @@ the default logs.
675
762
 
676
763
  ### Shutdown with a live socket
677
764
 
678
- Measured: a graceful `server.stop()` waits for open connections, and a WebSocket
679
- does not close on its own - so it **never resolves** while a socket is open. An app
680
- with at least one gateway therefore force-stops (`stop(true)`) in `shutdown()`, and
681
- those clients see a `1006` close. An app with no gateways still stops gracefully.
682
- Bun also delivers an empty close `reason` to `@OnClose` once a socket has exchanged
683
- frames, whatever the client passed; the `code` is reliable.
765
+ Measured: a graceful `server.stop()` waits for open connections, and a
766
+ WebSocket does not close on its own - so it **never resolves** while a socket
767
+ is open. An app with at least one gateway therefore force-stops (`stop(true)`)
768
+ in `shutdown()`, and those clients see a `1006` close. An app with no gateways
769
+ still stops gracefully.
770
+
771
+ Bun also delivers an empty close `reason` to `@OnClose` once a socket has
772
+ exchanged frames, whatever the client passed; the `code` is reliable.
684
773
 
685
774
  ### Multi-node fan-out
686
775
 
@@ -744,14 +833,14 @@ subscriptions on one channel is the other way to deliver everything twice.
744
833
  cross nodes goes through `PubSub`. `subscriberCount` is local too - Bun cannot count
745
834
  another node's sockets.
746
835
 
747
- `maxRetries` on `RedisRelay` defaults to `0`, and that is deliberate: a
836
+ `maxRetries` on `RedisRelay` defaults to `0`: a
748
837
  `Bun.RedisClient` that never connects keeps a retry timer alive past `close()` and
749
838
  the process then never exits. Raise it when Redis is a hard requirement and you want
750
839
  Bun's reconnection.
751
840
 
752
841
  ## Status codes
753
842
 
754
- `HttpStatusCode` is a frozen object, not an `enum` - one name serving as both the
843
+ `HttpStatusCode` is a frozen object rather than an `enum`, one name serving as both the
755
844
  value and the type, so it reads like an enum and erases like a constant:
756
845
 
757
846
  ```ts
@@ -806,7 +895,7 @@ export class Rates {
806
895
  ```
807
896
 
808
897
  `fetch` and nothing else underneath: it is a Web standard Bun implements natively,
809
- which is why `axios` and `node-fetch` are banned repo-wide and why there is no
898
+ so `axios` and `node-fetch` are banned repo-wide and there is no
810
899
  client dependency to justify. What the service adds is the part every caller
811
900
  otherwise rewrites slightly differently.
812
901
 
@@ -818,7 +907,7 @@ otherwise rewrites slightly differently.
818
907
  | **URLs** | `buildUrl` and `interpolate` from `@arkv/shared`, so `{param}` and query building are not rewritten |
819
908
  | **Tracing** | The inbound request id is forwarded as `x-request-id`, so one trace spans both services |
820
909
  | **Bun-only** | `proxy`, `tls`, `unix`, `decompress` passed straight through to `fetch` |
821
- | **SSE** | `streamSse` yields each `data:` payload; deliberately never retried |
910
+ | **SSE** | `streamSse` yields each `data:` payload; never retried |
822
911
 
823
912
  ### A failure is not your status
824
913
 
@@ -18,6 +18,7 @@ var __defNormalProp = (obj, key, value) => (key in obj) ? __defProp(obj, key, {
18
18
  var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError("Cannot " + msg);
19
19
  var __privateIn = (member, obj) => Object(obj) !== obj ? __typeError('Cannot use the "in" operator on this value') : member.has(obj);
20
20
  var __privateGet = (obj, member, getter) => (__accessCheck(obj, member, "read from private field"), getter ? getter.call(obj) : member.get(obj));
21
+ var __privateAdd = (obj, member, value) => member.has(obj) ? __typeError("Cannot add the same private member more than once") : member instanceof WeakSet ? member.add(obj) : member.set(obj, value);
21
22
  var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "write to private field"), setter ? setter.call(obj, value) : member.set(obj, value), value);
22
23
  var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "access private method"), method);
23
24
  var __decoratorStart = (base) => [, , , __create(base?.[__knownSymbol("metadata")] ?? null)];
@@ -104,7 +105,7 @@ var HttpStatusCode = Object.freeze({
104
105
  GATEWAY_TIMEOUT: 504
105
106
  });
106
107
 
107
- export { __decoratorStart, __decoratorMetadata, __runInitializers, __decorateElement, HttpStatusCode };
108
+ export { __privateGet, __privateAdd, __decoratorStart, __decoratorMetadata, __runInitializers, __decorateElement, HttpStatusCode };
108
109
 
109
- //# debugId=83F76F267D9DFA2264756E2164756E21
110
- //# sourceMappingURL=chunk-5z96f3gr.js.map
110
+ //# debugId=463DAE47FB741BC964756E2164756E21
111
+ //# sourceMappingURL=chunk-sz4pvqxy.js.map
@@ -4,7 +4,7 @@
4
4
  "sourcesContent": [
5
5
  "/**\n * Frozen object plus an indexed-access union, not an `enum`. An enum emits a\n * runtime object that no other syntax can produce, which is why the repo bans it -\n * see CLAUDE.md. This gives the same `HttpStatusCode.NOT_FOUND` ergonomics, a\n * narrower type, and erases cleanly.\n */\nexport const HttpStatusCode = Object.freeze({\n OK: 200,\n CREATED: 201,\n ACCEPTED: 202,\n NO_CONTENT: 204,\n MOVED_PERMANENTLY: 301,\n FOUND: 302,\n NOT_MODIFIED: 304,\n TEMPORARY_REDIRECT: 307,\n PERMANENT_REDIRECT: 308,\n BAD_REQUEST: 400,\n UNAUTHORIZED: 401,\n PAYMENT_REQUIRED: 402,\n FORBIDDEN: 403,\n NOT_FOUND: 404,\n METHOD_NOT_ALLOWED: 405,\n NOT_ACCEPTABLE: 406,\n REQUEST_TIMEOUT: 408,\n CONFLICT: 409,\n GONE: 410,\n PRECONDITION_FAILED: 412,\n PAYLOAD_TOO_LARGE: 413,\n URI_TOO_LONG: 414,\n UNSUPPORTED_MEDIA_TYPE: 415,\n IM_A_TEAPOT: 418,\n UNPROCESSABLE_ENTITY: 422,\n TOO_MANY_REQUESTS: 429,\n INTERNAL_SERVER_ERROR: 500,\n NOT_IMPLEMENTED: 501,\n BAD_GATEWAY: 502,\n SERVICE_UNAVAILABLE: 503,\n GATEWAY_TIMEOUT: 504,\n} as const);\n\n/** The status numbers: `200 | 201 | ...`. */\nexport type HttpStatusCode =\n (typeof HttpStatusCode)[keyof typeof HttpStatusCode];\n\n/** The names: `'OK' | 'CREATED' | ...`. */\nexport type HttpStatusName = keyof typeof HttpStatusCode;\n"
6
6
  ],
7
- "mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAMO,IAAM,iBAAiB,OAAO,OAAO;AAAA,EAC1C,IAAI;AAAA,EACJ,SAAS;AAAA,EACT,UAAU;AAAA,EACV,YAAY;AAAA,EACZ,mBAAmB;AAAA,EACnB,OAAO;AAAA,EACP,cAAc;AAAA,EACd,oBAAoB;AAAA,EACpB,oBAAoB;AAAA,EACpB,aAAa;AAAA,EACb,cAAc;AAAA,EACd,kBAAkB;AAAA,EAClB,WAAW;AAAA,EACX,WAAW;AAAA,EACX,oBAAoB;AAAA,EACpB,gBAAgB;AAAA,EAChB,iBAAiB;AAAA,EACjB,UAAU;AAAA,EACV,MAAM;AAAA,EACN,qBAAqB;AAAA,EACrB,mBAAmB;AAAA,EACnB,cAAc;AAAA,EACd,wBAAwB;AAAA,EACxB,aAAa;AAAA,EACb,sBAAsB;AAAA,EACtB,mBAAmB;AAAA,EACnB,uBAAuB;AAAA,EACvB,iBAAiB;AAAA,EACjB,aAAa;AAAA,EACb,qBAAqB;AAAA,EACrB,iBAAiB;AACnB,CAAU;",
8
- "debugId": "83F76F267D9DFA2264756E2164756E21",
7
+ "mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAMO,IAAM,iBAAiB,OAAO,OAAO;AAAA,EAC1C,IAAI;AAAA,EACJ,SAAS;AAAA,EACT,UAAU;AAAA,EACV,YAAY;AAAA,EACZ,mBAAmB;AAAA,EACnB,OAAO;AAAA,EACP,cAAc;AAAA,EACd,oBAAoB;AAAA,EACpB,oBAAoB;AAAA,EACpB,aAAa;AAAA,EACb,cAAc;AAAA,EACd,kBAAkB;AAAA,EAClB,WAAW;AAAA,EACX,WAAW;AAAA,EACX,oBAAoB;AAAA,EACpB,gBAAgB;AAAA,EAChB,iBAAiB;AAAA,EACjB,UAAU;AAAA,EACV,MAAM;AAAA,EACN,qBAAqB;AAAA,EACrB,mBAAmB;AAAA,EACnB,cAAc;AAAA,EACd,wBAAwB;AAAA,EACxB,aAAa;AAAA,EACb,sBAAsB;AAAA,EACtB,mBAAmB;AAAA,EACnB,uBAAuB;AAAA,EACvB,iBAAiB;AAAA,EACjB,aAAa;AAAA,EACb,qBAAqB;AAAA,EACrB,iBAAiB;AACnB,CAAU;",
8
+ "debugId": "463DAE47FB741BC964756E2164756E21",
9
9
  "names": []
10
10
  }
package/dist/client.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // @bun
2
2
  import {
3
3
  HttpStatusCode
4
- } from "./chunk-5z96f3gr.js";
4
+ } from "./chunk-sz4pvqxy.js";
5
5
 
6
6
  // src/client/errors.ts
7
7
  import { AppError } from "@dunx/core";
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The state a probe reports.
3
+ *
4
+ * `unknown` is not `down`. A probe that timed out has told you nothing, and the
5
+ * difference decides whether traffic is shed: `unknown` on a critical check fails
6
+ * readiness, on a non-critical one it does not. `@dunx/dashboard` had this rule
7
+ * first and re-exports these two from here.
8
+ */
9
+ export type ProbeState = 'up' | 'down' | 'unknown';
10
+ export interface ProbeResult {
11
+ readonly state: ProbeState;
12
+ /** One line for the operator: a latency, a version, a failure message. */
13
+ readonly detail?: string;
14
+ }
15
+ /**
16
+ * One thing worth checking.
17
+ *
18
+ * An abstract class rather than an interface because it is an injection site: the
19
+ * container needs a runtime value to record, and an interface there is a boot
20
+ * error. Subclass it, or hand `HealthOptions` any object with the three members.
21
+ */
22
+ export declare abstract class HealthIndicator {
23
+ abstract readonly name: string;
24
+ /**
25
+ * Whether a failure here should shed traffic. `true` by default.
26
+ *
27
+ * `false` reports without gating readiness, which is what memory and disk want: a
28
+ * disk at 91 percent is worth seeing and is not worth pulling the pod out of
29
+ * rotation for, since no other pod is any emptier.
30
+ */
31
+ readonly critical: boolean;
32
+ abstract check(): Promise<ProbeResult> | ProbeResult;
33
+ }
34
+ /**
35
+ * Enough of a client to answer "is it up". `RedisConnection` from
36
+ * `@dunx/infra/redis` satisfies it as written, and so does a bare
37
+ * `Bun.RedisClient`.
38
+ *
39
+ * Narrower than `@dunx/dashboard`'s `RedisProbe` on purpose, and they are not
40
+ * merged. That one also needs `connected` and `send`, because it renders an `INFO`
41
+ * panel; this needs a round trip and nothing else. Sharing one contract would
42
+ * oblige an app to hand a health check two members it never calls.
43
+ */
44
+ export declare abstract class PingProbe {
45
+ abstract ping(message?: string): Promise<string>;
46
+ }
47
+ /**
48
+ * A database that can be asked for a round trip. `DbConnection` from
49
+ * `@dunx/infra/db` satisfies it once it grows `ping()`.
50
+ *
51
+ * Separate from {@link PingProbe} because the return types differ: a Redis `PING`
52
+ * answers `PONG` and a database round trip answers nothing worth reading.
53
+ */
54
+ export declare abstract class QueryProbe {
55
+ abstract ping(): Promise<void>;
56
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Two routes, not three. A startup probe is already answered by the port: `create()`
3
+ * finishes every `onInit` before `listen()` binds, so a connection refused *is* "not
4
+ * started yet" and a third endpoint would restate it.
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.
9
+ */
10
+ export declare class HealthController {
11
+ #private;
12
+ /**
13
+ * Is the process working. A failure here means restart me.
14
+ *
15
+ * Draining deliberately does not fail this: a pod shutting down does not need
16
+ * killing, and reporting `down` invites a SIGKILL mid-drain.
17
+ */
18
+ live(): Promise<Response>;
19
+ /** Should the process receive traffic. Fails from the moment the drain starts. */
20
+ ready(): Promise<Response>;
21
+ }
@@ -0,0 +1,71 @@
1
+ import { HealthIndicator, type PingProbe, type ProbeResult, type QueryProbe } from './contracts.js';
2
+ /** Redis is up if it answers `PING`. */
3
+ export declare class RedisIndicator extends HealthIndicator {
4
+ private readonly redis;
5
+ readonly name = "redis";
6
+ constructor(redis: PingProbe);
7
+ check(): Promise<ProbeResult>;
8
+ }
9
+ /** The database is up if a round trip completes. */
10
+ export declare class DatabaseIndicator extends HealthIndicator {
11
+ private readonly db;
12
+ readonly name = "database";
13
+ constructor(db: QueryProbe);
14
+ check(): Promise<ProbeResult>;
15
+ }
16
+ export interface MemoryOptionsInit {
17
+ /** Report `down` above this resident set size. */
18
+ readonly maxRssBytes: number;
19
+ }
20
+ export declare class MemoryOptions {
21
+ readonly maxRssBytes: number;
22
+ constructor(init: MemoryOptionsInit);
23
+ }
24
+ /**
25
+ * Resident set size against a ceiling.
26
+ *
27
+ * `process.memoryUsage()` costs 5.96 us, which is what makes it safe on an endpoint
28
+ * scraped every two seconds. The alternatives were measured and rejected:
29
+ * `jsc.heapStats()` walks every live object at 2.2 ms and up,
30
+ * `v8.getHeapStatistics()` is 1 to 7.6 ms, and `Bun.generateHeapSnapshot()` is
31
+ * hundreds of milliseconds and megabytes. None belongs on this path.
32
+ *
33
+ * Not critical: a process near its ceiling is worth seeing, and shedding traffic
34
+ * from it does not make it use less memory. Liveness is where a ceiling belongs, so
35
+ * the orchestrator restarts it.
36
+ */
37
+ export declare class MemoryIndicator extends HealthIndicator {
38
+ private readonly options;
39
+ readonly name = "memory";
40
+ readonly critical = false;
41
+ constructor(options: MemoryOptions);
42
+ check(): ProbeResult;
43
+ }
44
+ export interface DiskOptionsInit {
45
+ /** Any path on the filesystem to measure. */
46
+ readonly path: string;
47
+ /** Report `down` above this used fraction. `0.9` is 90 percent. */
48
+ readonly maxUsedFraction: number;
49
+ }
50
+ export declare class DiskOptions {
51
+ readonly path: string;
52
+ readonly maxUsedFraction: number;
53
+ constructor(init: DiskOptionsInit);
54
+ }
55
+ /**
56
+ * How full a filesystem is.
57
+ *
58
+ * Bun ships no disk API, so this is `node:fs`. The **async** `statfs` at 167 us,
59
+ * not `statfsSync` at 1.85 us, and the slower one is the right call: a stalled
60
+ * network mount blocks the event loop for as long as it stalls, and a health check
61
+ * is exactly what gets called while a mount is stalling.
62
+ *
63
+ * Not critical, for the same reason as memory: no other pod's disk is any emptier.
64
+ */
65
+ export declare class DiskIndicator extends HealthIndicator {
66
+ private readonly options;
67
+ readonly name = "disk";
68
+ readonly critical = false;
69
+ constructor(options: DiskOptions);
70
+ check(): Promise<ProbeResult>;
71
+ }
@@ -0,0 +1,40 @@
1
+ import { type Deps, type DynamicModule, type FactoryProvider } from '@dunx/core';
2
+ import { type HealthOptionsInit } from './registry.js';
3
+ /**
4
+ * Liveness and readiness, and the drain that makes readiness worth having.
5
+ *
6
+ * `Readiness` implements `OnDrain`, so readiness starts failing **before** the
7
+ * server stops accepting. Without that phase the flip was unexpressible: every
8
+ * `onShutdown` hook runs after `server.stop()` has resolved, so a probe answering
9
+ * from there answers on a closed port and the load balancer is still routing when
10
+ * the socket goes away.
11
+ *
12
+ * `routes: false` binds everything and mounts nothing, for an app that would rather
13
+ * answer on its own paths or from a sidecar.
14
+ */
15
+ export declare class HealthModule {
16
+ static forRoot(init?: HealthOptionsInit): DynamicModule;
17
+ /**
18
+ * The same, with the indicators built from the container, which is the usual case:
19
+ * a database indicator needs the connection.
20
+ *
21
+ * ```ts
22
+ * HealthModule.forRootAsync({
23
+ * useFactory: (db: DbConnection, redis: RedisConnection) => ({
24
+ * readiness: [new DatabaseIndicator(db), new RedisIndicator(redis)],
25
+ * drainDelayMs: 15_000,
26
+ * }),
27
+ * inject: [DbConnection, RedisConnection],
28
+ * });
29
+ * ```
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.
35
+ */
36
+ static forRootAsync<const D extends Deps>(config: FactoryProvider<HealthOptionsInit, D> & {
37
+ readonly imports?: DynamicModule['imports'];
38
+ readonly routes?: boolean;
39
+ }): DynamicModule;
40
+ }
@@ -0,0 +1,51 @@
1
+ import type { OnDrain } from '@dunx/core';
2
+ export interface ReadinessOptionsInit {
3
+ /**
4
+ * How long to keep failing readiness after the drain starts, before the server
5
+ * stops accepting. Default `0`.
6
+ *
7
+ * The window exists because a load balancer notices a failing probe on its own
8
+ * schedule: with a 2 second probe interval and a 3 failure threshold, traffic can
9
+ * arrive for 6 seconds after the pod has decided to go. Set it to a few probe
10
+ * intervals and the pod stops receiving before the socket closes, which is the
11
+ * whole reason this phase runs before `server.stop()`.
12
+ */
13
+ readonly drainDelayMs?: number;
14
+ }
15
+ /** A class, so it is a recordable constructor parameter type. */
16
+ export declare class ReadinessOptions {
17
+ readonly drainDelayMs: number;
18
+ constructor(init?: ReadinessOptionsInit);
19
+ }
20
+ /**
21
+ * Whether this process wants traffic.
22
+ *
23
+ * Injectable, so a handler can pull the pod out of rotation for a migration and put
24
+ * it back. `hold` and `release` are for that; `onDrain` is for shutdown and does not
25
+ * release.
26
+ *
27
+ * This is what `OnDrain` was added to `@dunx/core` for. `HttpApplication.shutdown()`
28
+ * stopped the server before running any hook, so a readiness flip in `onShutdown`
29
+ * answered on a closed port, which is the wrong order: a load balancer has to see
30
+ * the probe fail while the port is still open.
31
+ */
32
+ export declare class Readiness implements OnDrain {
33
+ #private;
34
+ private readonly options;
35
+ constructor(options: ReadinessOptions);
36
+ /** `true` once shutdown has begun, or while something holds the pod out. */
37
+ get draining(): boolean;
38
+ /** Why readiness is failing, for the report. */
39
+ get reason(): string | undefined;
40
+ /** Fail readiness until `release()`. Idempotent; the last reason wins. */
41
+ hold(reason: string): void;
42
+ release(): void;
43
+ /**
44
+ * Fails readiness, then waits, all before the server stops accepting.
45
+ *
46
+ * The wait is here rather than in the application because this is the thing that
47
+ * knows why it is waiting. `App.drain()` runs every hook under one `Promise.all`,
48
+ * so this window overlaps a queue worker's own drain instead of being added to it.
49
+ */
50
+ onDrain(): Promise<void>;
51
+ }
@@ -0,0 +1,64 @@
1
+ import type { HealthIndicator, ProbeState } from './contracts.js';
2
+ import type { Readiness } from './readiness.js';
3
+ export interface HealthCheckReport {
4
+ readonly name: string;
5
+ readonly state: ProbeState;
6
+ readonly critical: boolean;
7
+ /** How long the check took, rounded to a millisecond. */
8
+ readonly ms: number;
9
+ readonly detail?: string;
10
+ }
11
+ /**
12
+ * The wire format, declared here and imported by anything that renders it.
13
+ *
14
+ * One list rather than terminus' four fields holding the same data partitioned
15
+ * three ways: a reader wants to know which check is unhappy, and partitioning by
16
+ * outcome means looking in three places to find out.
17
+ */
18
+ export interface HealthReport {
19
+ readonly status: ProbeState;
20
+ readonly draining: boolean;
21
+ readonly uptimeMs: number;
22
+ readonly checks: readonly HealthCheckReport[];
23
+ }
24
+ export declare class HealthOptions {
25
+ readonly liveness: readonly HealthIndicator[];
26
+ readonly readiness: readonly HealthIndicator[];
27
+ readonly timeoutMs: number;
28
+ /** Mount `/health/live` and `/health/ready`. Default `true`. */
29
+ readonly routes: boolean;
30
+ /** How long to fail readiness before the server stops accepting. Default `0`. */
31
+ readonly drainDelayMs: number;
32
+ constructor(init?: HealthOptionsInit);
33
+ }
34
+ export interface HealthOptionsInit {
35
+ /** Checked by `/health/live`: is this process still working. */
36
+ readonly liveness?: readonly HealthIndicator[];
37
+ /** Checked by `/health/ready`: should it receive traffic. */
38
+ readonly readiness?: readonly HealthIndicator[];
39
+ /** Per-indicator budget. Default `2000`. */
40
+ readonly timeoutMs?: number;
41
+ readonly routes?: boolean;
42
+ /** Passed through to {@link ReadinessOptions}. */
43
+ readonly drainDelayMs?: number;
44
+ }
45
+ /** Runs the indicators and shapes the report. Never throws. */
46
+ export declare class HealthRegistry {
47
+ #private;
48
+ private readonly options;
49
+ private readonly readiness_;
50
+ constructor(options: HealthOptions, readiness_: Readiness);
51
+ /**
52
+ * Concurrently, each bounded by `timeoutMs`, so the report costs the slowest
53
+ * check rather than their sum.
54
+ */
55
+ report(indicators: readonly HealthIndicator[]): Promise<HealthReport>;
56
+ /**
57
+ * Is the process working. **Draining does not fail liveness**: a pod that is
58
+ * shutting down is not a pod that needs killing, and reporting `down` here invites
59
+ * the orchestrator to SIGKILL it mid-drain.
60
+ */
61
+ liveness(): Promise<HealthReport>;
62
+ /** Should the process receive traffic. Draining fails it, before anything runs. */
63
+ readiness(): Promise<HealthReport>;
64
+ }
package/dist/index.d.ts CHANGED
@@ -27,3 +27,9 @@ export { defaultRelayUrl, RedisRelay, type RedisRelayOptions, } from './ws/redis
27
27
  export { decodeRelay, DEFAULT_RELAY_CHANNEL, encodeRelay, type PubSubRelay, type RelayFrame, type RelayOptions, type RelayPhase, } from './ws/relay.js';
28
28
  export { buildGateways, buildRuntime, type GatewayRuntime, } from './ws/runtime.js';
29
29
  export type { Socket, SocketData, SocketErrorHandler, SocketOptions, } from './ws/socket.js';
30
+ export { HealthIndicator, PingProbe, QueryProbe, type ProbeResult, type ProbeState, } from './health/contracts.js';
31
+ export { HealthController } from './health/controller.js';
32
+ export { DatabaseIndicator, DiskIndicator, DiskOptions, MemoryIndicator, MemoryOptions, RedisIndicator, type DiskOptionsInit, type MemoryOptionsInit, } from './health/indicators.js';
33
+ export { HealthModule } from './health/module.js';
34
+ export { Readiness, ReadinessOptions } from './health/readiness.js';
35
+ export { HealthOptions, HealthRegistry, type HealthCheckReport, type HealthOptionsInit, type HealthReport, } from './health/registry.js';