@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.
Diagnostic disclosure
DiagnosticPolicy is the shared structural contract for rendering diagnostic
messages, attributes and causes. Pass the same policy object explicitly to
logging, tracing and Temporal adapters. Its message, attributes and cause
methods must be synchronous and must not throw; rendering leaves the original
Result and its failure channel unchanged.
@btravstack/observability implements the production policy
as diagnosticPolicy() and exposes sensitive debugging as an explicit separate
choice. The kernel declares the type without implementing disclosure rules or
adding a policy port. See the
observability contracts.
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. AHealthCheckcan opt into gating/readyzwithreadiness: true(1000 ms cache) orreadiness: { intervalMs: 5000 }. Concurrent probes and/healthzshare that check's cache; ordinary checks only affect/healthz. Failed checks can recover without a restart; drain and crash readiness remain latched.RunningApp.ready()reads only the lifecycle. See probes. - 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