@btravstack/core 0.6.0 → 0.8.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
@@ -38,18 +38,25 @@ var HealthChecks = class extends _btravstack_di.Port.many("HealthChecks") {};
38
38
  *
39
39
  * Each check's failure is recovered into a component line BEFORE `allAsync`
40
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.
41
+ * naming one component is worth less than one naming all of them. A check
42
+ * that throws rather than answers is a bug in the check, and it is folded in
43
+ * too: each check is STARTED inside the pipeline, so a synchronous throw and
44
+ * a defecting `AsyncResult` alike are recovered into an unhealthy line —
45
+ * escaped, the first would reach the kernel's own `uncaughtException` handler
46
+ * and the second would leave `/healthz` hanging with nothing written.
44
47
  */
45
- const runHealthChecks = (checks) => (0, unthrown.allAsync)(checks.map((health) => health.check().map(() => ({
48
+ const runHealthChecks = (checks) => (0, unthrown.allAsync)(checks.map((health) => (0, unthrown.OkAsync)().flatMap(() => health.check()).map(() => ({
46
49
  name: health.name,
47
50
  status: "healthy"
48
51
  })).recoverErrCases((matcher) => matcher.with(unthrown.P.tag("HealthCheckFailed"), (error) => ({
49
52
  name: health.name,
50
53
  status: "unhealthy",
51
54
  reason: error.reason
52
- }))))).map((components) => ({
55
+ }))).recoverDefect((cause) => (0, unthrown.Ok)({
56
+ name: health.name,
57
+ status: "unhealthy",
58
+ reason: String(cause)
59
+ })))).map((components) => ({
53
60
  status: components.every((c) => c.status === "healthy") ? "healthy" : "unhealthy",
54
61
  components
55
62
  }));
@@ -361,6 +368,37 @@ const createUnitRegistry = () => {
361
368
  //#endregion
362
369
  //#region src/start.ts
363
370
  const providesEnv = (module) => module.provides.some((provider) => provider.port.portId === _btravstack_config.Env.portId) || module.imports.some(providesEnv);
371
+ /**
372
+ * What the kernel reads from the environment for itself. Each field is pinned
373
+ * by the matching `StartOptions` field — explicit beats environment beats
374
+ * default, per field, exactly as a starter's own configuration is.
375
+ */
376
+ const KERNEL_DEFAULTS = {
377
+ probePort: 9e3,
378
+ preDrainDelayMs: 5e3,
379
+ drainTimeoutMs: 2e4
380
+ };
381
+ const readKernelConfig = (options, env) => {
382
+ const probePort = options.probes === void 0 ? void 0 : options.probes === false ? KERNEL_DEFAULTS.probePort : options.probes.port;
383
+ const result = _btravstack_config.Config.object({
384
+ probePort: _btravstack_config.Config.pinned(probePort, _btravstack_config.Config.port("PROBE_PORT", { default: KERNEL_DEFAULTS.probePort })),
385
+ preDrainDelayMs: _btravstack_config.Config.pinned(options.preDrainDelayMs, _btravstack_config.Config.integer("PRE_DRAIN_DELAY_MS", {
386
+ default: KERNEL_DEFAULTS.preDrainDelayMs,
387
+ min: 0
388
+ })),
389
+ drainTimeoutMs: _btravstack_config.Config.pinned(options.drainTimeoutMs, _btravstack_config.Config.integer("DRAIN_TIMEOUT_MS", {
390
+ default: KERNEL_DEFAULTS.drainTimeoutMs,
391
+ min: 0
392
+ }))
393
+ })["~standard"].validate(env);
394
+ return result.issues === void 0 ? (0, unthrown.Ok)(result.value) : (0, unthrown.Err)(new RuntimeStartFailed({
395
+ runtime: "kernel",
396
+ cause: new _btravstack_config.ConfigInvalid({
397
+ port: "kernel",
398
+ issues: result.issues
399
+ })
400
+ }));
401
+ };
364
402
  const start = (module, options = {}) => {
365
403
  const clock = options.clock ?? systemClock;
366
404
  const env = options.env ?? process.env;
@@ -382,8 +420,8 @@ const start = (module, options = {}) => {
382
420
  const shutdown = Promise.withResolvers();
383
421
  const teardownErrors = [];
384
422
  const startedAt = clock.now();
385
- const preDrainDelayMs = options.preDrainDelayMs ?? 5e3;
386
- const drainTimeoutMs = options.drainTimeoutMs ?? 2e4;
423
+ const kernelConfig = readKernelConfig(options, env);
424
+ const { preDrainDelayMs, drainTimeoutMs } = kernelConfig.getOrElse(() => KERNEL_DEFAULTS);
387
425
  const skipDrain = new AbortController();
388
426
  let shutdownRequestedAt = startedAt;
389
427
  let shutdownRequested = false;
@@ -420,16 +458,7 @@ const start = (module, options = {}) => {
420
458
  const probeBound = Promise.withResolvers();
421
459
  const runtimePublished = Promise.withResolvers();
422
460
  emit({ type: "building" });
423
- 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({
424
- runtime: "probes",
425
- cause: new _btravstack_config.ConfigInvalid({
426
- port: "probes",
427
- issues: [{
428
- message: reason,
429
- path: ["PROBE_PORT"]
430
- }]
431
- })
432
- }))) : (0, unthrown.Ok)(options.probes);
461
+ const probesOptions = kernelConfig.map(({ probePort }) => options.probes === false ? false : { port: probePort });
433
462
  if (options.probes === false) probeBound.resolve(void 0);
434
463
  const probesStarted = probesOptions.toAsync().flatMap((probes) => probes === false ? (0, unthrown.OkAsync)() : startProbeServer({
435
464
  port: probes.port,
package/dist/index.d.cts CHANGED
@@ -74,9 +74,12 @@ type HealthReport = {
74
74
  *
75
75
  * Each check's failure is recovered into a component line BEFORE `allAsync`
76
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.
77
+ * naming one component is worth less than one naming all of them. A check
78
+ * that throws rather than answers is a bug in the check, and it is folded in
79
+ * too: each check is STARTED inside the pipeline, so a synchronous throw and
80
+ * a defecting `AsyncResult` alike are recovered into an unhealthy line —
81
+ * escaped, the first would reach the kernel's own `uncaughtException` handler
82
+ * and the second would leave `/healthz` hanging with nothing written.
80
83
  */
81
84
  declare const runHealthChecks: (checks: readonly HealthCheck[]) => AsyncResult<HealthReport, never>;
82
85
  //#endregion
@@ -439,9 +442,11 @@ type StartOptions<UnitX = never, UnitNeeds = never> = {
439
442
  */
440
443
  readonly env?: Environment;
441
444
  /**
442
- * A module forked around **every unit**: built as the unit opens, torn down
443
- * as it closes — while the unit's ambient record is still open — reading
444
- * anything the application context carries.
445
+ * A module forked around **every unit** — a unit being one bounded piece of
446
+ * work, whatever the transport calls it: an HTTP request, a Temporal
447
+ * activity, an AMQP delivery. Built as the unit opens, torn down as it
448
+ * closes — while the unit's ambient record is still open — reading anything
449
+ * the application context carries.
445
450
  *
446
451
  * A failing unit finaliser is reported as a `teardownError` event and never
447
452
  * in `ExitReport.teardownErrors`. With this option the unit's work runs only
@@ -458,7 +463,21 @@ type StartOptions<UnitX = never, UnitNeeds = never> = {
458
463
  readonly probes?: {
459
464
  readonly port: number;
460
465
  } | false;
466
+ /**
467
+ * How long readiness stays false before the runtime is told to stop
468
+ * accepting. Unset, it is bound from `PRE_DRAIN_DELAY_MS` in `env` (default
469
+ * `5_000`) — the window that covers Kubernetes' eventually-consistent
470
+ * endpoint removal, which is a property of the cluster rather than of the
471
+ * code, so a deployment must be able to set it.
472
+ */
461
473
  readonly preDrainDelayMs?: number;
474
+ /**
475
+ * How long in-flight work gets before it is aborted and reported
476
+ * `abandoned`. Unset, it is bound from `DRAIN_TIMEOUT_MS` in `env` (default
477
+ * `20_000`). Keep it under the pod's `terminationGracePeriodSeconds`, which
478
+ * is the reason this one belongs in the environment: the two are set
479
+ * together, in the same manifest.
480
+ */
462
481
  readonly drainTimeoutMs?: number;
463
482
  readonly onEvent?: EventSink;
464
483
  };
package/dist/index.d.mts CHANGED
@@ -74,9 +74,12 @@ type HealthReport = {
74
74
  *
75
75
  * Each check's failure is recovered into a component line BEFORE `allAsync`
76
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.
77
+ * naming one component is worth less than one naming all of them. A check
78
+ * that throws rather than answers is a bug in the check, and it is folded in
79
+ * too: each check is STARTED inside the pipeline, so a synchronous throw and
80
+ * a defecting `AsyncResult` alike are recovered into an unhealthy line —
81
+ * escaped, the first would reach the kernel's own `uncaughtException` handler
82
+ * and the second would leave `/healthz` hanging with nothing written.
80
83
  */
81
84
  declare const runHealthChecks: (checks: readonly HealthCheck[]) => AsyncResult<HealthReport, never>;
82
85
  //#endregion
@@ -439,9 +442,11 @@ type StartOptions<UnitX = never, UnitNeeds = never> = {
439
442
  */
440
443
  readonly env?: Environment;
441
444
  /**
442
- * A module forked around **every unit**: built as the unit opens, torn down
443
- * as it closes — while the unit's ambient record is still open — reading
444
- * anything the application context carries.
445
+ * A module forked around **every unit** — a unit being one bounded piece of
446
+ * work, whatever the transport calls it: an HTTP request, a Temporal
447
+ * activity, an AMQP delivery. Built as the unit opens, torn down as it
448
+ * closes — while the unit's ambient record is still open — reading anything
449
+ * the application context carries.
445
450
  *
446
451
  * A failing unit finaliser is reported as a `teardownError` event and never
447
452
  * in `ExitReport.teardownErrors`. With this option the unit's work runs only
@@ -458,7 +463,21 @@ type StartOptions<UnitX = never, UnitNeeds = never> = {
458
463
  readonly probes?: {
459
464
  readonly port: number;
460
465
  } | false;
466
+ /**
467
+ * How long readiness stays false before the runtime is told to stop
468
+ * accepting. Unset, it is bound from `PRE_DRAIN_DELAY_MS` in `env` (default
469
+ * `5_000`) — the window that covers Kubernetes' eventually-consistent
470
+ * endpoint removal, which is a property of the cluster rather than of the
471
+ * code, so a deployment must be able to set it.
472
+ */
461
473
  readonly preDrainDelayMs?: number;
474
+ /**
475
+ * How long in-flight work gets before it is aborted and reported
476
+ * `abandoned`. Unset, it is bound from `DRAIN_TIMEOUT_MS` in `env` (default
477
+ * `20_000`). Keep it under the pod's `terminationGracePeriodSeconds`, which
478
+ * is the reason this one belongs in the environment: the two are set
479
+ * together, in the same manifest.
480
+ */
462
481
  readonly drainTimeoutMs?: number;
463
482
  readonly onEvent?: EventSink;
464
483
  };
package/dist/index.mjs CHANGED
@@ -37,18 +37,25 @@ var HealthChecks = class extends Port.many("HealthChecks") {};
37
37
  *
38
38
  * Each check's failure is recovered into a component line BEFORE `allAsync`
39
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.
40
+ * naming one component is worth less than one naming all of them. A check
41
+ * that throws rather than answers is a bug in the check, and it is folded in
42
+ * too: each check is STARTED inside the pipeline, so a synchronous throw and
43
+ * a defecting `AsyncResult` alike are recovered into an unhealthy line —
44
+ * escaped, the first would reach the kernel's own `uncaughtException` handler
45
+ * and the second would leave `/healthz` hanging with nothing written.
43
46
  */
44
- const runHealthChecks = (checks) => allAsync(checks.map((health) => health.check().map(() => ({
47
+ const runHealthChecks = (checks) => allAsync(checks.map((health) => OkAsync().flatMap(() => health.check()).map(() => ({
45
48
  name: health.name,
46
49
  status: "healthy"
47
50
  })).recoverErrCases((matcher) => matcher.with(P.tag("HealthCheckFailed"), (error) => ({
48
51
  name: health.name,
49
52
  status: "unhealthy",
50
53
  reason: error.reason
51
- }))))).map((components) => ({
54
+ }))).recoverDefect((cause) => Ok({
55
+ name: health.name,
56
+ status: "unhealthy",
57
+ reason: String(cause)
58
+ })))).map((components) => ({
52
59
  status: components.every((c) => c.status === "healthy") ? "healthy" : "unhealthy",
53
60
  components
54
61
  }));
@@ -360,6 +367,37 @@ const createUnitRegistry = () => {
360
367
  //#endregion
361
368
  //#region src/start.ts
362
369
  const providesEnv = (module) => module.provides.some((provider) => provider.port.portId === Env.portId) || module.imports.some(providesEnv);
370
+ /**
371
+ * What the kernel reads from the environment for itself. Each field is pinned
372
+ * by the matching `StartOptions` field — explicit beats environment beats
373
+ * default, per field, exactly as a starter's own configuration is.
374
+ */
375
+ const KERNEL_DEFAULTS = {
376
+ probePort: 9e3,
377
+ preDrainDelayMs: 5e3,
378
+ drainTimeoutMs: 2e4
379
+ };
380
+ const readKernelConfig = (options, env) => {
381
+ const probePort = options.probes === void 0 ? void 0 : options.probes === false ? KERNEL_DEFAULTS.probePort : options.probes.port;
382
+ const result = Config.object({
383
+ probePort: Config.pinned(probePort, Config.port("PROBE_PORT", { default: KERNEL_DEFAULTS.probePort })),
384
+ preDrainDelayMs: Config.pinned(options.preDrainDelayMs, Config.integer("PRE_DRAIN_DELAY_MS", {
385
+ default: KERNEL_DEFAULTS.preDrainDelayMs,
386
+ min: 0
387
+ })),
388
+ drainTimeoutMs: Config.pinned(options.drainTimeoutMs, Config.integer("DRAIN_TIMEOUT_MS", {
389
+ default: KERNEL_DEFAULTS.drainTimeoutMs,
390
+ min: 0
391
+ }))
392
+ })["~standard"].validate(env);
393
+ return result.issues === void 0 ? Ok(result.value) : Err(new RuntimeStartFailed({
394
+ runtime: "kernel",
395
+ cause: new ConfigInvalid({
396
+ port: "kernel",
397
+ issues: result.issues
398
+ })
399
+ }));
400
+ };
363
401
  const start = (module, options = {}) => {
364
402
  const clock = options.clock ?? systemClock;
365
403
  const env = options.env ?? process.env;
@@ -381,8 +419,8 @@ const start = (module, options = {}) => {
381
419
  const shutdown = Promise.withResolvers();
382
420
  const teardownErrors = [];
383
421
  const startedAt = clock.now();
384
- const preDrainDelayMs = options.preDrainDelayMs ?? 5e3;
385
- const drainTimeoutMs = options.drainTimeoutMs ?? 2e4;
422
+ const kernelConfig = readKernelConfig(options, env);
423
+ const { preDrainDelayMs, drainTimeoutMs } = kernelConfig.getOrElse(() => KERNEL_DEFAULTS);
386
424
  const skipDrain = new AbortController();
387
425
  let shutdownRequestedAt = startedAt;
388
426
  let shutdownRequested = false;
@@ -419,16 +457,7 @@ const start = (module, options = {}) => {
419
457
  const probeBound = Promise.withResolvers();
420
458
  const runtimePublished = Promise.withResolvers();
421
459
  emit({ type: "building" });
422
- 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({
423
- runtime: "probes",
424
- cause: new ConfigInvalid({
425
- port: "probes",
426
- issues: [{
427
- message: reason,
428
- path: ["PROBE_PORT"]
429
- }]
430
- })
431
- }))) : Ok(options.probes);
460
+ const probesOptions = kernelConfig.map(({ probePort }) => options.probes === false ? false : { port: probePort });
432
461
  if (options.probes === false) probeBound.resolve(void 0);
433
462
  const probesStarted = probesOptions.toAsync().flatMap((probes) => probes === false ? OkAsync() : startProbeServer({
434
463
  port: probes.port,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@btravstack/core",
3
- "version": "0.6.0",
3
+ "version": "0.8.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",
@@ -46,20 +46,20 @@
46
46
  },
47
47
  "devDependencies": {
48
48
  "@btravstack/tsconfig": "0.2.0",
49
- "@types/node": "26.2.0",
49
+ "@types/node": "26.4.0",
50
50
  "@unthrown/vitest": "5.7.0",
51
51
  "@vitest/coverage-v8": "4.1.11",
52
52
  "tsdown": "0.22.14",
53
53
  "typescript": "7.0.2",
54
54
  "unthrown": "5.7.0",
55
55
  "vitest": "4.1.11",
56
- "@btravstack/config": "0.6.0",
57
- "@btravstack/di": "0.6.0"
56
+ "@btravstack/config": "0.8.0",
57
+ "@btravstack/di": "0.8.0"
58
58
  },
59
59
  "peerDependencies": {
60
60
  "unthrown": "^5.0.0",
61
- "@btravstack/config": "^0.6.0",
62
- "@btravstack/di": "^0.6.0"
61
+ "@btravstack/config": "^0.8.0",
62
+ "@btravstack/di": "^0.8.0"
63
63
  },
64
64
  "engines": {
65
65
  "node": ">=22"