@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 +7 -4
- package/dist/index.cjs +46 -17
- package/dist/index.d.cts +25 -6
- package/dist/index.d.mts +25 -6
- package/dist/index.mjs +46 -17
- package/package.json +6 -6
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
|
|
116
|
-
to stop accepting — endpoint removal is eventually
|
|
117
|
-
in-flight work gets `drainTimeoutMs` (
|
|
118
|
-
open is aborted and reported
|
|
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.
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
-
})))
|
|
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
|
|
386
|
-
const drainTimeoutMs =
|
|
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 =
|
|
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.
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
|
443
|
-
*
|
|
444
|
-
*
|
|
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.
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
|
443
|
-
*
|
|
444
|
-
*
|
|
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.
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
-
})))
|
|
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
|
|
385
|
-
const drainTimeoutMs =
|
|
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 =
|
|
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.
|
|
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.
|
|
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.
|
|
57
|
-
"@btravstack/di": "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.
|
|
62
|
-
"@btravstack/di": "^0.
|
|
61
|
+
"@btravstack/config": "^0.8.0",
|
|
62
|
+
"@btravstack/di": "^0.8.0"
|
|
63
63
|
},
|
|
64
64
|
"engines": {
|
|
65
65
|
"node": ">=22"
|