Skip to content

Serve an oRPC contract over HTTP

How-to. Take a contract, implement it as Result-returning procedures, and serve it under the kernel's lifecycle with @btravstack/http. For the package's full surface, see @btravstack/http; for why the transport maps no Result to a status itself, see The kernel maps nothing.

The starter answers HTTP one way: oRPC, through @orpc/server/node's RPCHandler, one kernel unit per request, the response flushed before the unit closes. What you write is the contract, the router and the composition root. What follows is a minimal, standalone illustration of that shape — one contract, one sync, no controller layer — sized for a single-slice API. For an API that has outgrown one sync, each slice with its own contract fragment and controller, see Split a router into controllers; for the real two-slice deployment this recipe scales into, see Order API (HTTP).

Recipe

  1. Declare the contract with @orpc/contract — inputs, outputs and the .errors({...}) a client may branch on.
  2. Implement it with HttpRouter(contract)(deps, { sync }): a record shaped like the contract, each leaf a Result-returning function.
  3. Compose with HttpModule(name)({ router, imports, provides, exports }).
  4. await runMain(OrdersApi) in main.ts.

Step 1 — the contract

The contract lives in its own package, because a client needs it and needs none of the server:

ts
import { oc, type } from "@orpc/contract";

export type OrderView = { readonly id: string; readonly quantity: number };
export type OrderRef = { readonly id: string };

export const ordersContract = {
  place: oc
    .input(type<{ readonly id: string; readonly quantity: number }>())
    .output(type<OrderView>())
    .errors({
      INVALID_QUANTITY: { data: type<OrderRef>() },
      CONFLICT: { data: type<OrderRef>() },
    }),
  find: oc
    .input(type<OrderRef>())
    .output(type<OrderView>())
    .errors({ NOT_FOUND: { data: type<OrderRef>() } }),
};

Step 2 — the router, as a provider

HttpRouter(ordersContract) is di's own Provider(port) on the starter's router port — there is no name to give, a process serves one router — so the call declares the use cases the procedures call and closes over them. The mapErrCases in each procedure is the one place a domain error becomes an HTTP answer — every case named, no wildcard, so a new domain error is a compile error here:

ts
import { ordersContract, type OrderView } from "./contract.js";
import { FindOrder, PlaceOrder } from "@btravstack/example-order-application";
import type { Order } from "@btravstack/example-order-domain";
import { HttpRouter } from "@btravstack/http";
import { P } from "unthrown";

const view = (order: Order): OrderView => ({
  id: order.id,
  quantity: order.quantity,
});

export const ordersRouter = HttpRouter(ordersContract)(
  [PlaceOrder, FindOrder],
  {
    sync: (place, find) => ({
      place: ({ errors }, input) =>
        place
          .execute(input.id, input.quantity)
          .map(view)
          .mapErrCases((matcher) =>
            matcher
              .with(P.tag("InvalidQuantity"), (error) =>
                errors.INVALID_QUANTITY({
                  message: error.message,
                  data: { id: error.id },
                }),
              )
              .with(P.tag("DuplicateOrder"), (error) =>
                errors.CONFLICT({
                  message: error.message,
                  data: { id: error.id },
                }),
              ),
          ),
      find: ({ errors }, input) =>
        find
          .execute(input.id)
          .map(view)
          .mapErrCases((matcher) =>
            matcher.with(P.tag("OrderNotFound"), (error) =>
              errors.NOT_FOUND({
                message: error.message,
                data: { id: error.id },
              }),
            ),
          ),
    }),
  },
);

Each leaf is the .result() handler @unthrown/orpc gives an implementer: Ok is the output, a returned Err holding an ORPCError is typed end to end (the client sees code: "CONFLICT" as a value), and a Defect rethrows onto oRPC's own path, where it collapses to INTERNAL_SERVER_ERROR. implement, os.…, .result(...) and os.router(...) are what the call does for you. oRPC's context stays empty: what a procedure needs, the provider declared.

Step 3 — the composition root

ts
import { OrderApplicationModule } from "@btravstack/example-order-application";
import { OrderPersistenceModule } from "@btravstack/example-order-infrastructure";
import { HttpModule } from "@btravstack/http";
import { Logger, observability } from "@btravstack/observability";

