Skip to content

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

sh
pnpm add @btravstack/core @btravstack/http-server @btravstack/config @btravstack/di unthrown @orpc/server@^2.0.0-beta @orpc/contract@^2.0.0-beta @unthrown/orpc zod
pnpm add -D tsx
sh
npm install @btravstack/core @btravstack/http-server @btravstack/config @btravstack/di unthrown @orpc/server@^2.0.0-beta @orpc/contract@^2.0.0-beta @unthrown/orpc zod
npm install -D tsx
sh
yarn add @btravstack/core @btravstack/http-server @btravstack/config @btravstack/di unthrown @orpc/server@^2.0.0-beta @orpc/contract@^2.0.0-beta @unthrown/orpc zod
yarn add -D tsx

Every one of those but zod is a peer of @btravstack/http-server, so your application holds a single copy of each (why). tsx is the odd one out: a dev dependency, and only to run the TypeScript you are about to write — Step 7 says why Node's own type stripping is not enough here.

The oRPC ranges carry @^2.0.0-beta deliberately. oRPC v2 is pre-release and its latest tag still points at the 1.x line, which @unthrown/orpc does not peer on; an unpinned install resolves the wrong major and the first compile fails (the full list).

The project needs "type": "module" in its package.jsonmain.ts ends in a top-level await — TypeScript in strict mode with moduleResolution: "nodenext" (which is why every relative import below carries a .js suffix), and Node >=22.

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

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)({
      inject: {},
      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

ts
import { oc } from "@orpc/contract";
import { z } from "zod";

export const contract = {
  hello: oc
    .input(z.object({ name: z.string() }))
    .output(z.object({ message: z.string() })),
};

oc is oRPC's contract builder, and the schemas are validated at the boundary: a client that posts { name: 42 } is rejected before hello runs. Reach for oRPC's type<T>() only where you genuinely trust a shape without checking it — it validates nothing, so an unchecked input arrives typed as whatever the contract claimed. 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. Every HTTP entity comes from one defineHttp call — the door where an application declares its security schemes; this service is public, so it takes no argument. Then api.OrpcRouter(contract) types the implementation from the contract — a typo'd key or a wrong output is a compile error here:

router.ts

ts
import { defineHttp } from "@btravstack/http-server";
import { OkAsync } from "unthrown";

import { contract } from "./contract.js";
import { Greeter } from "./greeter.js";

// Held whole and never destructured: each destructured member expands to a
// type mentioning an inaccessible `unique symbol` (TS2527).
const api = defineHttp();

export const greetingRouter = api.OrpcRouter(contract)({
  inject: { greeter: 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

ts
import { HttpModule } from "@btravstack/http-server";

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

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.exitCode0 clean, 78 for a bad configuration variable, 2 for a drain that abandoned work. It never calls process.exit (why).

Step 7 — Run it

sh
PORT=3000 npx tsx src/main.ts

tsx rather than node, and the reason is the .js suffixes. Node >=22.18 does run a .ts entry point by stripping the types, but stripping is all it does: it never remaps ./contract.js to ./contract.ts, and .js is what moduleResolution: "nodenext" makes you write. node src/main.ts therefore ends in ERR_MODULE_NOT_FOUND on a file that plainly exists. tsx resolves the suffix and is what this repository's own examples run.

On stderr, one JSON line per kernel event:

json
{"type":"building"}
{"type":"serving","runtime":"http","info":{"port":3000},"probePort":9000}

info is whatever the runtime published about itself — { port } for this one, which is how a PORT=0 boot tells you what it got — and probePort is the kernel's own listener, not the runtime's.

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

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!
sh
npx tsx src/client.ts

client.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):

sh
kill -TERM <pid>

Then read stderr:

json
{"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

  • Configure and test — the next lesson: bind a setting of your own the way the starter binds PORT, then prove it with a booted test.
  • Log and correlateobservability() next to the starter, and the kernel events above as lines in the same stream.
  • Why btravstack? — the theses this lesson quietly followed.

Released under the MIT License.