Skip to content

@btravstack/http

Reference. A complete, structured description of the HTTP starter's public surface: every export of @btravstack/http, its options and their defaults, and what the package decides about a request. For the task, see Serve an oRPC contract over HTTP; for the reasoning behind a starter, see Starters and The kernel maps nothing; for the worked example, Order API. Generated signatures are under API reference.

Exports

packages/http/src/index.ts exports exactly this:

ExportKindWhat it is
HttpModulevalueHttpModule(name)({ router, prefix?, port?, hostname?, imports?, provides?, exports? }) — a di Module(name)({...}) that also takes the router provider; the composition root of an HTTP deployment
HttpModuleOptionstypeThe options object HttpModule(name) takes
HttpRoutervalueHttpRouter(contract)(deps, { sync }), or HttpRouter(contract)(controllers) — the router as a provider on the starter's own router port, contract-first, either from one sync or from a keyed record of controllers
HttpControllervalueHttpController(name, fragment)([deps], { sync }) — one slice of a contract, as a provider on a port minted for it
httpvaluehttp({ prefix?, port?, hostname? }) — the starter module itself, needing the router port; what HttpModule imports
HttpOptionstypehttp()'s options
HttpRuntimevalueclass HttpRuntime extends RuntimePort<Runtime<never, HttpInfo>> {} — the runtime's port; what http() provides and the module start boots must export
HttpConfigvalueclass HttpConfig extends Port("HttpConfig")<{ port: number; hostname: string }> {} — what the socket is bound with, provided by http() from PORT / HOST
HttpInfotype{ readonly port: number } — what the runtime publishes on Serving.info once listening, read back through RunningApp.runtimeInfo()

HttpRouterPort (the starter's router port, Port("HttpRouter")), Implementation<C> (the record type HttpRouter's sync returns) and HttpHandler (the node listener port) exist in src/orpc.ts and src/handler.ts but are not exported from the package entry point: the first is reached as provider.port when a caller needs it, the second is inferred at the call, the third is an internal seam.

HttpModule(name)({...})

Everything Module(name)({...}) takes — imports, provides, exports — plus the starter's own fields. It appends http({ prefix, port, hostname }) to imports, prepends router to provides, prepends HttpRuntime to exports, and hands the augmented tuples to di's own Module(name), whose return type is the sugar's. The kernel and both gates see a plain module.

OptionRequiredDefaultWhat it is
routeryesthe application's router provider — a Provider<HttpRouterPort, E, N>, what HttpRouter(contract)(deps, arm) returns; a provider on any other port fails at the call
prefixno/rpcwhere the RPC endpoint is mounted; typed `/${string}`
portnoread from PORTpins the port instead of reading it
hostnamenoread from HOSTpins the host instead of reading it
importsno[]the application's modules
providesno[]the application's own providers
exportsno[]the application's own exports; HttpRuntime is added

The worked composition root, from examples/order-api/src/module.ts:

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

That is exactly the module Module("OrderApi")({ imports: [OrderApplicationModule, OrderPersistenceModule, observability(), http()], provides: [orderRouter], exports: [HttpRuntime, Logger] }) would have declared. observability() is a second starter, not this package's business: it brings the Logger the application writes to, bound from LOG_LEVEL, JSON per line on stdout, every line carrying the trace id of the unit this runtime opened.

HttpRouter(contract)(deps, { sync })

Contract-first: contract is an oRPC router record (Record<string, RouterContract> — a record, not a bare procedure), and the second call is di's Provider(port)(deps, { sync }) on the starter's own router port with one difference — sync returns an implementation record shaped like the contract and the router is built from it. Only the sync arm exists: a router is built, not acquired.

Each leaf is the .result() handler @unthrown/orpc gives that procedure's implementer: (helpers, input) => AsyncResult<Output, ORPCError>, where input is the contract's parsed input, Output its declared output and helpers.errors its declared error map. A typo'd key, a missing procedure or a wrong output type is a compile error at the call. implement(contract), os.…, .result(...) and os.router(...) are what the call does for you.

