@btravstack/core 0.5.0 → 0.7.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
@@ -112,10 +112,13 @@ is a module that exports no runtime port at all.
112
112
  ## What you get
113
113
 
114
114
  - **A drain that survives Kubernetes.** On SIGTERM: readiness flips false, the
115
- kernel waits `preDrainDelayMs` (default `5_000`) before telling the runtime
116
- to stop accepting — endpoint removal is eventually consistent — then
117
- in-flight work gets `drainTimeoutMs` (default `20_000`); whatever is still
118
- open is aborted and reported `abandoned`. A second signal skips the drain.
115
+ kernel waits `preDrainDelayMs` (`PRE_DRAIN_DELAY_MS`, default `5_000`) before
116
+ telling the runtime to stop accepting — endpoint removal is eventually
117
+ consistent — then in-flight work gets `drainTimeoutMs` (`DRAIN_TIMEOUT_MS`,
118
+ default `20_000`); whatever is still open is aborted and reported
119
+ `abandoned`. A second signal skips the drain. Both timings have to agree with
120
+ the pod's `terminationGracePeriodSeconds`, which is why the deployment can
121
+ set them and the option only pins them.
119
122
  - **Probes from the state machine.** `GET /livez` and `GET /readyz` on
120
123
  `PROBE_PORT` (default `9000`), up before the graph is built.
121
124
  - **Configuration from the environment, typed.** The kernel provides
package/dist/index.cjs CHANGED
@@ -1,9 +1,59 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- let unthrown = require("unthrown");
3
2
  let _btravstack_di = require("@btravstack/di");
3
+ let unthrown = require("unthrown");
4
4
  let _btravstack_config = require("@btravstack/config");
5
5
  let node_http = require("node:http");
6
6
  let node_async_hooks = require("node:async_hooks");
7
+ //#region src/instrumentation.ts
8
+ /**
9
+ * The set port a package contributes its instrumentation to.
10
+ *
11
+ * Nothing collects it unless an OTel SDK is composed, which is the point: a
12
+ * starter DECLARES what it can instrument, and composing
13
+ * `@btravstack/observability/otel` is what turns the declarations on. A graph
14
+ * without it registers nothing and pays nothing — the Spring Boot starter
15
+ * shape, in one port.
16
+ */
17
+ var Instrumentations = class extends _btravstack_di.Port.many("Instrumentations") {};
18
+ //#endregion
19
+ //#region src/health.ts
20
+ /**
21
+ * A component could not answer for itself. Modeled rather than thrown, because
22
+ * a health check that throws is a bug in the check; one that fails is the news
23
+ * `/healthz` exists to carry.
24
+ */
25
+ var HealthCheckFailed = class extends (0, unthrown.TaggedError)("HealthCheckFailed") {};
26
+ /**
27
+ * The set port every starter contributes to and the kernel reads whole.
28
+ *
29
+ * A set port rather than a registry the kernel hands out: a starter DECLARES
30
+ * its check the same way it declares anything else, and a starter an
31
+ * application never composed contributes nothing — no registration call to
32
+ * forget, and no order to get wrong.
33
+ */
34
+ var HealthChecks = class extends _btravstack_di.Port.many("HealthChecks") {};
35
+ /**
36
+ * Runs every check and folds the results into one report — the whole app is
37
+ * unhealthy if any single component is.
38
+ *
39
+ * Each check's failure is recovered into a component line BEFORE `allAsync`
40
+ * sees it, so a failing dependency cannot short-circuit the others: a report
41
+ * naming one component is worth less than one naming all of them. What
42
+ * survives to `allAsync`'s error channel is a `Defect` alone — a check that
43
+ * threw rather than answered, which is a bug in the check.
44
+ */
45
+ const runHealthChecks = (checks) => (0, unthrown.allAsync)(checks.map((health) => health.check().map(() => ({
46
+ name: health.name,
47
+ status: "healthy"
48
+ })).recoverErrCases((matcher) => matcher.with(unthrown.P.tag("HealthCheckFailed"), (error) => ({
49
+ name: health.name,
50
+ status: "unhealthy",
51
+ reason: error.reason
52
+ }))))).map((components) => ({
53
+ status: components.every((c) => c.status === "healthy") ? "healthy" : "unhealthy",
54
+ components
55
+ }));
56
+ //#endregion
7
57
  //#region src/clock.ts