import { ordersRouter } from "./router.js";

export const OrdersApi = HttpModule("OrdersApi")({
  router: ordersRouter,
  imports: [OrderApplicationModule, OrderPersistenceModule, observability()],
  exports: [Logger],
});

HttpModule is Module(name)({...}) plus router: it imports the starter (http()), provides the router and exports HttpRuntime, and returns exactly the module the hand-written form would:

ts
Module("OrdersApi")({
  imports: [
    OrderApplicationModule,
    OrderPersistenceModule,
    observability(),
    http(),
  ],
  provides: [ordersRouter],
  exports: [HttpRuntime, Logger],
});

observability() is the other starter here: it brings the Logger the use cases and the request scope write to, bound from LOG_LEVEL, one JSON object per line on stdout, every line carrying the trace id of the unit http() opened around the request. It is exported because the per-request RequestModule reads it.

Two gates hold at compile time. A root that forgets the starter exports no runtime port and start fails on arity (NO RUNTIME). A root that imports http() without providing the router carries an unmet need — the starter's runtime provider depends on its router port through di — and start refuses the module.

Step 4 — main.ts

ts
import { runMain } from "@btravstack/core";
import {
  createLogger,
  jsonSink,
  kernelEvents,
} from "@btravstack/observability";

import { OrdersApi } from "./module.js";
import { RequestModule } from "./request-scope.js";

await runMain(OrdersApi, {
  unit: RequestModule,
  onEvent: kernelEvents(createLogger(jsonSink())),
});

That is the whole process. PORT (default 3000), HOST (default 0.0.0.0), LOG_LEVEL (default info) and the kernel's PROBE_PORT are read inside the graph from the Env port; a malformed one is a ConfigInvalid, reported as startFailed and exit 78. RequestModule is optional — see Open a per-request scope — and so is onEvent, which puts the kernel's own lifecycle events in the application's stream rather than the default JSON on stderr; see Log and correlate.

Options

HttpModule(name)({...}) takes imports, provides, exports and:

OptionDefaultWhat it does
routerthe router provider HttpRouter returned; required
prefix/rpcwhere the RPC endpoint is mounted
portPORTpins the port instead of reading the variable
hostnameHOSTpins the host instead of reading the variable

http({ prefix?, port?, hostname? }) takes the last three; the router is not an option but the module's need, provided by the root. Pinning is per field — port: 0 still reads HOST.

What the package decides

Result → status is not in this table: that is the router's .result() triage above. What the package itself answers:

RequestAnswerDecided by
a procedure under prefixits output, or the ORPCError its Result was mapped tooRPC, the router
a defect thrown inside a procedureoRPC's own INTERNAL_SERVER_ERROR collapseoRPC
a path under prefix naming no procedure404 {"error":"NotFound"} — oRPC declines it unwrittenthis package
any path outside prefix404 {"error":"NotFound"} — likewisethis package
the listener resolved without writing404 {"error":"NotFound"}this package
the listener failed before headers were out500 {"error":"InternalError"}this package
a failure with headers already on the wirethe socket is destroyed — a reset, not a hangthis package

The last three are fallbacks the transport proves against a bare listener; the two 500 shapes are unreachable over oRPC, which collapses every defect itself. A StartOptions.unit provider that fails to build gets its 500 from the unit's defect path, before any procedure runs.

Read the bound port back

PORT=0 lets the OS pick; the runtime publishes what it got on Serving.info, read through runtimeInfo():

ts
const app = start(OrdersApi, {
  env: { PORT: "0", HOST: "127.0.0.1" },
  signals: false,
  probes: false,
});
const info = (await app.runtimeInfo()).get(); // HttpInfo | undefined
const origin = `http://127.0.0.1:${info?.port}`;

runtimeInfo() is an AsyncResult<HttpInfo | undefined, never>undefined if the runtime never reached serving.

Correlate requests

Every request is a unit with a minted id: randomUUID(). A non-blank inbound x-request-id header becomes the unit's traceId, so a line logged by an adapter that reads currentUnit() joins a trace that started outside the process; a blank header is ignored rather than winning over the minted id. observability()'s logger reads that record per call, so every line written under the request already carries it — see Log and correlate.

See also

Released under the MIT License.