There is no name to give: a process serves one router as it boots one runtime, so the port is the starter's — Port("HttpRouter"), declared once, framework-owned like HttpConfig — and two router providers in one graph are di's duplicate-provider defect at build. Returns Provider<PortInstance<"HttpRouter", Router<…>>, never, InstanceType<D[number]>> & { readonly port: PortClassOf<"HttpRouter", Router<…>> }provider.port is the port class, for a hand-declared provider or a type test. The implementation below is the one in examples/order-api/src/slices/orders/controller.ts, served through the positional form — the example composes it as a controller instead (see the keyed form), and a fragment is a contract, so the same sync reads either way:

ts
export const ordersRouter = HttpRouter(contract.orders)(
  [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 },
              }),
            ),
          ),
    }),
  },
);

An implementation key the contract does not declare is unreachable through the types; if one is smuggled past them it is dropped, not defected on.

The keyed form: HttpRouter(contract)(controllers)

For a contract shaped Record<string, RouterContract>, HttpRouter also takes a record of controllers, one per top-level key, instead of (deps, { sync }):

ts
export const orderRouter = HttpRouter(contract)({
  orders: ordersController,
  customers: customersController,
});

Each value is what HttpController returns. The call is exact: M is constrained to { readonly [K in keyof C]: ControllerFor<C[K]> }, and the controllersparameter itself is typed M & { readonly [K in Exclude<keyof M, keyof C>]: never } — the exactness intersection sits on the parameter, not on M, so a key C does not declare is typed never there without collapsing M (and with it the needs channel di orders the controllers by) to never too. Five gates are pinned by packages/http/src/controller.test-d.ts: every contract key must be covered; a key the contract does not declare is rejected; a controller wired under the wrong key is rejected (its fragment does not match that key's); a procedure a controller's own fragment does not declare is rejected inside the controller, before the root ever sees it; and a slice lifts into a process of its own with its controller untouched — HttpRouter(contract.orders)([ordersController.port], { sync: (implementation) => implementation }) compiles — the property a slice's independent deployability rests on. The positional (deps, { sync }) form is unchanged and stays correct for a small API — the two are discriminated at the call the same way Provider(port)(depsOrOptions, …) discriminates its own two forms. See Split a router into controllers for the worked recipe.

HttpController(name, fragment)

ts
const HttpController: <const Name extends string, C extends RouterContract>(
  name: Name,
  fragment: C,
) => <const D extends readonly AnyPort[]>(
  deps: D,
  options: {
    readonly sync: (
      ...services: { [K in keyof D]: ServiceOf<InstanceType<D[K]>> }
    ) => Implementation<C>;
  },
) => Provider<
  PortInstance<Name, Implementation<C>>,
  never,
  InstanceType<D[number]>
> & {
  readonly port: PortClassOf<Name, Implementation<C>>;
};

One slice of a contract, as a provider over a port minted for it — the same two-call shape as HttpRouter(contract)(deps, { sync }), aimed at a fragment rather than the whole contract. fragment is read for its type only: it shapes sync's return, so a procedure the fragment does not declare, or a handler whose input or output has drifted, is a compile error inside the controller. The port is minted under name and carried back on provider.port — the shape Config.provider("RelayConfig")(schema) already uses — so a slice's module exports controller.port rather than naming a port of its own:

ts
export const OrdersSlice = Module("OrdersSlice")({
  provides: [ordersController],
  exports: [ordersController.port],
});

The controller does no oRPC work: it is a plain record, and HttpRouter's own walk wraps each leaf in .result(...) when the keyed form composes the router. A fragment is itself a valid contract, so a slice lifts out into a process of its own without its controller changing at all — the lifted root declares the controller's own port and hands back what it built:

ts
export const ordersRouter = HttpRouter(contract.orders)(
  [ordersController.port],
  {
    sync: (implementation) => implementation,
  },
);

That property is marked do-not-break: it is what makes composing several slices into one router a starting point rather than a trap.

http(options)

ts
const http: (
  options?: HttpOptions,
) => Module<HttpRuntime | HttpConfig, ConfigInvalid, Env | HttpRouterPort>;

The primitive HttpModule delegates to, for a composition root written by hand. HttpOptions:

OptionRequiredDefaultWhat it is
prefixno/rpcwhere the RPC endpoint is mounted
portnoread from PORTpins the port
hostnamenoread from HOSTpins the host

The module provides HttpRuntime and HttpConfig, exports both, and needs Env (the kernel discharges it) and the starter's router port (HttpRouterPort, the port HttpRouter(contract)(deps, arm) provides on) — the runtime provider depends on the router through di, which is why a composition that imports http() without providing the router carries an unmet need start refuses (di's gate, not the kernel's). The router is not an option: there is no other port it could be on. The declared type is the same whether or not a field is pinned: Env and ConfigInvalid stay in the signature, and a pinned config never produces the latter.

HttpConfig, and the environment

HttpConfig is { port, hostname }, bound through Config.provider from the Env port the kernel provides. port / hostname in the options pin a field: explicit > environment > default, per field, so http({ port: 0 }) still reads HOST.

VariableDefaultParsed byNotes
PORT3000Config.port0 lets the OS pick; read the bound port back from RunningApp.runtimeInfo()
HOST0.0.0.0Config.stringthe deployment target is a pod; set 127.0.0.1 locally if the server must not be reachable off-host

An unset variable takes the default; a set-but-empty one, PORT=abc and PORT=70000 are each a ConfigInvalid — a startFailed event and exit 78 under runMain. Anything in the graph may depend on HttpConfig.

HttpRuntime and HttpInfo

HttpRuntime is declared over the kernel's RuntimePort with service Runtime<never, HttpInfo>: the runtime declares no needs — the router is a port its provider depends on — so RuntimeHost.ctx goes unread. Once listening it publishes HttpInfo, { port }, on Serving.info; with PORT=0 that is the only way to learn the port that was actually bound.

What it decides about a request

RequestAnswerDecided by
a procedure under prefixthe procedure's 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 the package's own fallbacks, guaranteeing that every request produces exactly one completed response. The two 500 shapes are unreachable over the oRPC surface, which collapses every defect itself; they exist because the transport is proven against a bare listener. "Failed" covers a rejected promise, a synchronous throw, and a StartOptions.unit provider that failed to build — the last two never reach the listener's promise, so that 500 is written from the unit's own defect path.

Result → HTTP status is deliberately not in the table: it is the router's .result() triage, at the one place that decides what a client sees.

The unit

One unit per request, kind: "http". Its lifetime is the response's: the unit's work resolves on the response's 'close' event (or at once if that already fired before the work ran — a client hanging up during a slow StartOptions.unit build), so there is no seam for a late write to land in.

UnitMeta fieldValue
idrandomUUID(), minted per request — never the route, which would give every request one trace id
traceIdthe inbound x-request-id header when non-blank; otherwise absent, so it defaults to id

A blank header is ignored rather than adopted, because "" is not nullish and would otherwise win over the minted id.

The drain

Serving.drain(signal) marks the server draining, retires every open response — an unsent header gets Connection: close, a sent one ends its socket on 'finish' — then calls server.close() and closeIdleConnections(). closeIdleConnections() alone reaches only connections idle at that instant, and node would keep serving keep-alive requests down a busy one for the whole drain window; retirement per response is what actually stops accepting. The deadline signal is noted and not otherwise used: closing a listener is instantaneous, so there is nothing to escalate to. Serving.stop() destroys whatever sockets are still open and resolves once the server has closed.

Startup failures

A bind failure (EADDRINUSE, and the synchronous ERR_SOCKET_BAD_PORT node throws for a port outside 0..65535) is Err(RuntimeStartFailed({ runtime: "http", cause })) — exit 1 under runMain. Once serving, the server keeps a permanent no-op 'error' listener so a transient accept fault cannot become an uncaughtException teardown.

Peer dependencies

@btravstack/core, @btravstack/config, @btravstack/di, unthrown, @orpc/server, @orpc/contract, @unthrown/orpc. All peers, so an application holds one copy of each. Node >=20.

Deliberately not included

  • Any other router or handler. oRPC through @orpc/server/node's RPCHandler is the one way HTTP is answered; there is no handler option and no listener port to provide.
  • Middleware. oRPC's own, inside the router's procedures.
  • Result → HTTP status. The router's .result() triage owns it.
  • HTTPS, HTTP/2. node:http only; terminate TLS at the ingress.

Released under the MIT License.