@btravstack/core
v0.36.0
Published
The application kernel: boot a di module into a running process, and stop it without losing work
Maintainers
Readme
@btravstack/core
The application kernel: boot a
@btravstack/dimodule into a running process, and stop it again without losing work — one lifecycle state machine, one unit-of-work registry, oneRuntimecontract. It knows nothing about HTTP, AMQP or Temporal, never throws, and never callsprocess.exit.
📖 Documentation · Get Started · Reference · API Reference
pnpm add @btravstack/core @btravstack/config @btravstack/di unthrown@btravstack/config, @btravstack/di and unthrown are peer
dependencies — install all four. The kernel itself depends on node:
builtins only. Node >=22.
A worked example
An application is a di module. A runtime is a service of that module,
provided on a port declared over RuntimePort; start finds it there, builds
the graph, and drives what it finds. The transport starters
(@btravstack/http-server, @btravstack/temporal-worker,
@btravstack/amqp-worker) provide real runtimes; this one is a timer, so
the sample stays self-contained:
import { Module, Port, Provider } from "@btravstack/di";
import {
RuntimePort,
runMain,
start,
type Runtime,
type Serving,
} from "@btravstack/core";
import { Ok, OkAsync } from "unthrown";
class Greeter extends Port("Greeter")<{
readonly greet: (name: string) => string;
}> {}
const AppModule = Module("App")({
provides: [
Provider(Greeter)({
inject: {},
value: { greet: (name: string) => `hello, ${name}` },
}),
],
exports: [Greeter],
});
// A runtime owns the transport; the kernel owns the lifecycle. This one is a
// timer, so the sample stays self-contained — no published runtime models a
// timer, and `@btravstack/http-server` would pull in a real dependency this
// sample doesn't need.
const ticker: Runtime<typeof Greeter> = {
name: "ticker",
resolves: [Greeter],
start: (host) => {
const timer = setInterval(() => {
// Every piece of work goes through `host.run`: that is what makes it
// count towards the drain, and what gives it an `AbortSignal`.
//
// The unit's `Result` is the runtime's to map — the kernel hands it back
// and stays out of it. A timer has nowhere to return one, so it observes
// it instead; dropping it would hide the work's `Err` *and* a `Defect`.
void host
.run({ kind: "tick", id: `${Date.now()}` }, (unit, signal) =>
signal.aborted ? Ok("") : Ok(unit.ctx.get(Greeter).greet("world")),
)
.tapFailure((failure) => {
process.stderr.write(`${JSON.stringify({ tick: failure.tag })}\n`);
});
}, 1_000);
const serving: Serving = {
// Stop accepting new work. In-flight units are the kernel's business.
drain: () => {
clearInterval(timer);
return OkAsync();
},
stop: () => OkAsync(),
};
return OkAsync(serving);
},
};
// A runtime is a service the module provides, on a port declared over
// `RuntimePort` — `start` finds it by that port in the module's exports. The
// composition root is what differs between an `api`, a `worker` and a
// `consumer` process; the application module is the same in all three.
class Ticker extends RuntimePort<Runtime<typeof Greeter>> {}
const TickerApp = Module("TickerApp")({
imports: [AppModule],
provides: [Provider(Ticker)({ inject: {}, value: ticker })],
exports: [Greeter, Ticker],
});
await runMain(TickerApp);runMain is the front door: it boots the module, awaits the application's exit
and turns the outcome into a process exit code — one call, and the whole of a
main.ts. Underneath it is start, which returns immediately with a
RunningApp and decides nothing about the process. The runtime's declared
needs are checked against the module's exports at compile time — a
TickerApp that does not export Greeter is a type error at the call, and so
is a module that exports no runtime port at all.
For a runtime author
Serving carries an optional third member, stopped — the channel a
runtime reports its own death on, () => AsyncResult<void, never>. Without it
the lifecycle only ever moves on a signal or a stop() call, so a worker whose
poll loop died stays alive with /readyz answering 200: a pod in a Service's
endpoints, consuming nothing. A runtime that can stop on its own account should
implement it; the ticker above does not, because a setInterval has no third
state to report, and that is why the field is optional rather than required.
The obligation is the half a signature cannot express: withdraw after a stop
the kernel asked for. An arm that settles on the ordinary path races every
clean shutdown. @btravstack/temporal-worker is the worked implementation, and
the runtime reference
states the contract in full.
Two helpers sit beside the Runtime contract, because every transport needs
them and none should own a private copy:
releasedBy(signal, running)—running, but no later than the kernel's drain deadline, for aServing.drainwhose work settles on somebody else's clock.traceIdOfTraceparent(header)— the trace-id field of a W3Ctraceparent, and nothing else of it: the parent's span id is dropped, sinceUnitMeta.traceIdis a correlation id rather than a span context. Answersundefinedfor anything the specification calls invalid — a malformed header, an all-zero trace id, an all-zero parent id, the reserved versionff— so your runtime falls back to its own id instead of adopting one that means nothing. A higher version's extra fields are read rather than refused.
Application infrastructure exports
infrastructureExports(imports, provides) returns the publicly available
Logger and HealthChecks ports from imported modules and root providers.
InfrastructureExports<I, P> describes those ports at the type level.
It follows whole-module re-exports while keeping private providers and business
ports hidden. Application starters use it alongside their own runtime exports.
What you get
- A drain that survives Kubernetes. On SIGTERM: readiness flips false, the
kernel waits
preDrainDelayMs(PRE_DRAIN_DELAY_MS, default5_000) before telling the runtime to stop accepting — endpoint removal is eventually consistent — then in-flight work getsdrainTimeoutMs(DRAIN_TIMEOUT_MS, default20_000); whatever is still open is aborted and reportedabandoned. The teardown that follows getsstopTimeoutMs(STOP_TIMEOUT_MS, default5_000), so areleasethat never settles is reported asabandonedAt: "stop"rather than leaving the process instoppingwith no exit report at all. A second signal stops every wait at once. The three sum to the pod'sterminationGracePeriodSecondsdefault of 30 s, which is why the deployment sets them and the options only pin them. - Probes from the state machine.
GET /livezandGET /readyzonPROBE_PORT(default9000) andPROBE_HOST(default0.0.0.0, so a kubelethttpGetprobe reaches them), up before the graph is built. - Configuration from the environment, typed. The kernel provides
@btravstack/config'sEnvport to every graph it boots; a bad value is aConfigInvalidnaming every fault at once, and exit78. - Teardown on every path, with failing finalisers in
ExitReport.teardownErrors, never masking the exit reason. - A per-unit scope no handler manages — the runtime calls
unit.fork(module, seed)from insidehost.run's work callback, and the kernel closes the scope when the unit settles. - An ambient record, not an ambient container —
currentUnit()reads{ unitId, traceId, tenantId, signal }; services never travel there.signalis the veryAbortSignalthe unit's work callback is handed, so a runtime whose work is a library'snext()— a Temporal activity, an AMQP delivery — can still honour the drain deadline. - Observation as a set port, not a logger in every starter. A starter
hands each operation to
Observers—observe(observers, operation)for a span-shaped start-then-settle,observed(observers, operation, call)around oneAsyncResult-returning call — and holds noLogger,MeterorTracerof its own. Composing@btravstack/observabilityis what turns those operations into lines, spans and instruments; composing nothing costs an inert call. - Nothing throws. Every async surface is an
unthrownAsyncResult;runMainsetsprocess.exitCode—0clean,1a modeled startup error,2abandoned work or a failed finaliser,70a crash,78a bad environment.
Every sample above is compiled by
src/docs-examples.test-d.ts. The rest —
the Runtime contract, the drain, embedding without runMain — is on the
documentation site.
Testing what the kernel boots is @btravstack/testing's job — a
bootFixture for test.extend, tapped to read services out of a running
app, an in-memory runtime and a fake clock — kept out of this package so a
production bundle never pulls the fakes in.
License
MIT © Benoit TRAVERS
