@dunx/http 2.0.1 → 2.1.1
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 +136 -47
- package/dist/{chunk-5z96f3gr.js → chunk-sz4pvqxy.js} +4 -3
- package/dist/{chunk-5z96f3gr.js.map → chunk-sz4pvqxy.js.map} +2 -2
- package/dist/client.js +1 -1
- package/dist/health/contracts.d.ts +56 -0
- package/dist/health/controller.d.ts +21 -0
- package/dist/health/indicators.d.ts +71 -0
- package/dist/health/module.d.ts +40 -0
- package/dist/health/readiness.d.ts +51 -0
- package/dist/health/registry.d.ts +64 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +407 -55
- package/dist/index.js.map +15 -9
- package/dist/route/discover.d.ts +1 -1
- package/dist/server/application.d.ts +6 -0
- package/dist/server/client-address.d.ts +7 -1
- package/dist/server/settings.d.ts +9 -4
- package/package.json +2 -2
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
|
|
148
|
-
them put together.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
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
|
|
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
|
|
375
|
-
`dunx-logging` as separate subjects for exactly this reason: with
|
|
376
|
-
runs at 81-100% of raw `Bun.serve` depending on the scenario,
|
|
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
|
|
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.
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
|
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,
|
|
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
|
|
462
|
-
be inferred - `enableCors()` mounts an explicit `OPTIONS` handler on
|
|
463
|
-
built at boot from the methods that path actually declares.
|
|
464
|
-
string, a list, or a predicate; anything not allowed gets
|
|
465
|
-
all,
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
`
|
|
469
|
-
|
|
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
|
|
615
|
-
`@OnMessage(event)` handler. A gateway with only a raw `@OnMessage()` never
|
|
616
|
-
JSON it did not ask for.
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
|
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
|
|
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
|
|
679
|
-
does not close on its own - so it **never resolves** while a socket
|
|
680
|
-
with at least one gateway therefore force-stops (`stop(true)`)
|
|
681
|
-
those clients see a `1006` close. An app with no gateways
|
|
682
|
-
|
|
683
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|
|
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=
|
|
110
|
-
//# sourceMappingURL=chunk-
|
|
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": "
|
|
8
|
-
"debugId": "
|
|
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
|
@@ -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 `OnBeforeShutdown`, 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 { OnBeforeShutdown } 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; `onBeforeShutdown` is for shutdown and does not
|
|
25
|
+
* release.
|
|
26
|
+
*
|
|
27
|
+
* This is what `OnBeforeShutdown` 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 OnBeforeShutdown {
|
|
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
|
+
onBeforeShutdown(): 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';
|