@btravstack/orpc-server
v0.53.0
Published
The oRPC answerer and OpenAPI adapter for @btravstack/http-server
Maintainers
Readme
@btravstack/orpc-server
The oRPC answerer and composition helper for the protocol-neutral
@btravstack/http-serverruntime.defineHttptypes handlers from the contract;HttpModulecomposes the router, optional htmx fragments, and the HTTP runtime — and, withopenapi: true, the same router as OpenAPI routes. The document generator is on/openapi. Runtime ports and availableLogger/HealthChecksare exported automatically; application business ports remain explicit.
The order API example serves oRPC and htmx under one listener. The GraphQL example runs in a separate process and uses the typed oRPC client, without installing this server package.
For OpenAPI routes, named errors declare their HTTP status beside data or
message in the contract's .errors(...) entries. The answerer and document
generator honor each procedure's declaration without an application-wide map.
See custom error statuses.
pnpm add @btravstack/orpc-server @btravstack/http-server @btravstack/htmx-server \
@btravstack/core @btravstack/config @btravstack/di @btravstack/contract \
unthrown @orpc/[email protected] @orpc/[email protected] \
@orpc/[email protected] @orpc/[email protected] \
@orpc/[email protected] @unthrown/orpc@^0.4.2A worked example
import { runMain } from "@btravstack/core";
import { HttpModule, defineHttp } from "@btravstack/orpc-server";
import { P } from "unthrown";
// One call mints every marker-typed entity this application uses. A public
// API declares no security scheme, so it takes no argument. Hold the result
// as ONE binding — never destructure it (see "Protecting a procedure").
const api = defineHttp();
// Contract-first: the record is shaped like the contract, each leaf a plain
// Result-returning function typed by it. The use cases arrive under the names
// the `inject` record gave them; the request context carries native metadata.
const ordersRouter = api.OrpcRouter(ordersContract)({
inject: { place: PlaceOrder, find: FindOrder },
sync: ({ place, find }) => ({
place: ({ errors }, input) =>
place
.execute(input.id, input.quantity)
.map(view)
// The one place a domain error becomes a transport one — exhaustive,
// so a new domain error is a compile error right 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 },
}),
),
),
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 },
}),
),
),
}),
});
// A di module that also knows about its router: imports the starter, provides
// the router on the starter's own port (a process serves one router, so
// there is nothing to name), exports the runtime port — nothing else to spell.
const OrdersApi = HttpModule("OrdersApi")({
router: ordersRouter,
imports: [Application, Persistence],
});
await runMain(OrdersApi);That is a whole main.ts. PORT (default 3000), HOST (default 0.0.0.0)
and the kernel's PROBE_PORT are read inside the graph; the router is mounted
under /rpc; a test boots the same module with
start(OrdersApi, { env: { PORT: "0", HOST: "127.0.0.1" } }) and reads the bound
port back from app.runtimeInfo().
Splitting a large API into slices
api.OrpcRouter(contract)({ inject, sync }) is right for a small API. A large
one splits into pieces — one per node of the contract tree, named by a
dotted path — composed at the root as an array instead:
// each slice owns one piece, and exports only its port
export const ordersController = api.OrpcController(contract, "orders")({
inject: { place: PlaceOrder },
sync: ({ place }) => ({ place: ({ errors }, input) => placeOrder(place, errors, input) }),
});
// the root composes them; every procedure the contract declares must be covered
export const orderRouter = api.OrpcRouter(contract)([ordersController, customersController]);The key rides the piece itself, so a piece cannot sit under the wrong one; an uncovered procedure and a piece nested inside another's fragment are each refused at the array, naming what is missing. And because a fragment is itself a valid contract, a slice lifts into a process of its own with its piece unchanged — which is what makes a modulith a starting point rather than a trap.
When several APIs share a resource name in one process, give each API a distinct literal namespace:
import { defineHttp } from "@btravstack/orpc-server";
const paymentsApi = defineHttp({ namespace: "payments-api" });
const billingApi = defineHttp({ namespace: "billing-api" });An unnamed API keeps OrpcController:<path> port IDs; a named API uses
OrpcController[<namespace>]:<path>. The same namespace and path reuse one port,
even across equivalent contract objects or package copies. Contract object
identity does not define the port. The namespace stays literal in DI's types,
so one API cannot satisfy another's controller dependency. Dynamic strings,
unions, and names containing ] are refused. A router composes controllers
from its own namespace and rejects duplicate paths. RPC resource keys and
OpenAPI HTTP paths are unchanged.
The worked recipe, with the slice modules and the lifted root: Split a router into controllers.
Protecting a procedure
A contract says which security schemes a procedure accepts and which scopes
each must grant — the marker is @btravstack/contract's, so it lives in the
artifact a client holds too, and it names no identity type, so nothing about the
server's view of a caller reaches a client.
defineHttp({ authenticators }) says what each scheme resolves to.
Declaring a scheme and implementing it are the same act, so there is no registry
to keep in step with the contract:
const api = defineHttp({ authenticators: { user: userAuth, service: serviceAuth } });Four consequences, and they are the whole story:
- A marked procedure's handler receives a typed principal as
({ principal }, input), narrowed to the scheme that answered when the contract names more than one. - The scheme ports are declared by the router, one per scheme its contract names — so a scheme with no authenticator behind it is an unmet dependency the compiler reports, naming the port.
- A caller no requirement accepts gets
401; a valid credential missing a scope gets403, neither with a message, because oRPC serializesmessageto the client and a refusal has nothing a caller is entitled to. - A defect from an authenticator is a bug, not a refusal: it stops the walk rather than promoting the caller to the next scheme.
Resource-dependent authorization — "may this caller read this order" — stays in the handler, deliberately: a scope is a property of the credential and answerable before dispatch, and ownership is not.
The worked recipe:
Protect a procedure.
The full surface, arm by arm:
AUTH.md.
Authenticators
The shipped ones — JWT, API key and the session cookie — are the ones where
writing it per application is how CVEs happen. Each is an ordinary
Authenticator value bound by name in defineHttp({ authenticators }):
apiKeyAuthenticator<P>()({ keys, header? }), on the main entry point. Constant-time compare over SHA-256 digests, every key checked with no early return, and a missing header on the same path as a wrong one.jwtAuthenticator<P>()({ principal, inject?, jwks?, issuer?, audience?, variablePrefix?, scopes?, algorithms?, clockToleranceSec?, header? }), from@btravstack/http-server/jwt, withjoseas an optional peer. JWKS fetch, cache and rotation; an asymmetric-only algorithm allowlist, because a JWKS publishes public keys and acceptingHS256beside them is the algorithm-confusion attack;iss,audandexprequired to be present,nbfhonoured when present.jwks,issuerandaudienceare bound fromHTTP_JWT_*when they are not pinned — see Options below.principalis(claims, services):servicesare the portsinjectnames, so a claim check can read configuration, each port joining the scheme's needs.sessionAuthenticator<P>()({ scopes?, principal?, issuer?, clientId?, variablePrefix? }), from@btravstack/http-server/session, over the codecsessionCodec()provides. Reads__Host-session, the cookie the OIDC login answerer (oidc()) seals, and accepts only a session its own login sealed — the issuer and client id that login reads,HTTP_OIDC_ISSUERandHTTP_OIDC_CLIENT_IDby default; a root composing it withoutsessionCodec()is an unmet dependency naming the port.
The CommonJS build needs Node ≥22.12: oRPC and jose are ESM-only, so its
require depends on require(esm).
Password hashing and credential issuing are out of scope — all three above are on the verifying side. Details: the reference page.
Publishing an OpenAPI document
openApiDocumentRoute serves a document generated by openApiDocument() or
kept as a build artifact. Add the provider to HttpModule's provides:
import { openApiDocumentRoute, type OpenApiDocument } from "@btravstack/orpc-server/openapi";
declare const document: OpenApiDocument;
const documentRoute = openApiDocumentRoute({ path: "/v1/openapi.json", document });The snippet constructs the provider only. Register it with
provides: [documentRoute] on your existing HttpModule to mount the route.
The exact public path supports GET and HEAD; descendants return 404 and
other methods return 405 with Allow: GET, HEAD. See
the document route reference.
Options
defineHttp({ namespace }) fixes controller DI identity for an API; see
Splitting a large API into slices. It is
independent of the deployment options below.
HttpModule(name)({...}) takes the HTTP starter's options plus router,
fragments, fragmentsPrefix, openapi and the module lists (imports,
provides, exports, needs) — supply router, fragments, or both;
supplying neither is refused at the call. It is the only way to compose the
starter, which is not exported:
| Option | What it is |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
| router | the router provider — what api.OrpcRouter(contract)(...) returns |
| fragments | the fragments provider — what api.HtmxFragments([...]) returns over an array of HtmxGet/HtmxPost pieces |
| prefix | where the RPC endpoint is mounted (default /rpc) |
| fragmentsPrefix | where htmx fragments are mounted (default /, htmx()'s own default) |
| openapi | serves the router as OpenAPI routes too — true, or openApiRoutes()'s options (off; needs router) |
| port | pins PORT |
| hostname | pins HOST |
| headersTimeoutMs | pins HTTP_HEADERS_TIMEOUT_MS — how long a client may take to send its headers before a 408 (60 s) |
| requestTimeoutMs | pins HTTP_REQUEST_TIMEOUT_MS — how long a client may take to send a whole request (300 s) |
| cors | pins HTTP_CORS_ORIGIN — true for oRPC's defaults, or its CORSHandlerPluginOptions (off); RPC and openapi |
| bodyLimit | pins HTTP_BODY_LIMIT — the largest body a procedure or a fragment POST reads, in bytes (1 MiB; false is unbounded) |
| streamBodyLimit | pins HTTP_STREAM_BODY_LIMIT — the largest body an openapi route reads as a stream, in bytes (1 GiB; false is unbounded) |
| compression | pins HTTP_COMPRESSION — response compression, true for oRPC's defaults or its options record; RPC and openapi |
| plugins | any other oRPC handler plugin, forwarded to RPCHandler |
| securityHeaders | response headers set on the raw listener, before dispatch (default on) |
| csrf | refuse a cross-site state change carrying cookies, before dispatch (default: on once a scheme reads a cookie) |
| unit | kind → module: anonymous, or a scheme — both answerers fork the kind that authenticated the request |
cors, bodyLimit, streamBodyLimit and compression pin a field of HttpConfig that is
otherwise bound from the environment — explicit beats environment beats
default, per field — so a deployment sets HTTP_CORS_ORIGIN or
HTTP_BODY_LIMIT without a code change, and a test pins them instead. The rest
stay composition-time: prefix because a client's baseURL has to agree with
it, securityHeaders and csrf because a deployment that can silently turn
x-frame-options — or the CSRF check — off is a footgun, and plugins (or a
CORSHandlerPluginOptions record) because an environment carries no records.
jwtAuthenticator's three transport options pin the same way, from
@btravstack/http-server/jwt. A root composing that scheme writes no needs
line for it: HttpModule carries Env for every provider in the root, its
schemes included, the same way it already carries the starter's own.
| Option | What it is |
| ------------------- | -------------------------------------------------------------------------------------------------- |
| jwks | pins HTTP_JWT_JWKS_URI — the issuer's JWKS endpoint |
| issuer | pins HTTP_JWT_ISSUER — the required iss |
| audience | pins HTTP_JWT_AUDIENCE — the required aud, this deployment's own name |
| allowInsecureJwks | fetch the key set from a non-loopback http: URL (default false, and a boot failure without it) |
| variablePrefix | the prefix of the three variables (default HTTP_JWT), so a second scheme reads its own |
A variable nobody pinned and nobody set — or a HTTP_JWT_JWKS_URI that is not
a URL, or a cleartext one that is not loopback — fails the boot with a
ConfigInvalid naming it, rather than a scheme that refuses every caller. A
refusal the ISSUER caused, rather than the token, is reported to Observers,
so a key-rotation incident is a metric and a line instead of a 401 per
request.
sessionCodec({ keys?, ttlSec? }), from @btravstack/http-server/session,
pins one variable the same way — and sessionAuthenticator is the scheme that
reads what it seals.
| Option | What it is |
| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| keys | pins HTTP_SESSION_KEYS — a comma-separated list of 32-byte base64url keys (A-Z a-z 0-9 - _, no padding); the first seals and every one unseals, so rotation is append, promote, drop — one rollout each |
| ttlSec | how long a session lasts (default 12 h). Fixed: there is no sliding re-seal |
Mint one with
node -e 'console.log(require("node:crypto").randomBytes(32).toString("base64url"))'.
A key that is not 32 base64url bytes fails the boot with a ConfigInvalid
naming HTTP_SESSION_KEYS and the POSITION it refused — never the value.
oidc()({ principal, ... }), from @btravstack/http-server/oidc, is what
authenticates the principal that codec seals: an answerer serving
<prefix>/login, <prefix>/callback and <prefix>/logout over the
authorization-code flow with PKCE. It needs openid-client, an optional peer
behind that subpath.
| Option | What it is |
| --------------------- | --------------------------------------------------------------------------------------------------------------- |
| principal | required — (claims, services); what the ID token's claims make the caller; undefined refuses the login |
| inject | the ports principal reads, resolved as the graph is built; each joins the answerer's needs |
| issuer | pins HTTP_OIDC_ISSUER — the provider, as its discovery document names itself |
| clientId | pins HTTP_OIDC_CLIENT_ID — this deployment's client |
| clientSecret | pins HTTP_OIDC_CLIENT_SECRET — its secret; the flow is a confidential client's |
| redirectUri | pins HTTP_OIDC_REDIRECT_URI — the URI registered with the provider, and what the grant is checked against |
| prefix | where the three routes are mounted (default /auth) |
| scope | what the authorization request asks for (default openid) |
| postLogout | where a logout lands when the provider advertises no end-session endpoint (default /) |
| allowInsecureIssuer | talk to an http: issuer that is not on a loopback host (default false, and a boot failure without it) |
| variablePrefix | the prefix of the four variables (default HTTP_OIDC), so a second login reads its own |
An http: issuer is refused at boot unless its host is loopback —
localhost, 127.0.0.1, [::1] — or allowInsecureIssuer: true is pinned at
the call: cleartext sends the client secret, the authorization code and every
token in the open. An option rather than a variable, because its silent change
is a security regression.
Discovery runs once, at boot: a provider that is not there is an
OidcUnreachable naming the issuer, rather than a 500 on the first login.
Each route is an operation reported to Observers, so a refusal carries its
own reason — compose any observability and the line is there, compose none
and it costs nothing. The cookie it seals is SESSION_COOKIE, which the
scheme reads and neither side can rename.
The full table — required/optional, defaults, and the reasoning — lives on the reference page, which is this list's one detailed home.
What it guarantees
Every request produces exactly one completed response, and its unit stays open
until that response is on the wire — the kernel's least-checkable contract,
made structural. A procedure's output or the ORPCError its Result was
mapped to is oRPC's; a defect inside a procedure is oRPC's own
INTERNAL_SERVER_ERROR collapse; an unmatched path is the package's 404.
Result → HTTP status is the router's .result() triage — this package maps
nothing. The drain retires busy keep-alive connections and resets open
server-sent-event streams, so a client reconnects to a replica that is
staying rather than being reported abandoned; a client's x-request-id
becomes the unit's traceId. The rest is on the
documentation site.
Streaming
A procedure whose output is an eventIterator is served as
text/event-stream, and GET is admitted for exactly those procedures — the
one request a browser's EventSource can send — so a stream is reachable
from a browser and from a typed oRPC client alike. A deploy resets an open
stream at the start of the drain's third beat — after readiness has gone
false and the pre-drain delay has been paid, so the reconnect lands on a
replica that is staying — and the client resumes from Last-Event-ID. The
recipe is
Stream with server-sent events.
A raw body streams too, over the openapi routes. A route declaring
openapi({ method: "PUT", path: "/files/{key}", inputStructure: "detailed",
requestBodyHint: "octet-stream" }) gets input.body as a ReadableStream
while the client is still sending, bounded by streamBodyLimit rather than
bodyLimit: a declared content-length over it is 413 before reading, and
a body that passes it errors its stream and answers 413 whatever the
handler made of the error. An outputStructure: "detailed" answer whose
body is a ReadableStream streams out with the headers it names, and a
stream that errors midway resets the connection rather than ending a short
body cleanly; declare responseBodyHint: "octet-stream" on that route. A
route declaring either hint is served by the openapi routes only: the RPC
answerer leaves it unmatched, so /rpc answers 404 before its handler
runs, and a typed RPC client that lists it gets that 404. An RPC answer
that still carries a ReadableStream has its cancellation started and
answers 500 at once, reported as a defect; a locked stream cannot be freed
that way and is reported on its own. The recipe is
Serve an oRPC contract as OpenAPI routes.
Controller context exposes the native request (IncomingMessage), host
(unit host) and cancellation signal, beside the authenticated principal and
context.unit services. Request-scoped providers can inject
HttpRequestMetadata from @btravstack/http-server for an immutable metadata
snapshot carrying the same signal. Headers remain untrusted input to decode
and authorize in the application. See
request metadata.
What it does not do
Each of these is a decision with a reason, and the reasons are on the reference page:
- Another router inside the oRPC answerer — a second protocol is a second
answerer on the
HttpHandlerset port, under the same runtime. - A middleware slot for application logic — oRPC's own middleware is where
that belongs, and the ordinary cross-cutting concerns are named options.
pluginsis the honest escape hatch. Result→ HTTP status — the router's.result()triage owns it.- Rate limiting — a per-process counter is the wrong unit when a deployment is N pods; the ingress counts a request once.
- Static files and an SPA fallback — the ingress or a CDN serves assets;
with htmx fragments the asset set is htmx plus a stylesheet. The CSP for an
HTML response is the deployment's: pass a
securityHeadersrecord carrying your policy andx-content-type-options,x-frame-optionsandreferrer-policy, since a record replaces the defaults rather than adding to them. - Resource-dependent authorization — a scope is checked here because it is a property of the credential; "is this caller the order's owner" needs the order, so it stays in the handler.
- AND within one requirement, OpenAPI scheme metadata, HTTPS and HTTP/2 — a composite scheme, the contract, and the ingress, respectively.
License
MIT © Benoit TRAVERS
