Skip to content

Why btravstack? ​

Explanation. This page is about understanding — why the kernel exists, where its edges are, and what was rejected. To get your hands on it first, start with the Getting started tutorial; for the surface itself, see start and StartOptions.

@btravstack/core is an application kernel. It takes a @btravstack/di module whose wiring has already been proven, builds it once, hands the graph to one runtime, drains in-flight work when the process is told to stop, and closes the application scope on every path out. It owns three things — the lifecycle state machine, the unit-of-work registry and the Runtime contract — and nothing else. It knows nothing about HTTP, AMQP or Temporal.

That sentence is the whole design. Everything below is what follows from holding it.

di proves the graph; @btravstack/core owns when ​

di answers "does this composition hold together?" before the process exists: a missing provider, a private port reached across a module boundary, a runtime whose ports the root does not export — each is a compile error at the call site (see Compile errors, not surprises). What the types cannot see — a dependency cycle, two providers for one port in modules that never see each other — is a wiring defect, found when the graph is assembled and before any factory runs. By the time a factory runs, there is nothing left to discover about the wiring.

So @btravstack/core does not wire. It decides when the already-proven graph is constructed and when it is torn down. Construction is Module.scoped; teardown is the scope closing; between the two sits the runtime, serving. The kernel is DI initialisation plus lifecycle, and it deliberately stops there.

What it is not ​

Three things it is easy to mistake it for.

Not NestJS's NestFactory.create. In Nest, create(AppModule) is the wiring step: decorator metadata is read at runtime, tokens are resolved, the injector graph is built, and a missing dependency throws at boot. Here that work is done by the type checker before the process starts, so there is no equivalent step for start to perform. The accepted cost is that there is no auto-discovery — a provider and its dependency array are written out, and that array is what buys the compile-time checking.

Not Effect's runtime. Effect owns how your code runs: fibers, interruption, Layer, a program written as Effect values and interpreted by a runtime. btravstack owns none of that. A use case here is a plain function returning an unthrown Result; the kernel never sees inside it. Effect also owns the process edge: its Node runMain interrupts on a signal, runs teardown, reports the failure and sets the exit code. What the kernel adds to that edge is Kubernetes-shaped — readiness probes from the lifecycle state, a pre-drain delay, a deadline-bounded drain that counts and reports its units — around code that is not written as Effect values.

Not a framework. There is no router, no ORM, no validation layer, no logger, no middleware chain in the kernel. Everything of that kind arrives as a starter — @btravstack/http-server, @btravstack/temporal-worker and @btravstack/amqp-worker, each a module that provides a runtime on a port the kernel resolves, and @btravstack/observability, which provides a Logger and no runtime at all. A starter is opinionated about its one concern and brings nothing else; the kernel still takes no logger dependency and emits events instead. Its public surface is small enough to hold in your head, and it is meant to stay that way.

What a hand-rolled main.ts gets wrong ​

Every backend process has a main.ts, and most of them contain some version of process.on("SIGTERM", () => server.close(() => process.exit(0))). That line is wrong in four separate ways, and each one is a thing the kernel exists to own.

The drain. Kubernetes removes a pod from its endpoints eventually, not atomically with SIGTERM. A process that stops accepting the instant the signal lands rejects requests the ingress is still routing to it. The kernel flips readiness first, waits a pre-drain delay, and only then tells the runtime to stop accepting — and gives in-flight work a deadline after that. The whole sequence, and why each beat is where it is, is on Draining, in three beats.

The probes. A liveness or readiness endpoint that lives on the application's HTTP port answers from the transport, not from the process's actual state — and a Temporal worker has no HTTP port to put it on. The kernel runs its own probe server, answering /livez and /readyz from the lifecycle state machine, on a port of its own, up before the graph is built.

Teardown on every path. The database pool has to be released whether the process stopped on a signal, on a runtime that refused to start, on a construction failure or on an uncaught exception. Hand-rolled shutdown handles the path its author thought of. Module.scoped closes the scope on all of them, and a finaliser that fails is collected into the exit report rather than allowed to mask why the process stopped.

The exit code. process.exit(0) truncates pending output, and — the less obvious half — installing an uncaughtException handler at all suppresses Node's default exit code of 1, so a process that crashes can report success to its orchestrator. runMain sets process.exitCode in exactly one place from a small table, and 70 for a crash exists precisely to close that hole. See Nothing throws.

The aim ​

The kernel is one piece of a stack with a stated goal: you write business code, the framework owns the plumbing, and the type checker is what you trust. A composition root that forgets its runtime is a compile error, not a boot-time crash. Configuration is a provider bound from the environment and validated once, not a string read at call time. A per-request scope is a starter's own bound unit option, forked by the runtime through UnitHost.fork, not a forkScope call in every handler.

The model for the extensibility half is Spring Boot: an opinionated default for the standard case, configurable where a deployment differs, and a starter per transport that brings the one way that transport is done here. Being opinionated is what makes the surface small; being extensible through ordinary di modules is what keeps it from being a cage.

The family ​

@btravstack/core sits on three packages and under four more.

The three below core are its peer dependencies, not dependencies: port identity and isResult both compare across copies, so an application must hold exactly one of each. The kernel itself depends on node: builtins only — see Peer dependencies.

Side by side ​

NestJSEffecthand-rolled main.tsbtravstack
Wiring checkedat boot, from decorator metadataat compile time, through Layer typeseach call's own signatureat compile time, at the Module and start call
Missing dependencyboot-time exceptioncompile errorcompile error at a typed call; scopes and visibility uncheckedcompile error
Failuresthrown, caught by filterstyped error channel of Effectthrownunthrown Result; the kernel never throws
Stop on SIGTERMenableShutdownHooks() + app.close()runMain: fiber interruption, Scope releaseserver.close()readiness off, pre-drain delay, deadline, abandoned count
Probes@nestjs/terminus, on the app's HTTP routesnonea route you writethe kernel's own server, from the state machine
Exit codenot setrunMain: 1 on failureprocess.exit(0)runMain's table, via process.exitCode
Transportbuilt in (Express/Fastify adapters)@effect/platformwhatever you importa starter module providing a Runtime port
Per-request scoperequest-scoped providers bubble up the chainLayer per request, by handclosuresa bound unit module, forked by the runtime

The row that matters most is the first: everything else the kernel does is only safe because what the types cannot prove about the graph is found before any factory runs, not halfway through construction.

Where to go next ​

Released under the MIT License.