npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@btravstack/orpc-server

v0.53.0

Published

The oRPC answerer and OpenAPI adapter for @btravstack/http-server

Readme

@btravstack/orpc-server

The oRPC answerer and composition helper for the protocol-neutral @btravstack/http-server runtime. defineHttp types handlers from the contract; HttpModule composes the router, optional htmx fragments, and the HTTP runtime — and, with openapi: true, the same router as OpenAPI routes. The document generator is on /openapi. Runtime ports and available Logger / HealthChecks are 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.2

A 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 gets 403, neither with a message, because oRPC serializes message to 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, with jose as an optional peer. JWKS fetch, cache and rotation; an asymmetric-only algorithm allowlist, because a JWKS publishes public keys and accepting HS256 beside them is the algorithm-confusion attack; iss, aud and exp required to be present, nbf honoured when present. jwks, issuer and audience are bound from HTTP_JWT_* when they are not pinned — see Options below. principal is (claims, services): services are the ports inject names, 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 codec sessionCodec() 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_ISSUER and HTTP_OIDC_CLIENT_ID by default; a root composing it without sessionCodec() 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 HttpHandler set 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. plugins is 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 securityHeaders record carrying your policy and x-content-type-options, x-frame-options and referrer-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