Getting started
Tutorial. A hands-on first lesson. Follow it top to bottom and you will have written, booted, called and stopped an HTTP service on the kernel. We keep explanation to a minimum here and link out to it — the goal is to do, not to study.
By the end you will have a process that serves one oRPC procedure, reads its port from the environment inside the graph, and drains cleanly when it is told to stop. It takes about ten minutes.
Step 1 — Install
pnpm add @btravstack/core @btravstack/http @btravstack/config @btravstack/di unthrown @orpc/server @orpc/contract @unthrown/orpcnpm install @btravstack/core @btravstack/http @btravstack/config @btravstack/di unthrown @orpc/server @orpc/contract @unthrown/orpcyarn add @btravstack/core @btravstack/http @btravstack/config @btravstack/di unthrown @orpc/server @orpc/contract @unthrown/orpcEvery one of those is a peer of @btravstack/http, so your application holds a single copy of each (why). The project needs "type": "module" in its package.json — main.ts ends in a top-level await — TypeScript in strict mode, and Node >=20.
Not yet published
This repository has not cut a release, so there is nothing on npm to install yet. The commands above are what they will be once it has.
Step 2 — Declare a service
A service is a port — a name with a service type — and a provider that builds it. Both live in a module, which says what it provides and what it lets others see:
// greeter.ts
import { Module, Port, Provider } from "@btravstack/di";
export class Greeter extends Port("Greeter")<{
readonly greet: (name: string) => string;
}> {}
export const GreetingModule = Module("Greeting")({
provides: [
Provider(Greeter)({ value: { greet: (name) => `Hello, ${name}!` } }),
],
exports: [Greeter],
});Nothing here knows about HTTP. That is the point: the module is the application, and a runtime is something you compose around it in Step 5.
Step 3 — Write the contract
The transport speaks a contract, declared before any implementation exists. One procedure, hello, with a typed input and output:
// contract.ts
import { oc, type } from "@orpc/contract";
export const contract = {
hello: oc
.input(type<{ readonly name: string }>())
.output(type<{ readonly message: string }>()),
};oc is oRPC's contract builder; type<T>() declares a shape without a schema library. A client can import this file and call the service without the server's code — which is why it is its own file.
Step 4 — Implement the contract as a router
The router is a provider like any other: it declares the services its procedures call, and di builds it from them. HttpRouter(contract) types the implementation from the contract — a typo'd key or a wrong output is a compile error here:
// router.ts
import { HttpRouter } from "@btravstack/http";
import { OkAsync } from "unthrown";
import { contract } from "./contract.js";
import { Greeter } from "./greeter.js";
export const greetingRouter = HttpRouter(contract)([Greeter], {
sync: (greeter) => ({
hello: (_helpers, input) => OkAsync({ message: greeter.greet(input.name) }),
}),
});Each leaf is a plain function returning a Result. OkAsync is the success case; a declared error would be returned as an Err from the helpers.errors map, and the client would receive it typed. Nothing is thrown, and no os.… or implement(...) is spelled — the starter does that.
Step 5 — Compose the application
HttpModule(name)({...}) is a di Module(name)({...}) that also takes the router. Under the hood it imports the HTTP starter, provides the router and exports HttpRuntime — the one port the kernel resolves and drives:
// app.ts
import { HttpModule } from "@btravstack/http";
import { GreetingModule } from "./greeter.js";
import { greetingRouter } from "./router.js";
export const App = HttpModule("App")({
router: greetingRouter,
imports: [GreetingModule],
});Try deleting imports: [GreetingModule] and watch the call fail to compile: the router's provider declares Greeter, and nothing supplies it. That is di's gate (Compile errors, not surprises), and it fires before any process exists.
Step 6 — Write main.ts
// main.ts
import { runMain } from "@btravstack/core";
import { App } from "./app.js";
await runMain(App);That is the whole entry point. runMain builds the graph, resolves HttpRuntime, serves it, waits for the process to exit and sets process.exitCode — 0 clean, 78 for a bad configuration variable, 2 for a drain that abandoned work. It never calls process.exit (why).
Step 7 — Run it
PORT=3000 node src/main.tsNode >=22.18 runs a .ts entry point directly by stripping the types; on an older Node, npx tsx src/main.ts does the same. On stderr, one JSON line per kernel event:
{"type":"building"}
{"type":"serving","runtime":"http"}PORT was read inside the graph — the starter binds PORT (default 3000) and HOST (default 0.0.0.0) onto a HttpConfig port from the Env port the kernel provides. Try PORT=abc instead: the process prints a startFailed event naming the variable and exits 78, without your code having parsed anything.
Step 8 — Call it
The contract types the client too. RPCLink speaks oRPC's RPC protocol to the endpoint the starter mounted under /rpc:
// client.ts
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import type { RouterContractClient } from "@orpc/contract";
import { contract } from "./contract.js";
const client: RouterContractClient<typeof contract> = createORPCClient(
new RPCLink({ origin: "http://localhost:3000", url: "/rpc" }),
);
const { message } = await client.hello({ name: "world" });
console.log(message); // Hello, world!node src/client.tsclient.hello takes { name: string } and returns { message: string } because the contract says so — the router file was never imported.
A Result client
@unthrown/orpc/client's createResultClient wraps this client so every call returns an AsyncResult whose error channel is the contract's declared errors — the shape examples/order-api uses. See Serve an oRPC contract over HTTP.
Step 9 — Stop it
Send the process a SIGTERM (Ctrl-C sends SIGINT, which takes the same path):
kill -TERM <pid>Then read stderr:
{"type":"draining","inFlight":0}
{"type":"drained","report":{"inFlightAtStart":0,"completed":0,"abandoned":0}}
{"type":"stopping"}
{"type":"exited"}Between draining and drained, three things happened in order: readiness flipped false, the kernel waited five seconds before telling the runtime to stop accepting, and in-flight requests were given twenty seconds to finish. The wait is deliberate — Kubernetes removes a pod from its endpoints eventually, not instantly, so a process that stops accepting the moment SIGTERM lands rejects traffic still being routed to it. The whole argument is in Draining, in three beats; the two numbers are preDrainDelayMs and drainTimeoutMs on StartOptions.
Where next
- The same application, a second runtime — boot
GreetingModuleas a Temporal worker, and see why the kernel insists on one runtime per process. - Configure from the environment — bind your own configuration slice the way the starter binds
PORT. - Log and correlate —
observability()next to the starter, and the kernel events above as lines in the same stream. - Test an application —
bootFixturefrom@btravstack/testing, and bootingAppon port0. - Why start? — the theses this lesson quietly followed.