skill
btravstack
A backend framework for Node.js and TypeScript — dependency injection the compiler proves, errors as values instead of exceptions, and HTTP, Temporal or AMQP behind one lifecycle.
Set up
Skills are installed by placing their folder where your agent loads skills.
The download comes from github.com/btravstack/btravstack itself, not from our server, so what you get is exactly what its author published.
Install instructions come from the README
README
synced Oct 20, 2026btravstack
A backend framework for Node.js and TypeScript — dependency injection the compiler proves, errors as values instead of exceptions, and a process that shuts down the way Kubernetes expects.
Documentation · Get Started · Why btravstack?
btravstack is the layer between your business logic and the process it runs in. You build an application out of ports and providers, and the compiler checks that everything one needs, another supplies. A starter then brings the transport — an HTTP server, a Temporal worker, an AMQP consumer — and the framework owns boot, readiness and shutdown.
It is not a full-stack framework: no ORM, no templating, no frontend. What
it replaces is the main.ts every backend writes by hand and gets subtly wrong.
What you get
| Dependency injection | Plain values — no decorators, no reflect-metadata. An unmet dependency is a compile error. |
| Errors as values | Every fallible call returns a Result: the failure is in the type, and a mapErrCases triage stops compiling when a new case arrives. Dropping one unread is a lint error (unthrown/no-unhandled-result), not a compile error. |
| Domain modelling | Optional @btravstack/entity: sealed entities, aggregate decisions, and Zod schemas. |
| Configuration | Environment variables validated once at boot into typed values; a bad one exits 78 naming it. |
| Three transports | HTTP (contract-first, over oRPC), Temporal workers, AMQP consumers. |
| Observability | Structured logs correlated per request, OpenTelemetry traces and metrics. |
| Lifecycle | Health probes, graceful drain, resource cleanup on every exit path. |
| Testing | A harness that boots the real graph and swaps one provider at a time. |
Why btravstack?
Every backend process gets the same things wrong on its own: it stops
accepting the instant SIGTERM lands and rejects the traffic Kubernetes is
still routing to it; its readiness probe answers from a transport rather than
from its state; a failed finaliser is logged and forgotten; a crash exits 0.
btravstack gets them right once, as defaults.
- 🧩 Business code only. A composition root is a
HttpModule("OrdersApi")({ router, imports, exports, needs })and amain.tsisawait runMain(OrdersApi). Configuration is bound from the environment inside the graph; there is noprocess.env, noapp.listen, no signal handler to write. - 🛂 Wiring proven at compile time. A module missing a provider, a composition root exporting no runtime, a runtime naming a port the graph does not carry — each is a type error at the call site, not a crash at boot.
- 🚦 A drain that survives Kubernetes. Readiness flips first, the kernel
waits out the eventually-consistent endpoint removal, then in-flight work
gets its deadline; whatever is still open is aborted and reported
abandoned. - 🎯 Nothing throws. Every async surface is an
unthrownAsyncResult; the kernel never callsprocess.exit, andrunMainis the one place a process's fate becomes an exit code —0,1,2,70or78, each meaning one thing.
How it compares
| btravstack | NestJS | AdonisJS | Hand-rolled | |
|---|---|---|---|---|
| Wiring checked | at compile time, across modules: visibility, scopes, transitive needs | at boot | at boot | each call's own signature |
| Dependency injection | plain values | decorators + metadata | decorators + metadata | by hand |
| Errors | values, typed | exceptions + filters | exceptions + handlers | your choice |
| Graceful shutdown | on SIGTERM by default, with a Kubernetes pre-drain delay | opt-in, enableShutdownHooks() | on SIGTERM, through providers' shutdown() | write it yourself |
| Ecosystem | small, growing | very large | large | none |
| Full-stack | no | no | yes | — |
Every framework in that table manages its lifecycle, and so do
Spring Boot
(graceful shutdown on by default), the
.NET Generic Host
and Effect's runMain. The
difference is narrower. The compiler checks the composition (module
visibility, scopes and transitive needs, not only a constructor's arguments),
and what it cannot see is a defect before any factory runs. The pre-drain
delay, the drain's deadlines and its unit accounting are defaults rather than
deployment work. The AdonisJS row is from
its v6 lifecycle
and service providers
pages.
NestJS has far more packages, integrations and hiring pool, and decorators are more concise to write. If that trade matters more than compile-time certainty, Nest is the better tool — the Coming from NestJS page says so in detail, including what Nest does better.
btravstack is for teams that already chose TypeScript for the type safety and want the framework to honour that choice rather than opt out of it. See Why btravstack? for the design argument and what it is not.
The entity guide shows how to model the domain used by the examples. It is usable without the process framework.
Install
pnpm add @btravstack/core @btravstack/config @btravstack/di unthrown
For an HTTP API add the starter and its peers:
pnpm add @btravstack/http-server @btravstack/orpc-server @btravstack/htmx-server @orpc/server@2.0.0-beta.44 @orpc/contract@2.0.0-beta.44 @orpc/openapi@2.0.0-beta.44 @orpc/json-schema@2.0.0-beta.44 @orpc/client@2.0.0-beta.44 @unthrown/orpc@^0.4.2
Everything is a peer dependency — the application holds one copy of each,
which is what keeps port identity and isResult honest across packages. The
kernel and @btravstack/config depend on node: builtins only. They require
Node >=22; the HTTP starter above requires >=22.12.
Every package shares one version, pre-1.0 minors may break, and security fixes land on the latest release only — read Support and upgrades before adopting. Each release's notes, migration steps included, are on its release pull request.
Working with a coding agent? Install the framework's
agent skill:
npx skills add btravstack/btravstack --skill btravstack.
Quick example
An HTTP API is a contract, a router that implements it from the use cases it
declares, and a composition root. This is
examples/order-api's orders slice, served on its
own and condensed to one procedure — the example itself composes that slice
and a customers one into a single router through controllers:
contract.ts — declared before any implementation exists; a client needs only this file.
import { oc, type } from "@orpc/contract";
export const ordersContract = {
place: oc
.input(type<{ readonly id: string; readonly quantity: number }>())
.output(type<{ readonly id: string; readonly quantity: number }>())
.errors({
INVALID_QUANTITY: { data: type<{ readonly id: string }>() },
BAD_REQUEST: { data: type<{ readonly id: string }>() },
CONFLICT: { data: type<{ readonly id: string }>() },
}),
};
router.ts — one Result-returning function per procedure, typed by the contract.
Three names in the fences below are yours, not this framework's, and
nothing here declares them for you: PlaceOrder is a di Port carrying your
use case and its modeled errors (InvalidQuantity, InvalidOrderId,
DuplicateOrder); OrderApplicationModule provides it; OrderPersistenceModule
holds the adapters behind it. Together they are about twenty lines of ordinary
TypeScript — the
tutorial
writes every one from an empty directory.
import { defineHttp } from "@btravstack/orpc-server";
import { P } from "unthrown";
// One call mints every marker-typed HTTP entity; a public API declares no
// security scheme, so it takes no argument.
const api = defineHttp();
export const ordersRouter = api.OrpcRouter(ordersContract)({
inject: { place: PlaceOrder },
sync: ({ place }) => ({
place: ({ errors }, input) =>
place
.execute(input.id, input.quantity)
.map((order) => ({ id: order.id, quantity: order.quantity }))
// The one place a domain error becomes a transport one — exhaustive,
// so a new domain error is a compile error here.
.mapErrCases((matcher) =>
matcher
.with(P.tag("InvalidQuantity"), (error) =>
errors.INVALID_QUANTITY({
message: error.message,
data: { id: error.id },
}),
)
// A malformed id is the caller's mistake, so 400 — not the
// 409 a duplicate gets.
.with(P.tag("InvalidOrderId"), (error) =>
errors.BAD_REQUEST({
message: error.message,
data: { id: error.id },
}),
)
.with(P.tag("DuplicateOrder"), (error) =>
errors.CONFLICT({
message: error.message,
data: { id: error.id },
}),
),
),
}),
});
main.ts — the composition root and the entry point; this is the whole process.
import { runMain } from "@btravstack/core";
import { HttpModule } from "@btravstack/orpc-server";
const OrdersApi = HttpModule("OrdersApi")({
router: ordersRouter,
imports: [OrderApplicationModule, OrderPersistenceModule],
});
await runMain(OrdersApi);
HttpModule is sugar over the same primitives: it imports the starter
(PORT and HOST bound from the environment, the router mounted under
/rpc), provides the router and exports the runtime port, and returns exactly
the di module a hand-written Module("OrdersApi")({...}) would have. runMain
builds the graph, serves it, drains it on SIGTERM in three beats, and sets the
exit code. Boot the same OrderApplicationModule under TemporalModule or
AmqpModule and you have the worker and the consumer — one application, three
deployments.
Packages
| Package | Description |
|---|---|
@btravstack/core | The kernel: start, runMain, the lifecycle state machine, the unit-of-work registry, the Runtime contract, probes, exit codes. |
@btravstack/di | The container: ports, providers, modules, and wiring checked by the compiler. Depends on nothing but unthrown. |
@btravstack/config | Configuration from the environment as providers: Config.string/integer/port, Config.object, Config.provider, ConfigInvalid → 78. |
@btravstack/testing | The test harness: bootFixture for test.extend, tapped to read services out of a booted app, testRuntime, createFakeClock, withApp. |
@btravstack/http-server | The Node HTTP runtime, request units, authentication and graceful drain. |
@btravstack/orpc-server | Contract-first oRPC answerer, defineHttp, HttpModule and OpenAPI routes. |
@btravstack/htmx-server | Escaped HTML fragments under the shared HTTP runtime. |
@btravstack/graphql-server | GraphQL Yoga answerer for a supplied schema; the example runs it as a separate gateway. |
@btravstack/mcp-server | Model Context Protocol answerer over the official SDK, authenticated through the shared schemes. |
@btravstack/pothos | Pothos fields that call oRPC procedures, each declared error a typed member of the field's result union, a registry that misses one call can fail with a compile error. |
@btravstack/temporal-worker | The Temporal starter: TemporalActivities / TemporalWorkflowActivities / TemporalModule, one unit per activity attempt, a drain that honours the kernel's deadline. |
@btravstack/amqp-worker | The AMQP starter: AmqpHandlers / AmqpHandler / AmqpModule, one unit per delivery, one drain deadline. |
@btravstack/temporal-client | Scoped Temporal connection and typed client providers, without a worker peer. |
@btravstack/http-client | Policy-bound outbound HTTPS with approved origins, checked destinations and bounded responses. |
@btravstack/oauth-client | A TokenSource port over the OAuth 2.0 client credentials grant, so a service calls another as itself — cached per audience, one fetch for concurrent callers. |
@btravstack/amqp-client | Scoped typed AMQP client provider, without a worker peer. |
@btravstack/contract | The contract tier a client and its server share: which schemes protect a route and which scopes each must grant, and one cursor page. Zero dependencies in the root. |
@btravstack/observability | Structured logging correlated with the ambient unit, a JSON sink, and OpenTelemetry traces and metrics behind their own subpath. |
@btravstack/prisma | The Prisma starter: DATABASE_URL through Config, a client whose pool is the application scope's, and a count and error line per query. |
@btravstack/outbox | The transactional outbox relay: publishes committed facts in outbox order through a publisher you provide, with a per-tenant claim so replicas take turns rather than race for the same rows, and a Prisma 8 store. |
@btravstack/cache | A Cache port with an in-memory adapter and a Redis one, reporting every call through Observers. |
@btravstack/mailer | A Mailer port with a recording adapter and an SMTP one, reporting every send through Observers. |
@btravstack/storage | A Storage port with an in-memory adapter and an S3-compatible one with presigned reads and uploads (upload URLs can be reused until expiry). |
@btravstack/keys | An EnvelopeKeys port: a root key per owner, wrapped by an AWS KMS key of that owner, cached for a TTL, erased with one key deletion, and an HKDF key per purpose. |
Examples
Ten small packages under ./examples model one clean-architecture
order application — domain, application, infrastructure, three contracts —
booted under three runtimes: order-api answers over HTTP,
order-temporal-worker orchestrates, order-amqp-worker broadcasts every
committed write from a transactional outbox — and the same DuplicateOrder
becomes a typed CONFLICT on the first and a non-retryable contract error on
the second, with no mapping anywhere near the kernel. Plus the
container's own di-hexagonal, which never calls start. Each is
compiled, linted and tested by CI like a package. See the
annotated walkthroughs.
Contributing
This is a pnpm + turbo monorepo. The gate — every change keeps all six green, and CI runs the same set:
pnpm install
pnpm format --check # oxfmt (run without --check to auto-fix)
pnpm lint # oxlint, incl. every @unthrown/oxlint rule
pnpm typecheck # tsc, incl. the type-level *.test-d.ts files
pnpm knip # dead code / unused deps
pnpm test # vitest + v8 coverage (100% lines/functions on packages)
pnpm build # tsdown CJS/ESM + d.ts (Temporal worker: ESM only)
The gate needs a running Docker daemon. Every workspace that boots a real
dependency — a broker, a workflow platform, a database, a cache, an SMTP
server, an object store — attaches to a container started once per machine
and shared, rather than starting one of its own
(internal/test-infra, which owns the list). Isolation
is the boundary each system already has, minted in setup and never cleaned up:
a vhost per test, a namespace per spec file, a tenant per test, a
Redis key prefix per test, a recipient per test, and an object-store
key prefix per test. Remove the containers with
docker rm -f $(docker ps -aq --filter label=com.btravstack.test-infra).
Commits follow Conventional Commits; user-facing changes carry a changeset.
AGENTS.md is the authoritative spec — the theses, the public
surface and the conventions, with the reasoning behind each.
License
MIT © Benoit TRAVERS