8
58
  const systemClock = {
9
59
  now: () => Date.now(),
@@ -185,6 +235,12 @@ const startProbeServer = (args) => (0, unthrown.fromSafePromise)(new Promise((re
185
235
  respond(response, args.ready(), "ready");
186
236
  return;
187
237
  }
238
+ if (request.url === "/healthz") {
239
+ args.health().map((report) => {
240
+ response.writeHead(report.status === "healthy" ? 200 : 503, { "content-type": "application/json" }).end(JSON.stringify(report));
241
+ });
242
+ return;
243
+ }
188
244
  response.writeHead(404).end();
189
245
  });
190
246
  const onBindError = (cause) => {
@@ -305,6 +361,37 @@ const createUnitRegistry = () => {
305
361
  //#endregion
306
362
  //#region src/start.ts
307
363
  const providesEnv = (module) => module.provides.some((provider) => provider.port.portId === _btravstack_config.Env.portId) || module.imports.some(providesEnv);
364
+ /**
365
+ * What the kernel reads from the environment for itself. Each field is pinned
366
+ * by the matching `StartOptions` field — explicit beats environment beats
367
+ * default, per field, exactly as a starter's own configuration is.
368
+ */
369
+ const KERNEL_DEFAULTS = {
370
+ probePort: 9e3,
371
+ preDrainDelayMs: 5e3,
372
+ drainTimeoutMs: 2e4
373
+ };
374
+ const readKernelConfig = (options, env) => {
375
+ const probePort = options.probes === void 0 ? void 0 : options.probes === false ? KERNEL_DEFAULTS.probePort : options.probes.port;
376
+ const result = _btravstack_config.Config.object({
377
+ probePort: _btravstack_config.Config.pinned(probePort, _btravstack_config.Config.port("PROBE_PORT", { default: KERNEL_DEFAULTS.probePort })),
378
+ preDrainDelayMs: _btravstack_config.Config.pinned(options.preDrainDelayMs, _btravstack_config.Config.integer("PRE_DRAIN_DELAY_MS", {
379
+ default: KERNEL_DEFAULTS.preDrainDelayMs,
380
+ min: 0
381
+ })),
382
+ drainTimeoutMs: _btravstack_config.Config.pinned(options.drainTimeoutMs, _btravstack_config.Config.integer("DRAIN_TIMEOUT_MS", {
383
+ default: KERNEL_DEFAULTS.drainTimeoutMs,
384
+ min: 0
385
+ }))
386
+ })["~standard"].validate(env);
387
+ return result.issues === void 0 ? (0, unthrown.Ok)(result.value) : (0, unthrown.Err)(new RuntimeStartFailed({
388
+ runtime: "kernel",
389
+ cause: new _btravstack_config.ConfigInvalid({
390
+ port: "kernel",
391
+ issues: result.issues
392
+ })
393
+ }));
394
+ };
308
395
  const start = (module, options = {}) => {
309
396
  const clock = options.clock ?? systemClock;
310
397
  const env = options.env ?? process.env;
@@ -326,8 +413,8 @@ const start = (module, options = {}) => {
326
413
  const shutdown = Promise.withResolvers();
327
414
  const teardownErrors = [];
328
415
  const startedAt = clock.now();
329
- const preDrainDelayMs = options.preDrainDelayMs ?? 5e3;
330
- const drainTimeoutMs = options.drainTimeoutMs ?? 2e4;
416
+ const kernelConfig = readKernelConfig(options, env);
417
+ const { preDrainDelayMs, drainTimeoutMs } = kernelConfig.getOrElse(() => KERNEL_DEFAULTS);
331
418
  const skipDrain = new AbortController();
332
419
  let shutdownRequestedAt = startedAt;
333
420
  let shutdownRequested = false;
@@ -359,24 +446,18 @@ const start = (module, options = {}) => {
359
446
  requestShutdown("uncaught");
360
447
  });
361
448
  let disposeProbes = () => {};
449
+ let healthChecks = [];
450
+ const health = () => runHealthChecks(healthChecks);
362
451
  const probeBound = Promise.withResolvers();
363
452
  const runtimePublished = Promise.withResolvers();
364
453
  emit({ type: "building" });
365
- const probesOptions = options.probes === void 0 ? _btravstack_config.Config.port("PROBE_PORT", { default: 9e3 }).parse(env["PROBE_PORT"]).map((port) => ({ port })).mapErrCases((matcher) => matcher.with(unthrown.P.tag("ConfigFieldInvalid"), ({ reason }) => new RuntimeStartFailed({
366
- runtime: "probes",
367
- cause: new _btravstack_config.ConfigInvalid({
368
- port: "probes",
369
- issues: [{
370
- message: reason,
371
- path: ["PROBE_PORT"]
372
- }]
373
- })
374
- }))) : (0, unthrown.Ok)(options.probes);
454
+ const probesOptions = kernelConfig.map(({ probePort }) => options.probes === false ? false : { port: probePort });
375
455
  if (options.probes === false) probeBound.resolve(void 0);
376
456
  const probesStarted = probesOptions.toAsync().flatMap((probes) => probes === false ? (0, unthrown.OkAsync)() : startProbeServer({
377
457
  port: probes.port,
378
458
  live,
379
- ready
459
+ ready,
460
+ health
380
461
  }).tap((server) => {
381
462
  probeBoundPort = server.port;
382
463
  probeBound.resolve(server.port);
@@ -444,6 +525,7 @@ const start = (module, options = {}) => {
444
525
  tracker.advanceTo("starting");
445
526
  const runtime = ctx.get(RuntimePort);
446
527
  runtimeName = runtime.name;
528
+ healthChecks = ctx.get(HealthChecks);
447
529
  const runtimeCtx = ctx;
448
530
  const unit = options.unit;
449
531
  const run = (meta, work) => registry.run(meta, (signal) => {
@@ -554,6 +636,9 @@ const runMain = async (module, options = {}, exit = (code) => {
554
636
  await awaitExit(start(module, options), exit);
555
637
  };
556
638
  //#endregion
639
+ exports.HealthCheckFailed = HealthCheckFailed;
640
+ exports.HealthChecks = HealthChecks;
641
+ exports.Instrumentations = Instrumentations;
557
642
  exports.LEVELS = LEVELS;
558
643
  exports.Logger = Logger;
559
644
  exports.Meter = Meter;
@@ -563,6 +648,7 @@ exports.SPAN_STATUS = SPAN_STATUS;
563
648
  exports.Tracer = Tracer;
564
649
  exports.currentUnit = currentUnit;
565
650
  exports.releasedBy = releasedBy;
651
+ exports.runHealthChecks = runHealthChecks;
566
652
  exports.runMain = runMain;
567
653
  exports.start = start;
568
654
  exports.stderrSink = stderrSink;
package/dist/index.d.cts CHANGED
@@ -1,6 +1,85 @@
1
1
  import { AsyncResult, Result } from "unthrown";
2
2
  import { AnyPort, Context, Module, PortClass, Scope, ServiceOf } from "@btravstack/di";
3
3
  import { Env, Environment } from "@btravstack/config";
4
+ //#region src/instrumentation.d.ts
5
+ /**
6
+ * One package's offer of an OpenTelemetry instrumentation.
7
+ *
8
+ * **Async, and answers `undefined` rather than failing, because the package
9
+ * supplying the instrumentation is an OPTIONAL peer.** A starter cannot know at
10
+ * composition time whether the consumer installed it, so it contributes the
11
+ * ATTEMPT and the collector drops what did not load — which is what lets
12
+ * `@btravstack/prisma` declare engine tracing without making every consumer
13
+ * install `@prisma/instrumentation`. A contributor that wants to say why its
14
+ * load answered nothing logs that itself; it is the one that knows.
15
+ *
16
+ * The instrumentation is `unknown` on purpose. Naming OpenTelemetry's
17
+ * `Instrumentation` would put the vendor in the package every other one peers
18
+ * on — the same reason `Tracer` and `Meter` are declared as narrowings rather
19
+ * than as OTel's own types. The collector
20
+ * (`@btravstack/observability/otel`) is where the vendor is already a
21
+ * dependency, so that is where the cast belongs.
22
+ */
23
+ type InstrumentationLoader = () => Promise<unknown>;
24
+ declare const Instrumentations_base: import("@btravstack/di").ManyPortClass<"Instrumentations">;
25
+ /**
26
+ * The set port a package contributes its instrumentation to.
27
+ *
28
+ * Nothing collects it unless an OTel SDK is composed, which is the point: a
29
+ * starter DECLARES what it can instrument, and composing
30
+ * `@btravstack/observability/otel` is what turns the declarations on. A graph
31
+ * without it registers nothing and pays nothing — the Spring Boot starter
32
+ * shape, in one port.
33
+ */
34
+ declare class Instrumentations extends Instrumentations_base<InstrumentationLoader> {}
35
+ //#endregion
36
+ //#region src/health.d.ts
37
+ declare const HealthCheckFailed_base: import("unthrown").TaggedErrorConstructor<"HealthCheckFailed">;
38
+ /**
39
+ * A component could not answer for itself. Modeled rather than thrown, because
40
+ * a health check that throws is a bug in the check; one that fails is the news
41
+ * `/healthz` exists to carry.
42
+ */
43
+ declare class HealthCheckFailed extends HealthCheckFailed_base<{
44
+ readonly reason: string;
45
+ }> {}
46
+ /** One contribution: what to call the component, and how to ask it. */
47
+ type HealthCheck = {
48
+ readonly name: string;
49
+ readonly check: () => AsyncResult<void, HealthCheckFailed>;
50
+ };
51
+ declare const HealthChecks_base: import("@btravstack/di").ManyPortClass<"HealthChecks">;
52
+ /**
53
+ * The set port every starter contributes to and the kernel reads whole.
54
+ *
55
+ * A set port rather than a registry the kernel hands out: a starter DECLARES
56
+ * its check the same way it declares anything else, and a starter an
57
+ * application never composed contributes nothing — no registration call to
58
+ * forget, and no order to get wrong.
59
+ */
60
+ declare class HealthChecks extends HealthChecks_base<HealthCheck> {}
61
+ type ComponentHealth = {
62
+ readonly name: string;
63
+ readonly status: "healthy" | "unhealthy";
64
+ /** Present only when the component is unhealthy. */
65
+ readonly reason?: string;
66
+ };
67
+ type HealthReport = {
68
+ readonly status: "healthy" | "unhealthy";
69
+ readonly components: readonly ComponentHealth[];
70
+ };
71
+ /**
72
+ * Runs every check and folds the results into one report — the whole app is
73
+ * unhealthy if any single component is.
74
+ *
75
+ * Each check's failure is recovered into a component line BEFORE `allAsync`
76
+ * sees it, so a failing dependency cannot short-circuit the others: a report
77
+ * naming one component is worth less than one naming all of them. What
78
+ * survives to `allAsync`'s error channel is a `Defect` alone — a check that
79
+ * threw rather than answered, which is a bug in the check.
80
+ */
81
+ declare const runHealthChecks: (checks: readonly HealthCheck[]) => AsyncResult<HealthReport, never>;
82
+ //#endregion
4
83
  //#region src/clock.d.ts
5
84
  type Clock = {
6
85
  readonly now: () => number;
@@ -379,7 +458,21 @@ type StartOptions<UnitX = never, UnitNeeds = never> = {
379
458
  readonly probes?: {
380
459
  readonly port: number;
381
460
  } | false;
461
+ /**
462
+ * How long readiness stays false before the runtime is told to stop
463
+ * accepting. Unset, it is bound from `PRE_DRAIN_DELAY_MS` in `env` (default
464
+ * `5_000`) — the window that covers Kubernetes' eventually-consistent
465
+ * endpoint removal, which is a property of the cluster rather than of the
466
+ * code, so a deployment must be able to set it.
467
+ */
382
468
  readonly preDrainDelayMs?: number;
469
+ /**
470
+ * How long in-flight work gets before it is aborted and reported
471
+ * `abandoned`. Unset, it is bound from `DRAIN_TIMEOUT_MS` in `env` (default
472
+ * `20_000`). Keep it under the pod's `terminationGracePeriodSeconds`, which
473
+ * is the reason this one belongs in the environment: the two are set
474
+ * together, in the same manifest.
475
+ */
383
476
  readonly drainTimeoutMs?: number;
384
477
  readonly onEvent?: EventSink;
385
478
  };
@@ -455,4 +548,4 @@ declare const start: <X, E, UnitX = never, UnitNeeds = never>(module: Module<X,
455
548
  */
456
549
  declare const runMain: <X, E, UnitX = never, UnitNeeds = never>(module: Module<X, E, Scope | Env> & StartGate<X, UnitNeeds>, options?: StartOptions<UnitX, UnitNeeds>, exit?: (code: number) => void) => Promise<void>;
457
550
  //#endregion
458
- export { type Attributes, type Clock, type Counter, type DrainReport, type EventSink, type ExitReport, type Histogram, type KernelEvent, LEVELS, type Level, Logger, type LoggerService, Meter, type MeterService, type Phase, type RunUnit, type RunningApp, type Runtime, type RuntimeHost, type RuntimeInfoOf, RuntimePort, RuntimeStartFailed, SPAN_STATUS, type Serving, type Span, type SpanStatusCode, type StartGate, type StartOptions, type TeardownError, Tracer, type TracerService, type UnitMeta, type UnitRecord, type UnitRegistry, type UnitWork, currentUnit, releasedBy, runMain, start, stderrSink, systemClock };
551
+ export { type Attributes, type Clock, type ComponentHealth, type Counter, type DrainReport, type EventSink, type ExitReport, type HealthCheck, HealthCheckFailed, HealthChecks, type HealthReport, type Histogram, type InstrumentationLoader, Instrumentations, type KernelEvent, LEVELS, type Level, Logger, type LoggerService, Meter, type MeterService, type Phase, type RunUnit, type RunningApp, type Runtime, type RuntimeHost, type RuntimeInfoOf, RuntimePort, RuntimeStartFailed, SPAN_STATUS, type Serving, type Span, type SpanStatusCode, type StartGate, type StartOptions, type TeardownError, Tracer, type TracerService, type UnitMeta, type UnitRecord, type UnitRegistry, type UnitWork, currentUnit, releasedBy, runHealthChecks, runMain, start, stderrSink, systemClock };
package/dist/index.d.mts CHANGED
@@ -1,6 +1,85 @@
1
- import { AsyncResult, Result } from "unthrown";
2
1
  import { AnyPort, Context, Module, PortClass, Scope, ServiceOf } from "@btravstack/di";
2
+ import { AsyncResult, Result } from "unthrown";
3
3
  import { Env, Environment } from "@btravstack/config";
4
+ //#region src/instrumentation.d.ts
5
+ /**
6
+ * One package's offer of an OpenTelemetry instrumentation.
7
+ *
8
+ * **Async, and answers `undefined` rather than failing, because the package
9
+ * supplying the instrumentation is an OPTIONAL peer.** A starter cannot know at
10
+ * composition time whether the consumer installed it, so it contributes the
11
+ * ATTEMPT and the collector drops what did not load — which is what lets
12
+ * `@btravstack/prisma` declare engine tracing without making every consumer
13
+ * install `@prisma/instrumentation`. A contributor that wants to say why its
14
+ * load answered nothing logs that itself; it is the one that knows.
15
+ *
16
+ * The instrumentation is `unknown` on purpose. Naming OpenTelemetry's
17
+ * `Instrumentation` would put the vendor in the package every other one peers
18
+ * on — the same reason `Tracer` and `Meter` are declared as narrowings rather
19
+ * than as OTel's own types. The collector
20
+ * (`@btravstack/observability/otel`) is where the vendor is already a
21
+ * dependency, so that is where the cast belongs.
22
+ */
23
+ type InstrumentationLoader = () => Promise<unknown>;
24
+ declare const Instrumentations_base: import("@btravstack/di").ManyPortClass<"Instrumentations">;
25
+ /**
26
+ * The set port a package contributes its instrumentation to.
27
+ *
28
+ * Nothing collects it unless an OTel SDK is composed, which is the point: a
29
+ * starter DECLARES what it can instrument, and composing
30
+ * `@btravstack/observability/otel` is what turns the declarations on. A graph
31
+ * without it registers nothing and pays nothing — the Spring Boot starter
32
+ * shape, in one port.
33
+ */
34
+ declare class Instrumentations extends Instrumentations_base<InstrumentationLoader> {}
35
+ //#endregion
36
+ //#region src/health.d.ts
37
+ declare const HealthCheckFailed_base: import("unthrown").TaggedErrorConstructor<"HealthCheckFailed">;
38
+ /**
39
+ * A component could not answer for itself. Modeled rather than thrown, because
40
+ * a health check that throws is a bug in the check; one that fails is the news
41
+ * `/healthz` exists to carry.
42
+ */
43
+ declare class HealthCheckFailed extends HealthCheckFailed_base<{
44
+ readonly reason: string;
45
+ }> {}
46
+ /** One contribution: what to call the component, and how to ask it. */
47
+ type HealthCheck = {
48
+ readonly name: string;
49
+ readonly check: () => AsyncResult<void, HealthCheckFailed>;
50
+ };
51
+ declare const HealthChecks_base: import("@btravstack/di").ManyPortClass<"HealthChecks">;
52
+ /**
53
+ * The set port every starter contributes to and the kernel reads whole.
54
+ *
55
+ * A set port rather than a registry the kernel hands out: a starter DECLARES
56
+ * its check the same way it declares anything else, and a starter an
57
+ * application never composed contributes nothing — no registration call to
58
+ * forget, and no order to get wrong.
59
+ */
60
+ declare class HealthChecks extends HealthChecks_base<HealthCheck> {}
61
+ type ComponentHealth = {
62
+ readonly name: string;
63
+ readonly status: "healthy" | "unhealthy";
64
+ /** Present only when the component is unhealthy. */
65
+ readonly reason?: string;
66
+ };
67
+ type HealthReport = {
68
+ readonly status: "healthy" | "unhealthy";
69
+ readonly components: readonly ComponentHealth[];
70
+ };
71
+ /**
72
+ * Runs every check and folds the results into one report — the whole app is
73
+ * unhealthy if any single component is.
74
+ *
75
+ * Each check's failure is recovered into a component line BEFORE `allAsync`
76
+ * sees it, so a failing dependency cannot short-circuit the others: a report
77
+ * naming one component is worth less than one naming all of them. What
78
+ * survives to `allAsync`'s error channel is a `Defect` alone — a check that
79
+ * threw rather than answered, which is a bug in the check.
80
+ */
81
+ declare const runHealthChecks: (checks: readonly HealthCheck[]) => AsyncResult<HealthReport, never>;
82
+ //#endregion
4
83
  //#region src/clock.d.ts
5
84
  type Clock = {
6
85
  readonly now: () => number;
@@ -379,7 +458,21 @@ type StartOptions<UnitX = never, UnitNeeds = never> = {
379
458
  readonly probes?: {
380
459
  readonly port: number;
381
460
  } | false;
461
+ /**
462
+ * How long readiness stays false before the runtime is told to stop
463
+ * accepting. Unset, it is bound from `PRE_DRAIN_DELAY_MS` in `env` (default
464
+ * `5_000`) — the window that covers Kubernetes' eventually-consistent
465
+ * endpoint removal, which is a property of the cluster rather than of the
466
+ * code, so a deployment must be able to set it.
467
+ */
382
468
  readonly preDrainDelayMs?: number;
469
+ /**
470
+ * How long in-flight work gets before it is aborted and reported
471
+ * `abandoned`. Unset, it is bound from `DRAIN_TIMEOUT_MS` in `env` (default
472
+ * `20_000`). Keep it under the pod's `terminationGracePeriodSeconds`, which
473
+ * is the reason this one belongs in the environment: the two are set
474
+ * together, in the same manifest.
475
+ */
383
476
  readonly drainTimeoutMs?: number;
384
477
  readonly onEvent?: EventSink;
385
478
  };
@@ -455,4 +548,4 @@ declare const start: <X, E, UnitX = never, UnitNeeds = never>(module: Module<X,
455
548
  */
456
549
  declare const runMain: <X, E, UnitX = never, UnitNeeds = never>(module: Module<X, E, Scope | Env> & StartGate<X, UnitNeeds>, options?: StartOptions<UnitX, UnitNeeds>, exit?: (code: number) => void) => Promise<void>;
457
550
  //#endregion
458
- export { type Attributes, type Clock, type Counter, type DrainReport, type EventSink, type ExitReport, type Histogram, type KernelEvent, LEVELS, type Level, Logger, type LoggerService, Meter, type MeterService, type Phase, type RunUnit, type RunningApp, type Runtime, type RuntimeHost, type RuntimeInfoOf, RuntimePort, RuntimeStartFailed, SPAN_STATUS, type Serving, type Span, type SpanStatusCode, type StartGate, type StartOptions, type TeardownError, Tracer, type TracerService, type UnitMeta, type UnitRecord, type UnitRegistry, type UnitWork, currentUnit, releasedBy, runMain, start, stderrSink, systemClock };
551
+ export { type Attributes, type Clock, type ComponentHealth, type Counter, type DrainReport, type EventSink, type ExitReport, type HealthCheck, HealthCheckFailed, HealthChecks, type HealthReport, type Histogram, type InstrumentationLoader, Instrumentations, type KernelEvent, LEVELS, type Level, Logger, type LoggerService, Meter, type MeterService, type Phase, type RunUnit, type RunningApp, type Runtime, type RuntimeHost, type RuntimeInfoOf, RuntimePort, RuntimeStartFailed, SPAN_STATUS, type Serving, type Span, type SpanStatusCode, type StartGate, type StartOptions, type TeardownError, Tracer, type TracerService, type UnitMeta, type UnitRecord, type UnitRegistry, type UnitWork, currentUnit, releasedBy, runHealthChecks, runMain, start, stderrSink, systemClock };
package/dist/index.mjs CHANGED
@@ -1,8 +1,58 @@
1
- import { Err, Ok, OkAsync, P, TaggedError, fromSafePromise } from "unthrown";
2
1
  import { Module, Port, Provider } from "@btravstack/di";
2
+ import { Err, Ok, OkAsync, P, TaggedError, allAsync, fromSafePromise } from "unthrown";
3
3
  import { Config, ConfigInvalid, Env } from "@btravstack/config";
4
4
  import { createServer } from "node:http";
5
5
  import { AsyncLocalStorage } from "node:async_hooks";
6
+ //#region src/instrumentation.ts
7
+ /**
8
+ * The set port a package contributes its instrumentation to.
9
+ *
10
+ * Nothing collects it unless an OTel SDK is composed, which is the point: a
11
+ * starter DECLARES what it can instrument, and composing
12
+ * `@btravstack/observability/otel` is what turns the declarations on. A graph
13
+ * without it registers nothing and pays nothing — the Spring Boot starter
14
+ * shape, in one port.
15
+ */
16
+ var Instrumentations = class extends Port.many("Instrumentations") {};
17
+ //#endregion
18
+ //#region src/health.ts
19
+ /**
20
+ * A component could not answer for itself. Modeled rather than thrown, because
21
+ * a health check that throws is a bug in the check; one that fails is the news
22
+ * `/healthz` exists to carry.
23
+ */
24
+ var HealthCheckFailed = class extends TaggedError("HealthCheckFailed") {};
25
+ /**
26
+ * The set port every starter contributes to and the kernel reads whole.
27
+ *
28
+ * A set port rather than a registry the kernel hands out: a starter DECLARES
29
+ * its check the same way it declares anything else, and a starter an
30
+ * application never composed contributes nothing — no registration call to
31
+ * forget, and no order to get wrong.
32
+ */
33
+ var HealthChecks = class extends Port.many("HealthChecks") {};
34
+ /**
35
+ * Runs every check and folds the results into one report — the whole app is
36
+ * unhealthy if any single component is.
37
+ *
38
+ * Each check's failure is recovered into a component line BEFORE `allAsync`
39
+ * sees it, so a failing dependency cannot short-circuit the others: a report
40
+ * naming one component is worth less than one naming all of them. What
41
+ * survives to `allAsync`'s error channel is a `Defect` alone — a check that
42
+ * threw rather than answered, which is a bug in the check.
43
+ */
44
+ const runHealthChecks = (checks) => allAsync(checks.map((health) => health.check().map(() => ({
45
+ name: health.name,
46
+ status: "healthy"
47
+ })).recoverErrCases((matcher) => matcher.with(P.tag("HealthCheckFailed"), (error) => ({
48
+ name: health.name,
49
+ status: "unhealthy",
50
+ reason: error.reason
51
+ }))))).map((components) => ({
52
+ status: components.every((c) => c.status === "healthy") ? "healthy" : "unhealthy",
53
+ components
54
+ }));
55
+ //#endregion
6
56
  //#region src/clock.ts
7
57
  const systemClock = {
8
58
  now: () => Date.now(),
@@ -184,6 +234,12 @@ const startProbeServer = (args) => fromSafePromise(new Promise((resolve) => {
184
234
  respond(response, args.ready(), "ready");
185
235
  return;
186
236
  }
237
+ if (request.url === "/healthz") {
238
+ args.health().map((report) => {
239
+ response.writeHead(report.status === "healthy" ? 200 : 503, { "content-type": "application/json" }).end(JSON.stringify(report));
240
+ });
241
+ return;
242
+ }
187
243
  response.writeHead(404).end();
188
244
  });
189
245
  const onBindError = (cause) => {
@@ -304,6 +360,37 @@ const createUnitRegistry = () => {
304
360
  //#endregion
305
361
  //#region src/start.ts
306
362
  const providesEnv = (module) => module.provides.some((provider) => provider.port.portId === Env.portId) || module.imports.some(providesEnv);
363
+ /**
364
+ * What the kernel reads from the environment for itself. Each field is pinned
365
+ * by the matching `StartOptions` field — explicit beats environment beats
366
+ * default, per field, exactly as a starter's own configuration is.
367
+ */
368
+ const KERNEL_DEFAULTS = {
369
+ probePort: 9e3,
370
+ preDrainDelayMs: 5e3,
371
+ drainTimeoutMs: 2e4
372
+ };
373
+ const readKernelConfig = (options, env) => {
374
+ const probePort = options.probes === void 0 ? void 0 : options.probes === false ? KERNEL_DEFAULTS.probePort : options.probes.port;
375
+ const result = Config.object({
376
+ probePort: Config.pinned(probePort, Config.port("PROBE_PORT", { default: KERNEL_DEFAULTS.probePort })),
377
+ preDrainDelayMs: Config.pinned(options.preDrainDelayMs, Config.integer("PRE_DRAIN_DELAY_MS", {
378
+ default: KERNEL_DEFAULTS.preDrainDelayMs,
379
+ min: 0
380
+ })),
381
+ drainTimeoutMs: Config.pinned(options.drainTimeoutMs, Config.integer("DRAIN_TIMEOUT_MS", {
382
+ default: KERNEL_DEFAULTS.drainTimeoutMs,
383
+ min: 0
384
+ }))
385
+ })["~standard"].validate(env);
386
+ return result.issues === void 0 ? Ok(result.value) : Err(new RuntimeStartFailed({
387
+ runtime: "kernel",
388
+ cause: new ConfigInvalid({
389
+ port: "kernel",
390
+ issues: result.issues
391
+ })
392
+ }));
393
+ };
307
394
  const start = (module, options = {}) => {
308
395
  const clock = options.clock ?? systemClock;
309
396
  const env = options.env ?? process.env;
@@ -325,8 +412,8 @@ const start = (module, options = {}) => {
325
412
  const shutdown = Promise.withResolvers();
326
413
  const teardownErrors = [];
327
414
  const startedAt = clock.now();
328
- const preDrainDelayMs = options.preDrainDelayMs ?? 5e3;
329
- const drainTimeoutMs = options.drainTimeoutMs ?? 2e4;
415
+ const kernelConfig = readKernelConfig(options, env);
416
+ const { preDrainDelayMs, drainTimeoutMs } = kernelConfig.getOrElse(() => KERNEL_DEFAULTS);
330
417
  const skipDrain = new AbortController();
331
418
  let shutdownRequestedAt = startedAt;
332
419
  let shutdownRequested = false;
@@ -358,24 +445,18 @@ const start = (module, options = {}) => {
358
445
  requestShutdown("uncaught");
359
446
  });
360
447
  let disposeProbes = () => {};
448
+ let healthChecks = [];
449
+ const health = () => runHealthChecks(healthChecks);
361
450
  const probeBound = Promise.withResolvers();
362
451
  const runtimePublished = Promise.withResolvers();
363
452
  emit({ type: "building" });
364
- const probesOptions = options.probes === void 0 ? Config.port("PROBE_PORT", { default: 9e3 }).parse(env["PROBE_PORT"]).map((port) => ({ port })).mapErrCases((matcher) => matcher.with(P.tag("ConfigFieldInvalid"), ({ reason }) => new RuntimeStartFailed({
365
- runtime: "probes",
366
- cause: new ConfigInvalid({
367
- port: "probes",
368
- issues: [{
369
- message: reason,
370
- path: ["PROBE_PORT"]
371
- }]
372
- })
373
- }))) : Ok(options.probes);
453
+ const probesOptions = kernelConfig.map(({ probePort }) => options.probes === false ? false : { port: probePort });
374
454
  if (options.probes === false) probeBound.resolve(void 0);
375
455
  const probesStarted = probesOptions.toAsync().flatMap((probes) => probes === false ? OkAsync() : startProbeServer({
376
456
  port: probes.port,
377
457
  live,
378
- ready
458
+ ready,
459
+ health
379
460
  }).tap((server) => {
380
461
  probeBoundPort = server.port;
381
462
  probeBound.resolve(server.port);
@@ -443,6 +524,7 @@ const start = (module, options = {}) => {
443
524
  tracker.advanceTo("starting");
444
525
  const runtime = ctx.get(RuntimePort);
445
526
  runtimeName = runtime.name;
527
+ healthChecks = ctx.get(HealthChecks);
446
528
  const runtimeCtx = ctx;
447
529
  const unit = options.unit;
448
530
  const run = (meta, work) => registry.run(meta, (signal) => {
@@ -553,4 +635,4 @@ const runMain = async (module, options = {}, exit = (code) => {
553
635
  await awaitExit(start(module, options), exit);
554
636
  };
555
637
  //#endregion
556
- export { LEVELS, Logger, Meter, RuntimePort, RuntimeStartFailed, SPAN_STATUS, Tracer, currentUnit, releasedBy, runMain, start, stderrSink, systemClock };
638
+ export { HealthCheckFailed, HealthChecks, Instrumentations, LEVELS, Logger, Meter, RuntimePort, RuntimeStartFailed, SPAN_STATUS, Tracer, currentUnit, releasedBy, runHealthChecks, runMain, start, stderrSink, systemClock };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@btravstack/core",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "The application kernel: boot a di module into a running process, and stop it without losing work",
5
5
  "keywords": [
6
6
  "application",
@@ -53,13 +53,13 @@
53
53
  "typescript": "7.0.2",
54
54
  "unthrown": "5.7.0",
55
55
  "vitest": "4.1.11",
56
- "@btravstack/config": "0.5.0",
57
- "@btravstack/di": "0.5.0"
56
+ "@btravstack/config": "0.7.0",
57
+ "@btravstack/di": "0.7.0"
58
58
  },
59
59
  "peerDependencies": {
60
60
  "unthrown": "^5.0.0",
61
- "@btravstack/config": "^0.5.0",
62
- "@btravstack/di": "^0.5.0"
61
+ "@btravstack/config": "^0.7.0",
62
+ "@btravstack/di": "^0.7.0"
63
63
  },
64
64
  "engines": {
65
65
  "node": ">=22"