← stitchkitDocumentation

HTTP server

stitchkit serves contracts on Bun.serve() directly — no Hono, no Elysia, no Express. You bind a contract to handlers with implement(), then mount the result on createServer().

implement

implement(contract, handlers) type-checks each handler against its endpoint’s schemas and returns a ServiceDef to mount.

import { implement } from 'stitchkit/server'
import { users } from '../shared/contracts'

const usersService = implement(users, {
  list:   ()    => db.users.findMany(),
  create: (ctx) => db.users.create(ctx.input),       // ctx.input: typed
  get:    (ctx) => db.users.findById(ctx.params.id), // ctx.params: typed
  delete: (ctx) => db.users.delete(ctx.params.id),
})

A handler may be sync or async. Its return value is checked against the endpoint’s output schema; an endpoint without output returns nothing.

The handler context

Every handler receives one ctx argument:

ctx field Type Source
params inferred from params schema parsed path params
input inferred from input schema parsed body / query
files inferred File map or receiver values the endpoint’s typed multipart fields
source 'http' | 'mcp' | 'agent' the transport that invoked the handler
traceId string per-request trace id
ipAddress string caller IP
userAgent string caller user-agent

The same handler runs for HTTP, MCP and agent calls — ctx.source tells you which. Anything an auth hook attaches (e.g. the resolved user) is also on ctx.

Request attributes from the ingress

When your own code builds the Request — a local socket ingress, a relay, a test harness — and calls the fetch handler of the core, it often knows something an endpoint needs and the request cannot be allowed to say for itself: which authenticated peer sent it. A header can be forged by any client. defineRequestIngress is the channel the request cannot carry:

import { createHandler, defineRequestIngress, implement } from 'stitchkit/server'

interface Peer {
  node: string
}
export const peer = defineRequestIngress<Peer>('peer')

// The ingress, after it has authenticated the peer and built the request:
peer.attach(request, { node: 'node-7' })
const response = await handler(request)

// An endpoint, `authorize`, `beforeHandle` or `onError` — `ctx` is the source:
implement(contract, {
  place: (ctx) => ({ node: peer.from(ctx)?.node ?? null }),
})
  • The value belongs to the key. A header, a body, request.clone() and new Request(request) carry nothing; a request from another ingress has no value, and from answers undefined. Two keys never meet, even under one name, and a second attach of the same key to the same request is an error.
  • ctx.req is the Request the fetch handler of the core received, on every HTTP context: an endpoint, authorize, beforeHandle, and the onError context of a validation failure, an unknown path, a refused method or an onRequest that threw. That is the guarantee the lookup stands on, and the ingress tests hold it.
  • A raw route, streamingRoute and onRequest receive the request without a context: read with peer.from({ req }). The MCP, agent and CLI lanes carry no Request, so there is nothing to read.
  • With proxyOrigin configured, the request is replaced before wrapFetch: attach in wrapFetch or later, to the request you are handed.
  • A Socket.IO handshake is routed before wrapFetch on Bun and rebuilt as a new Request on Node: it never carries an attached value.
  • stitchkit/node exports the same names.

createImplement — a fixed context type

To type ctx with your app’s extras (the user an auth hook injects), fix the context type once:

import { createImplement } from 'stitchkit/server'

interface AppContext extends RuntimeContext { user: User | null }

export const implement = createImplement<AppContext>()
// every implement() call now has ctx.user typed

Per-scope handler context — createScopedImplement

createImplement<Ctx>() gives every handler the same context type. In an app where each scope guarantees different injected fields, that single type is a superset: a public handler is told it has a userId the runtime never injects there. createScopedImplement types each handler by its endpoint’s effective scope instead — the endpoint’s own scope, else the contract’s:

import { createScopedImplement } from 'stitchkit/server'

export const implementFor = createScopedImplement<{
  public: object                                 // no extra fields
  user: { userId: string }
  admin: { userId: string; isAdmin: boolean }
}>()

implementFor(postsContract, {
  list: (ctx) => listPosts(ctx.userId),          // contract scope 'user'
  purge: (ctx) => (ctx.isAdmin ? purge() : []),  // endpoint scope 'admin'
  ping: () => ({ ok: true }),                    // endpoint scope 'public'
})

The map is type-only — scope fields are types, so there is no runtime argument and no {} as UserFields at the call site. 'public' must be a key: a contract without a scope is 'public'. A scope outside the map is a compile error on that endpoint, naming the scope.

Three boundaries worth knowing:

  • A field of another scope is not a compile error on access — RuntimeContext keeps its [key: string]: unknown index signature (transports write through it). It degrades to unknown, so it cannot pose as a string; using it in a typed position fails.
  • The map states what your beforeHandle / createAuthHook.inject puts on the context. The framework does not verify a hand-written claim (→ ADR 0075) — so prefer not to write one: scoped auth rules declare their contribution by performing it, and createScopedImplement<AuthScopes<typeof hook>>() derives the map from the hook (details, → ADR 0078).
  • An endpoint hoisted out of the contract literal widens its scope to string, which is no key of any map, and lands in the error branch. An endpoint whose scope is added by a conditional spread is optional, so it resolves to both scopes and is typed against what they guarantee in common. Write endpoints inline for the precise type.
  • Scope keys are string literals. A map declared as an index signature (Record<string, …>) makes every scope valid and disables the check.

The same map has a registry form and a streaming form:

// one contract registry, handlers still typed per endpoint scope
const implementAll = createScopedImplementRegistry<Scopes>()
export const services = implementAll(apiContractRegistry, { posts, users })

// a service FILE declares its handlers typed but unbound — binding happens
// once, in the registry. Curried out of necessity: one call cannot both take
// the contract and contextually infer the handlers from it.
export const posts = implementFor.declare(postsContract)({
  list: (ctx) => listPosts(ctx.userId),          // ctx typed per endpoint scope
  purge: (ctx) => (ctx.isAdmin ? purge() : []),
})

// a streaming multipart handler that reads its scope's fields. The endpoint
// must declare its own `scope`, and only that literal is accepted: an endpoint
// inheriting the contract's scope cannot be verified from here, and guessing it
// would rebuild the superset this factory removes.
implementFor(mediaContract, {
  upload: implementFor.stream('admin', mediaContract.endpoints.upload, {
    files: { file: receiveFile },
    handler: ({ files, userId }) => store(files.file, userId),
  }),
})

Outside a scoped app, createMultipartStream<Ctx>() does for streaming handlers what createImplement<Ctx>() does for ordinary ones. Plain defineMultipartStream still gives the loose RuntimeContext.

implementRegistry — one backend registry

When contracts already live in one literal registry, bind the backend from that same source instead of maintaining a parallel services list:

import { implementRegistry } from 'stitchkit/server'

export const apiServices = implementRegistry(apiContractRegistry, {
  users: usersHandlers,
  posts: postsHandlers,
})

Every registry key is required, extra keys fail, and each handler map is checked against its own contract. The returned service order follows the contract registry order — and the same services ride along under .byKey, so a consumer whose registry keys are load-bearing (filtering a tool surface per caller) never rebuilds a prefix lookup by hand:

const services = implementRegistry(apiContractRegistry, handlers)
createServer({ services })                       // the array, as before
const visible = [services.byKey.users]           // the same objects, by key

createImplementRegistry<AppContext>() fixes the application context once, like createImplement does for a single contract. Runtime callers also fail first on missing/extra keys and on a duplicate prefix within one scope — the same prefix under two different group scopes is legal (their URLs are separated by scopePrefixes, and a genuine path clash is the router’s own construction error).

A dev stand that survives half a feature — onMissingHandler

Adding an endpoint is two edits by construction: the contract, then the handler. An editor saves one file at a time and a watcher restarts on the first save, so between the two the application refuses to start — and on a shared dev stand that is everyone’s stand, not just yours. A project rule (“make both edits at once”) cannot outrun the filesystem.

implementRegistry(apiContractRegistry, handlers, {
  onMissingHandler: process.env.NODE_ENV === 'production' ? 'throw' : 'stub',
})

With 'stub' the application starts, the unimplemented endpoint answers 501 and names itself in the log at every mount, so it cannot be forgotten. Both save orders are covered: a contract ahead of its handler, and a handler ahead of its contract. The option is accepted by implement, implementRegistry, createImplement, createScopedImplement, createImplementRegistry and createScopedImplementRegistry.

Three things it deliberately does not do. It stays 'throw' by default, and 'throw' is the only right answer in production: a release is atomic, so there is no window to protect, and a surface with a hole must not ship. It does not relax the compile-time exactness check — that lives in the generic constraint, your editor still shows the missing handler, and a watcher that only transpiles is precisely why the runtime refusal was the one that bit. And it does not stub a streaming multipart endpoint, which needs defineMultipartStream() receivers; the stand still stops on one of those.

The stub answers NOT_IMPLEMENTED with status 501 everywhere — over HTTP and on a tool call that crosses a process boundary alike, because the code is registered in STITCH_ERROR_STATUS. One consequence follows from that and is worth knowing: a stubbed endpoint keeps its expose, so on a dev stand it is advertised to a model as a real tool that always refuses. The refusal is not retryable, so a model stops rather than loops; it still costs one turn to find out.

The registry is intentionally flat: every key must point to one concrete defineContract() result. Composed namespace arrays are mounted explicitly with implement() because they do not have a one-key-to-one-handler-map boundary.

createServer

createServer(config) builds the router and starts Bun.serve(). It returns a managed handle with url, port, the concrete server under runtime, live status, and one idempotent shutdown() lifecycle.

import { createServer } from 'stitchkit/server'

createServer({
  services: [usersService, postsService],
  port: 3000,
  cors: { origin: 'https://app.example.com' },
  hooks: { /* … */ },
  logging: true,
})

createHandler(config) is the same router as a bare (req) => Promise<Response> function — no Bun.serve. Use it in tests, or to embed stitchkit in another server. See Testing & deployment.

ServerConfig

Field Purpose
services ServiceDef[] mounted at the root
groups route groups — a shared path prefix and hooks (see below)
scopePrefixes scope → path prefix map — mount services by service.scope (see below)
rawRoutes non-contract routes (see below)
maxJsonBodyBytes optional JSON body cap (bytes); per-route value overrides; unset preserves existing behaviour
port / hostname listen address — port defaults to 3000
unix listen on a unix domain socket instead of TCP — '/run/app.sock' or { path, mode } (see below)
cors CORS policy — { origin, credentials, methods, headers, exposeHeaders }. origin is required when cors is present: pass an explicit origin (or list), or '*' to deliberately allow every origin — an origin-less config is a construction error, never a silent wildcard. Omit cors entirely to emit no CORS headers.
hooks lifecycle hooks (see below)
logging true for built-in request logs, or a LoggingConfig (see below)
traceId override per-request trace-id resolution — may return undefined to fall back
wrapFetch compose wrappers around the finished handler (request context, audit)
proxyOrigin Bun TCP ingress policy: { trustedPeer(socketIp) } restores the external scheme before all composition; independent of client-IP trustProxy
socket full Stitchkit Socket.IO handle; route, default WebSocket handler and shutdown are mounted once
websocket custom Bun WebSocket handler; with socket, this is the explicit composed handler
development / bun passthrough to Bun.serve

Native Bun routes are intentionally not accepted: Bun matches them before fetch, so they could bypass shutdown admission. Use rawRoutes; they retain the Fetch Request → Response model and participate in lifecycle tracking.

External origin behind a trusted proxy

An HTTPS ingress often forwards plain HTTP to Bun. trustProxy trusts client-IP headers only; it does not change Request.url. Opt into the separate URL policy:

import { createServer, isLoopbackAddress } from 'stitchkit/server'

const server = createServer({
  hostname: '127.0.0.1',
  port: 3000,
  proxyOrigin: { trustedPeer: isLoopbackAddress },
  services,
})

The predicate receives the real TCP peer, never X-Forwarded-For or X-Real-IP. Match only the ingress you control. Loopback is appropriate only when other local processes are trusted. Unmatched peers, missing peer metadata and the default configuration ignore URL forwarding headers. Unix listeners have no TCP peer and do not use this policy. This option belongs to Bun createServer, not the Fetch-clean createHandler or Node serveNode.

The trusted ingress must overwrite Host with the external authority and X-Forwarded-Proto with exactly http or https. It must remove Forwarded, X-Forwarded-Host and X-Forwarded-Port; their presence, a missing/invalid proto, a list of values or a malformed Host returns HTTP 400 before application code. Header names are case-insensitive; proto values are lowercase. Multiple ingress chains require a single sanitizing ingress at this boundary.

For an nginx HTTPS terminator, the relevant forwarding settings are:

proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Forwarded "";
proxy_set_header X-Forwarded-Host "";
proxy_set_header X-Forwarded-Port "";

The normalized Request.url reaches wrapFetch, hooks, contracts, raw routes, and the managed Socket.IO route. It preserves the path/query, wire authority (including explicit ports), method, body, headers and linked cancellation signal. Request cloning uses the normalized URL. The same runtime handle resolves requestIP, timeout and upgrade against the original native request, retaining WebSocket and shutdown behavior.

This is a scheme normalization policy, not host authorization. Applications must retain their hostname/tenant allowlist and exact same-origin checks for Origin and Referer. The TLS terminator must reject SNI/Host mismatches before forwarding; a plain-HTTP Bun listener cannot observe the TLS SNI. The normalizer does not accept X-Forwarded-Host, authorize a foreign Host, or treat HTTP and HTTPS as aliases.

Local daemon over a unix socket

A local daemon whose door is a socket file needs no TCP port at all:

const server = createServer({
  services,
  unix: { path: '/run/my-daemon.sock', mode: 0o600 },
})

unix is mutually exclusive with port/hostname (construction error). The handle stays honest: server.url is unix:///run/my-daemon.sock — an identifier, not a fetchable address (dial the path with createHttpClient({ unix }), client guide) — and server.port is 0.

Socket-file hygiene is built in. A stale file left by a killed process is reclaimed automatically: the path is probed, and only a socket that is owned by the current user and answers no live listener is removed before binding (best-effort — two processes racing the same path resolve through the loud bind error of the loser). A regular file at the path, another user’s socket, or a live listener each fail with a specific error instead of being unlinked. A clean server.shutdown() removes the file.

mode tightens the socket file permissions after listen. Bun creates the file 0755 under a normal umask — since connect(2) requires write permission that is already owner-only in practice — but when access to the socket is the credential, set mode: 0o600 explicitly and let the filesystem be the policy. The Socket.IO lifecycle (socket) cannot mount on a unix listener (socket.io clients dial TCP only — construction error); Bun’s own WebSocket client cannot dial a unix path either. Unix listeners are Bun-only: srvx resolves a numeric port unconditionally, so stitchkit/node does not offer unix (no half-support).

Managed shutdown

const server = createServer({ services, socket })

const result = await server.shutdown({
  gracePeriodMs: 30_000,
  realtimeCloseTimeoutMs: 1_000,
  forceTimeoutMs: 5_000,
  retryAfterSeconds: 5,
  signal: shutdownController.signal,
})

The first call closes HTTP and Socket.IO admission and cancels owned contract streams and streamingRoute / ndjsonRoute / sseRoute sources. Their supplied signal is the lifetime: waiting producers must honour it, and their iterator cleanup must finish. No separate application abort controller or copied shutdown loop is required. Admitted finite HTTP/application work and stream cleanup share the full gracePeriodMs budget. Once that work drains, realtimeCloseTimeoutMs (default 1_000) bounds WebSocket close handshakes inside the same outer deadline. Any upgraded sockets still open at that boundary are terminated without shortening HTTP grace, and graceful runtime shutdown continues. If the outer grace budget or external signal forces destructive teardown, forceTimeoutMs bounds physical completion separately. Repeated calls return the same Promise; the first options win. New ordinary HTTP work receives 503, Retry-After and Connection: close outside wrapFetch. result.outcome is clean or forced; a forced result preserves the pending snapshot and reason while final pending counters describe the post-close transport state and any still-owned stream cleanup. A streaming request increments completedRequests only after its source cleanup settles, not when its Response is returned. abortedRequests counts requests pending at outer force, not subscriptions closed cooperatively during grace. forcedWebSockets counts both sockets terminated at the dedicated realtime bound and sockets terminated by outer force; pendingWebSocketsAtForce is only the latter snapshot, so a clean result can truthfully report a bounded realtime termination. A graceful phase error still runs forced cleanup and then rejects with the original error; a forced transport that cannot confirm completion before forceTimeoutMs rejects instead of reporting a false zero. An uncooperative stream stays pending even if its connection is gone; a cleanup error rejects shutdown. Standalone createHandler and arbitrary raw Response bodies have no managed source ownership: their embedding host/producer owns cancellation. Request identity must be retained by wrapFetch when forwarding to owned streaming routes; it is also required by native timeout/upgrade APIs. runtime is a diagnostics escape hatch, not a second canonical stop path.

Trusted HTTPS in development

Stitchkit does not own local certificate generation or a frontend development server. When a device or browser feature requires a secure context, generate a trusted certificate outside the application (for example with mkcert) and pass its files through the Bun server boundary:

import { readFileSync } from 'node:fs'
import { createServer } from 'stitchkit/server'

createServer({
  services,
  hostname: '0.0.0.0',
  bun: {
    tls: {
      cert: readFileSync('./certs/dev.pem'),
      key: readFileSync('./certs/dev-key.pem'),
    },
  },
})

Trust the certificate authority on each test device and configure the frontend’s HTTPS mode in that frontend project. Certificate renewal, interface discovery and device onboarding remain application infrastructure rather than framework or starter behavior.

Request logging

logging: true is shorthand for logging: {} — any object turns logging on, and the fields tune it:

createServer({
  services,
  logging: {
    // What the built-in formatter writes. Omit it and it follows NODE_ENV.
    format: 'json',
    // Route lines into your stack instead of the built-in formatter.
    logger: myLogger,
    // Silence noise. Runs after the built-in filter (framework assets,
    // favicon, preflights), so it can only quieten more.
    skip: (_req, url) => url.pathname === '/health' || url.pathname.startsWith('/socket.io/'),
    // Extra fields on the completion line.
    enrich: (req, _url, { status }) => ({
      userAgent: req.headers.get('user-agent') ?? undefined,
      cacheable: status === 200,
    }),
  },
})

Two formats, and who chooses

format What it writes Carries enrich / context identity
'pretty' two coloured lines per request — → on arrival, ← on completion no — a line sized for a terminal is not a record
'json' one structured line per completed request yes

Status 499 has one framework-wide meaning: the client closed the request. It is logged at info, not under the ordinary 4xx → warn rule. A confirmed disconnect is not sent through project onError, normalizeError or the request-error recorder; an AbortError while the request signal is still active remains an internal failure. A runtime abort reason may be preserved by identity through at most eight cycle-safe standard cause links; error messages and codes are never classifiers. The same rule applies when the disconnect happens while Stitchkit is reading a JSON upload body: bounded reads race every pending stream read against the request signal and never parse a cancelled partial body. 499 is transport telemetry, not a response declared in the contract or generated OpenAPI document.

Unset, format follows NODE_ENV: json under production, pretty otherwise. That default is read per request — not at import, not when this package was built — so it reflects the environment your app actually runs in. Set format and the environment stops being consulted at all.

If your project validates its environment through a single door — a Zod schema, @t3-oss/env-core, anything of that shape — set format explicitly from your value rather than leaning on the default. The library reads raw process.env, which is a second source of truth: let the two disagree on one deployment and production writes pretty without a word.

import { env } from '@/config'
createServer({ services, logging: { format: env.NODE_ENV === 'production' ? 'json' : 'pretty' } })

format applies to the built-in formatter only. With logger set, your sink always receives the structured object, in every environment — the format is not involved.

Want the structured line in development? Set format: 'json'. That is the way to see what enrich and the request context actually put on the record; changing NODE_ENV is not needed, and neither is deploying.

Three more things about enrich:

  • It runs at close, when the request body is already consumed.
  • The framework owns traceId, method, path, status, durationMs, errorCode and ip in both structured sinks; the built-in JSON line also owns ts, level and msg. Its value wins a collision, and a discarded key warns once per handler rather than disappearing silently.
  • errorCode has one outcome-aware exception: enrichment may supply it for a 4xx/5xx response when the framework derived no code, such as an error Response returned by a raw route. It cannot add one to a 2xx/3xx or replace a framework-derived code.
  • A throw in skip or enrich is swallowed; neither can fail a request.

With an observability context active, the structured line also carries userId, serviceName, action and dimensions for free. See Observability.

Route groups

A group gives a set of services a shared path prefix and its own hooks:

createServer({
  groups: [
    { pathPrefix: '/api',       services: [usersService, postsService] },
    { pathPrefix: '/api/admin', services: [adminService], hooks: { authorize: adminAuth } },
  ],
})

Each service’s own prefix is appended to the group prefix — usersService above is served at /api/users.

Param prefixes (resource-scoped paths)

A group pathPrefix may contain :param segments — the spine of a multi-tenant or resource-scoped API:

createServer({
  groups: [
    { pathPrefix: '/tenants/:tenantId', services: [widgetsService], hooks: { authorize: auth } },
  ],
})
// widgetsService (prefix 'widgets') → /tenants/:tenantId/widgets/...

Where the prefix param lands. The router matches the full path (group prefix + service prefix + endpoint path) and collects every :param — from the prefix and from the endpoint alike — into one set. Each is spread onto the context root, so it is available as ctx.tenantId (a raw string) in the authorize hook, handler and later lifecycle hooks:

beforeHandle: (ctx) => {
  const tenantId = ctx.tenantId   // string — from the group prefix
}

ctx.tenantId is typed unknown (it rides the context index signature) — narrow it (String(ctx.tenantId) / a guard) at the read site.

Relation to the endpoint params schema. ctx.params is the endpoint’s params schema parsed against all collected path params (prefix + endpoint). So to get the prefix param inside typed ctx.params, add it to that schema:

// endpoint under /tenants/:tenantId
{ method: 'GET', path: '/:widgetId', desc: 'Get a widget',
  params: z.object({ tenantId: z.string(), widgetId: z.string() }) }
// → ctx.params.tenantId and ctx.params.widgetId both typed

⚠️ A z.strictObject params schema that omits the prefix param rejects the request (the extra tenantId key fails the strict parse). Either include every prefix param in the schema, use a non-strict z.object (extra keys are dropped from ctx.params, but ctx.tenantId still works), or read the param off the context root.

Trailing wildcard. A contract path may end in a named wildcard. /app/:slug/*filePath matches both /app/foo and nested paths such as /app/foo/a/b; the collected params are { slug: 'foo', filePath: '' } and { slug: 'foo', filePath: 'a/b' } respectively. Put filePath in the endpoint’s params schema to keep it in typed ctx.params. Each captured segment is URL-decoded before the remainder is joined, so encoded spaces and reserved characters reach the handler as their semantic values while / remains the segment boundary. Static and named-param routes are matched before a catch-all, so a more specific endpoint wins regardless of declaration order. The same matcher drives 405 Allow resolution.

Scope-driven mounting (scopePrefixes)

With several scopes, hand-partitioning services into groups duplicates the scope↔prefix mapping. Instead, map scope → prefix once and pass the flat services list — each entry mounts under scopePrefixes[service.scope]:

createServer({
  services,   // mixed scopes, listed once
  scopePrefixes: { tenant: 'tenants/:tenantId', project: 'projects/:projectId' },
})
// scope 'tenant'  → /tenants/:tenantId/<prefix>/...
// scope 'project' → /projects/:projectId/<prefix>/...
// unmapped scope  → mounted flat

A prefix may carry :param segments (they land on the context exactly as above). Services listed under explicit groups are unaffected — the group prefix wins. Scope stays a free string (unless the contract is authored through createContractFactory<Scope>()); the core attaches no meaning beyond this lookup (→ ADR 0024). When each scope needs a different handler-context shape, use createScopedImplement rather than a single superset context.

Lifecycle hooks

Five hooks wrap every contract request. Route matching and path-param validation happen before authorization; body/query parsing happens only after authorization succeeds:

createServer({
  services,
  hooks: {
    onRequest(req)               { /* logging, global rate limit — may return a Response to short-circuit */ },
    authorize(ctx, endpoint)     { /* identity + scope, before body reads — throw to reject */ },
    beforeHandle(ctx, endpoint)  { /* validated-input preconditions */ },
    afterHandle(ctx, result, ep) { /* transform the result data */ },
    onError(ctx, error, ep)      { /* custom error response — return a Response */ },
  },
})
  • onRequest — runs first, with the raw Request. Return a Response to short-circuit (a rate-limit 429, a redirect); return nothing to continue.
  • authorize — runs after route matching and validated path params, but before query, JSON or multipart parsing. It receives request metadata and params, with input: undefined and no files. This is the HTTP home of createAuthHook.
  • beforeHandle — runs after the complete context has been parsed and validated, immediately before the handler. Put input-dependent application preconditions here, not authentication.
  • afterHandle — receives the handler result; return a replacement to transform it.
  • onError — for a matched contract route, the group’s hook runs first, then the global hook, then the standard error envelope. The first returned Response wins (including its status and headers); an absent hook, undefined return or thrown/rejected hook continues to the next level with the original error and the same context and endpoint. A failing hook is diagnosed through logging.logger.error (or the internal console fallback), never substituted into the client response. This covers path/body validation, authorization, before/after hooks and handler errors, including raw-response contract handlers. Global onRequest failures, rawRoutes and unmatched 404/405 have no matched contract group and use only the global hook. A confirmed client disconnect deliberately bypasses both hooks; errors after streaming headers have been sent keep their existing stream-error path.

Hooks see RuntimeContext (loose types); handlers see HandlerContext (typed). That split is deliberate — see ADR 0003.

Signed JSON webhooks

A provider signs the original JSON text, not JSON.stringify(ctx.input). Declare rawBody: true to retain the same decoded text the router reads while keeping normal Zod validation:

const webhooks = defineContract(
  { prefix: 'webhooks' },
  {
    receive: {
      method: 'POST', path: '/provider', desc: 'Receive a signed event',
      rawBody: true,
      maxJsonBodyBytes: 256 * 1024,
      input: ProviderEventSchema,
      output: z.object({ accepted: z.boolean() }),
    },
  },
)

receive: async (ctx) => {
  const signature = ctx.req.headers.get('x-signature')
  await verifyWebhookHmac(ctx.rawBody, signature) // guaranteed string
  return { accepted: true }
}

This endpoint is HTTP-only and cannot be multipart or exposed as a tool. The router sets ctx.rawBody before JSON/Zod validation, so onError can inspect it after malformed JSON or a schema failure. Endpoints without rawBody: true do not retain the text. maxJsonBodyBytes may also be set once on createServer / createHandler; a route value wins. Both limits are opt-in and abort an oversized stream before it is fully buffered. → ADR 0051

Safelisted request bodies (beacons)

A body the router reads is application/json, and a non-empty body with any other Content-Type is a 400. That is deliberate: a text/plain body is a CORS simple request, sent with cookies from any site before the browser asks whether the page may read the reply — so requiring JSON forces a preflight, and the preflight is where a foreign form is stopped.

A page that is being unloaded does not get a preflight. navigator.sendBeacon with an application/json blob and fetch({ keepalive: true }) with a JSON Content-Type both report success and both die before the server sees them when the API is on another origin. A beacon with a string body is text/plain and arrives. Declare the endpoint that receives it:

const tracking = defineContract(
  { prefix: 'tracking', scope: 'public' },
  {
    track: {
      method: 'POST', path: '/events', desc: 'Batch track browser events',
      safelistedBody: true,
      maxJsonBodyBytes: 256 * 1024,
      input: TrackEventsRequestSchema,
      output: TrackEventsResponseSchema,
    },
  },
)

// browser, on `pagehide`
navigator.sendBeacon(urls.tracking.track(), JSON.stringify(batch)) // text/plain;charset=UTF-8

The body is parsed by the same JSON parser, validated against the same input and bounded by the same maxJsonBodyBytes; application/json keeps working. What changes is one rule, and it is the rule that keeps the door from being a hole: a text/plain body is accepted only from an Origin on the server’s explicit cors.origin allow-list. Everything else is a 403 before the text is read:

Request Outcome
Origin on the allow-list (string or list, case-insensitive) parsed
Content-Type: text/plain; charset=application/json from a foreign origin 403 — the media type is compared whole, never as a substring
Origin of a site the server never named 403
Origin: null — sandboxed iframe, cross-origin redirect, file:// 403
no Origin header 403
server cors.origin: '*' 403 — a wildcard is not an allow-list
server without cors 403 — there is no allow-list
application/json from any origin unchanged — never subject to the check

The check is a browser invariant, not authentication: a curl with a stolen cookie and a forged Origin passes, and that is an authenticated call, not a cross-site one. Two things follow for the application:

  • sendBeacon sends no headers. A bearer-authenticated client has to put its token in the body — and the body is read after authorize, so that token is available in beforeHandle or the handler, never in an auth hook. Cookie-authenticated apps are unaffected.
  • POST only. defineContract refuses the flag on GET, HEAD, PUT, PATCH, DELETE, multipart, streaming and input-less endpoints: none of them is a simple request with a JSON body, so the flag would only widen the surface.

Unlike rawBody, the flag does not force expose: ['HTTP'] — a beacon endpoint may still be a tool; only HTTP body parsing changes. OpenAPI lists both media types with one schema. → ADR 0165

A raw route has none of this: parseBody reads any Content-Type, so a raw beacon route is reachable from any site with the user’s cookies unless the route checks Origin itself. Prefer the flag.

Choosing an HTTP boundary

Need Contract declaration What remains framework-owned
Typed JSON request/response ordinary input / output routing, auth, schemas, hooks, client, OpenAPI
HMAC-signed JSON rawBody: true + input / output the same pipeline plus the exact decoded request text
Page-unload beacon from another origin safelistedBody: true + input / output the same pipeline; a text/plain body is admitted only from an allow-listed Origin
File upload typed multipart descriptor file cardinality/limits, text input validation and client form encoding
File, stream or redirect response behind contract auth rawResponse: true request parsing, route identity, auth and typed URL/client surface
Transport that cannot be expressed as the contract pipeline RawRoute only raw routing, CORS, request hook and error normalisation

rawBody is for JSON signatures: verify ctx.rawBody against the signature header, then use the already validated ctx.input. A provider-specific binary signature protocol, OAuth callback or WebSocket upgrade can be a real raw route, but it must own its validation and authorization explicitly.

Typed JSON response metadata

A JSON endpoint that must attach dynamic HTTP headers while preserving typed output declares responseMeta. The handler still returns ordinary data:

const auth = defineContract({ prefix: 'auth' }, {
  complete: {
    method: 'POST', path: '/complete', desc: 'Complete authentication',
    input: CompleteAuthSchema,
    output: AuthUserSchema,
    responseMeta: { status: 200 },
  },
})

complete: async ({ input, response }) => {
  const result = await authenticate(input.token)
  response.headers.append('Set-Cookie', session.set(result.sessionId))
  response.headers.append('Set-Cookie', preferences.set(result.preferencesId))
  return result.user
}

ctx.response.headers is a fresh Web Fetch Headers bag per request. append preserves repeated Set-Cookie values on Bun and Node. The endpoint is HTTP-only, but its typed client method still resolves to AuthUser — not Response — and the final data still passes group/global afterHandle and the declared output schema exactly once.

responseMeta.status is static contract metadata and OpenAPI publishes the same 2xx code. Without it, an endpoint with output uses 200; an endpoint without output uses 204. A nullable output returns JSON null with status 200 — the runtime value never turns a declared response body into no content. undefined violates a declared output, while returning non-null data without an output schema is also a server fault. Bodyless 204/205 cannot be combined with output. Redirects, streams, files and handler-owned status/body logic remain rawResponse: true.

Collected headers are merged only after the complete success pipeline. A handler, hook or output-validation failure discards them. Content-Type, Content-Length, x-request-id and every Access-Control-* header remain framework-owned; trying to set one fails loudly with the endpoint identity. → ADR 0052

Raw-response endpoints

An endpoint that answers with bytes rather than data — a PDF download, a file, an SSE stream — declares rawResponse: true and returns the Response itself:

// contract
export const documents = defineContract(
  { prefix: 'documents', scope: 'admin' },
  {
    download: {
      method: 'GET', path: '/:id/pdf', desc: 'Download a document as a PDF',
      params: z.object({ id: z.uuid() }),
      rawResponse: true, contentType: 'application/pdf',
    },
  },
)

// handler — no guard on the first line; `beforeHandle` already ran
download: (ctx) => serveFile(ctx.req, { path: pathFor(ctx.params.id),
                                        filename: 'offer.pdf' }),

The request half is untouched: params, input and multipart parse and validate exactly as elsewhere, and the endpoint goes through beforeHandle — so the auth gate applies without a guard in the handler. Only the response is handed over, so there is no output schema, afterHandle is skipped (it transforms data; there is none) and the endpoint is HTTP-only: never an MCP tool, an agent tool or a CLI command. Declaring output, tool or a non-HTTP expose alongside rawResponse is a type error, and throws at definition time for a contract assembled at runtime.

On the typed client the method resolves to the untouched Response — the filename lives in Content-Disposition, so a Blob alone would lose it:

const client = createClient(documents, http)
const res = await client.download({ id })          // Response
const name = res.headers.get('Content-Disposition')
const blob = await res.blob()

When the browser should navigate or assign the endpoint directly to src, use createUrlBuilder. Raw-response GET methods are included, while mutation and multipart methods are intentionally absent.

Cross-origin, remember that those headers are readable only because CORS exposes them — see cors.exposeHeaders.

Raw response or raw route? Both hand the Response to your code. A raw-response endpoint stays in the contract: only its response is raw — it is still routed, gated, typed and documented like every other endpoint. A raw route is outside the contract entirely — no schemas, no auth gate, no client — which is what you want for an OAuth redirect or a non-JSON webhook. A signed JSON webhook can stay validated through rawBody: true.

If an endpoint returns typed JSON and only needs an additional status/header, use responseMeta, not rawResponse: the raw variant deliberately transfers response ownership and changes the client result to Response.

⚠️ Delete the old raw route when you move an endpoint into the contract. Raw routes are matched first, so a leftover one keeps serving the bytes and the contract endpoint — with its auth gate — never runs. stitchkit warns at startup when a raw route shadows a contract route, naming both and the scope being bypassed; treat that warning as a bug.

⚠️ A path built from user input needs a containment check. staticRoute enforces it; serveFile deliberately leaves it to the caller, so an endpoint serving /:filename must not pass it through:

import { isWithinDir, serveFile } from 'stitchkit/server'
import { resolve } from 'node:path'

const ROOT = resolve('./uploads')

file: (ctx) => {
  const target = resolve(ROOT, ctx.params.filename)
  // `../../etc/passwd` resolves outside ROOT — reject before touching disk.
  if (!isWithinDir(ROOT, target)) throw notFound('File not found')
  return serveFile(ctx.req, { path: target })
},

implementRemote proxies a raw-response endpoint like any other — the remote Response is forwarded verbatim. Request headers are not relayed, so a Range sent to the proxy does not reach the origin and the full body comes back. → ADR 0038.

Raw routes

Some routes cannot be a clean JSON contract — an OAuth redirect, a webhook with signature verification, static files, the Socket.IO endpoint. rawRoutes are plain Request → Response handlers, matched by the same router (shared CORS and onRequest) but with no schema parsing and no beforeHandle gate — a raw route authorises itself.

createServer({
  services,
  rawRoutes: [
    {
      method: 'GET',
      path: '/health',
      handler: () => Response.json({ status: 'ok' }),
    },
    {
      method: 'POST',
      path: '/webhooks/:provider',
      serviceName: 'webhooks',
      action: 'receive',
      handler: (req, ctx) => handleWebhook(ctx.params.provider, req),
    },
  ],
})

serviceName and action are optional observability identity. Declare serviceName when audit, logging, sampling or another cross-cutting policy groups this raw route with a service; add action when the policy needs an operation within that service. Stitchkit never derives either from path. action without serviceName, and empty identity strings, fail at startup.

A path may be exact, carry :param segments, or end in /*filePath for a prefix wildcard — and the two combine: /app/:slug/*filePath matches /app/x/a/b with ctx.params.slug === 'x' and the remainder in ctx.params.filePath (a SPA deep-link fallback). List more specific routes before the wildcard — the first match wins. staticRoute() builds a raw route that serves a directory.

At startup Stitchkit rejects exact duplicates, equivalent parameter shapes (/users/:id vs /users/:userId) and any later raw route completely hidden by an earlier raw route. Partial overlap stays legal, including the recommended specific-before-wildcard order. GET and HEAD remain independent because that is how the actual raw matcher dispatches them. Raw-vs-contract overlap remains a startup diagnostic naming the bypassed contract identity and scope.

Raw-route helpers

A raw route gives up the contract pipeline, so three things get re-implemented in every one. stitchkit/server ships them — conveniences, not a second pipeline (no auth, no schema gate beyond parseBody):

import { respondJson, errorResponse, parseBody, badRequest } from 'stitchkit/server'

handler: async (req) => {
  try {
    const body = await parseBody(req, MySchema)      // typed value, or null (no throw)
    if (!body) throw badRequest('invalid body')      // helpers THROW an AppError
    return respondJson(await myService(body))         // JSON; 204 when null/undefined
  } catch (err) {
    return errorResponse(err)                         // same envelope as a contract route
  }
}

parseBody reads the body up to { maxBytes } — 1 MiB when you pass none, as the third argument — and returns null for a larger one without reading further, so a raw route never holds an unbounded body in memory.

The error helpers (badRequest, notFound, …) throw an AppError, so raise them inside the try and let errorResponse(err) render it — that runs any thrown value through the framework’s normalizeError, so a raw route returns the identical { error: { code, message, … } } shape (and x-request-id) a contract route does.

Serving files & Range requests

staticRoute is basic on purpose — it reads the whole file into memory and has no Range or conditional support; put a CDN in front, or use it for small web assets. To serve media (video / audio / large downloads) that a browser must seek and cache, use serveFile (Bun) — it streams the requested byte range and speaks the conditional-request half of RFC 7233 / 9110:

import { defineContract } from 'stitchkit/contract'
import { implement, serveFile } from 'stitchkit/server'
import { z } from 'zod'

const MediaParams = z.object({ id: z.string() })
const media = defineContract({ prefix: 'media' }, {
  download: {
    method: 'GET', path: '/:id', desc: 'Download media',
    params: MediaParams, rawResponse: true, contentType: 'video/mp4',
  },
  inspect: {
    method: 'HEAD', path: '/:id', desc: 'Inspect media',
    params: MediaParams, rawResponse: true, contentType: 'video/mp4',
  },
})

const mediaService = implement(media, {
  download: ({ req, params }) =>
    serveFile(req, { path: pathForId(params.id), filename: 'clip.mp4' }),
  inspect: ({ req, params }) =>
    serveFile(req, { path: pathForId(params.id), filename: 'clip.mp4' }),
})

GET and HEAD are separate operations deliberately: declaring GET never creates a hidden HEAD alias. Both travel through the normal contract router, params, lifecycle/RBAC and request logging. A HEAD handler may inspect the raw query via ctx.req.url, but cannot declare a request input schema or body.

serveFile returns 206 (range, with Content-Range + Content-Length), 200 (full), 416 (unsatisfiable, Content-Range: bytes */size), 304 (If-None-Match / If-Modified-Since), 404 (missing) or 405 (non GET/HEAD). It always sets Accept-Ranges: bytes, a weak ETag and Last-Modified (so If-Range keeps a changing file from being stitched from stale + fresh bytes), and nosniff. Content-Type is auto-detected from the path — override it, or pass disposition / cacheControl / etag: false, via the options.

Cross-origin, the browser lets JavaScript read only the CORS-safelisted response headers. stitchkit therefore exposes the download-relevant ones by default (Content-Disposition, Content-Range, ETag, …) — without that a fetch-based download cannot recover the file’s name. Override with cors.exposeHeaders (extend DEFAULT_CORS_EXPOSE_HEADERS rather than replacing it), or pass [] to emit none.

serveFile takes an explicit path and trusts it — the caller owns containment. For a URL-derived path use staticRoute (which enforces it) or isWithinDir first. The byte-range parser is exported on its own as parseByteRange(header, size) for direct use and testing. → ADR 0023.

Server primitives

stitchkit/server also exports the primitives most APIs need. Each is a small, focused helper — not a sub-framework.

Helper Does
serveFile() serve a file with Range / 304 / HEAD (media seeking)
streamSSE() turn an AsyncGenerator into a Server-Sent-Events Response
ndjsonRoute() / sseRoute() a long-lived subscription route, with the whole checklist
parseMultipart() parse a typed buffered/streaming multipart descriptor
createRateLimiter() per-key token-bucket rate limiting
createCache() + cacheHeaders() in-memory TTL cache; Cache-Control builder
createEventBus<EventMap>() typed in-process pub/sub

Contract-first streams

Use an endpoint stream descriptor when the item schema and completion belong to the contract, rather than handing an application-owned Response to rawResponse:

const Item = z.discriminatedUnion('kind', [
  z.object({ kind: z.literal('line'), text: z.string() }),
  z.object({ kind: z.literal('complete'), count: z.number().int() }),
])

const reports = defineContract({ prefix: 'reports' }, {
  watch: {
    method: 'GET', path: '/:id/watch', desc: 'Watch one report',
    params: z.object({ id: z.string() }),
    stream: {
      item: Item,
      format: 'ndjson',
      maxFrameBytes: 64 * 1024,
      terminal: z.object({ kind: z.literal('complete') }).loose(),
    },
  },
})

const service = implement(reports, {
  watch: async function* ({ params, signal }) {
    yield { kind: 'line', text: `starting ${params.id}` }
    if (signal.aborted) return
    yield { kind: 'complete', count: 1 }
  },
})

The handler return is inferred as AsyncIterable<z.output<typeof Item>>; an invalid JavaScript producer is rejected before its value reaches the wire. Data, safe errors and normal end use a framework envelope. The default encoded frame limit is 256 KiB. format defaults to ndjson; heartbeatMs defaults to five seconds; idleTimeoutSeconds defaults to disabled; lifetimeMs is optional.

After headers, HTTP status can no longer report failure, so the stream sends a normalized code and never the raw internal exception. Normal producer completion sends an explicit end; when terminal is declared, at least one item must match it. Request abort and client iterator return abort signal, including a source waiting in next() or suspended at a yielded item.

Contract streams are HTTP-only and cannot declare output, rawResponse, multipart or tool exposure. They do not provide replay, cursors or durable subscriptions. Keep using streamingRoute for an application-owned protocol and rawResponse for arbitrary response bodies. → ADR 0117.

For an existing schema-owned NDJSON protocol, opt into direct item frames and terminal-owned completion:

stream: {
  item: Item,
  framing: 'item',
  completion: 'terminal',
  terminal: z.object({ kind: z.literal('complete') }).loose(),
  finalLine: 'require-newline',
}

item framing is NDJSON-only and requires terminal completion. The server writes each validated item directly, stops the source after the terminal and never reads trailing producer values. Because this wire has no framework error envelope, any producer/lifetime failure before terminal closes the response; the typed client reports STREAM_TERMINAL_MISSING. The terminal item therefore proves success without weakening safe post-header failure semantics. Existing streams retain the envelope and explicit end frame by default. → ADR 0126.

SSE streaming

streamSSE returns a Response, so its endpoint declares rawResponse: true — in a plain contract handler the response would be serialized into {} (that now fails loudly instead of shipping silently).

import { streamSSE } from 'stitchkit/server'

// contract
stream: { method: 'GET', path: '/stream', desc: 'Stream tokens',
          rawResponse: true, contentType: 'text/event-stream' },

// handler
async function* tokens() { yield 'a'; yield 'b' }
stream: () => streamSSE(tokens()),    // → a text/event-stream Response

The client side is parseSSE.

streamSSE is for a stream that finishes — a completion, a job’s output. For one that stays open, see below.

Long-lived subscriptions

streamSSE assumes the generator keeps producing. A subscription is the opposite: silence is its normal state, and three unrelated things have to be right or it breaks without saying anything. (Measured: with a heartbeat under the threshold, either of the first two alone keeps an in-process connection alive — the first earns its place against what a heartbeat cannot reach, an intermediary applying its own idle rule.)

  1. The generic idle timeout has to go. Without server.timeout(req, 0) Bun resets the connection after ten seconds — a healthy connection severed on a schedule, precisely because the subscriber had nothing to be told.
  2. Something has to be on the wire. Even with the timeout gone, intermediate proxies are under no obligation to hold a connection carrying no bytes.
  3. The headers have to leave at open. A runtime sends nothing until the body produces a byte, so the consumer’s fetch does not return. “Subscribed and silent” then looks exactly like “not answering”, and there is nothing to inspect because there is no response yet.

ndjsonRoute / sseRoute do all three, and close the source when the consumer goes away:

import { ndjsonRoute } from 'stitchkit/server'

const events = ndjsonRoute({
  path: '/events/subscribe',
  heartbeatMs: 5_000,                       // default; keep it well under 10s
  source: async function* (request, { signal }) {
    for await (const event of subscribe({ signal })) yield event
  },
})

createServer({ port: 3000, rawRoutes: [events] })

The client half is parseNDJSON — and the keep-alive frame is an empty line, so “blank lines are skipped” is part of the documented contract rather than an agreement between two halves of one project. sseRoute frames the same source as SSE and is read by parseSSE unchanged.

Honour context.signal. It is the one part the route cannot do for you, and the reason is worth knowing: an async generator serialises its requests, so iterator.return() issued while a next() is in flight is queued behind it. A subscription is in next() almost always, so the close would wait for an event that may never come. The signal is aborted the moment the consumer disconnects — through either route, a request abort or a stream cancel — and a source that waits on it stops at once. (iterator.return() is still called; it closes a source suspended at a yield.)

A failure part-way through arrives as a final frame carrying the framework error envelope, normalised — once the headers are gone there is no status left to send, and an internal message must not reach the wire raw.

Multipart

The contract owns one descriptor for buffered and streaming delivery:

const uploads = defineContract({ prefix: 'uploads' }, {
  create: {
    method: 'POST', path: '/', desc: 'Upload media',
    input: UploadMetadataSchema,
    output: UploadedMediaSchema,
    multipart: {
      maxRequestBytes: 220 * 1024 * 1024,
      maxFieldBytes: 64 * 1024,
      files: {
        cover: { required: false, maxBytes: 10 * 1024 * 1024, contentTypes: ['image/*'] },
        media: { multiple: true, maxFiles: 4, maxBytes: 50 * 1024 * 1024 },
      },
    },
  },
})

Buffered delivery is the default. Each file becomes a Web File; single, optional and multiple cardinality is inferred on ctx.files. The request cap defaults to 25 MB when omitted. The parser measures actual bytes including boundaries and headers instead of trusting Content-Length; per-file bytes, file count, text-field bytes and declared MIME policy are enforced while reading. Text fields remain strings until the endpoint’s Zod input parses them.

For large files, set delivery: 'stream' and define receivers. A receiver gets a Web ReadableStream<Uint8Array> and writes directly to consumer-owned storage; Stitchkit holds only bounded parser state:

const streamingUploads = defineContract({ prefix: 'uploads' }, {
  create: {
    method: 'POST', path: '/', desc: 'Upload media',
    input: UploadMetadataSchema,
    output: UploadedMediaSchema,
    multipart: {
      delivery: 'stream',
      maxRequestBytes: 260 * 1024 * 1024,
      files: { media: { maxBytes: 250 * 1024 * 1024, contentTypes: ['video/*'] } },
    },
  },
})

const service = implement(streamingUploads, {
  create: defineMultipartStream(streamingUploads.endpoints.create, {
    files: {
      media: async ({ metadata, stream, signal }) => {
        const stored = await storage.write({ metadata, stream, signal })
        return {
          value: stored,
          cleanup: () => storage.remove(stored.key),
        }
      },
    },
    handler: ({ input, files }) => mediaService.attach(input, files.media),
  }),
})

Receivers run sequentially in multipart order. A multiple receiver runs once per part and the handler gets an ordered value array. cleanup is registered as soon as a receiver materialises external state. Disconnect, size/policy failure, a later receiver/text validation failure or handler failure rolls accepted handles back exactly once in reverse order. After handler success, ownership transfers to the application.

Authorization always completes before the multipart parser or any receiver is started. The receiver signal is tied to the request abort. The core does not provide filesystem/S3 adapters, retries, antivirus or a distributed storage/database transaction; those policies belong to the application.

parseMultipart(req, descriptor, fieldsSchema?, receivers?) is also exported for a custom raw transport. It uses the same descriptor and returns { files, fields, rollback }; contract endpoints should prefer the automatic dispatcher path.

Both delivery modes preserve UTF-8 filename metadata, including browser FormData names. For interoperability, a UTF-8 percent-encoded filename* parameter takes precedence over filename; unsupported charsets or malformed encoding are rejected rather than falling back silently. Literal percent sequences in ordinary filename are not decoded. Filenames remain untrusted metadata, not safe filesystem paths. Part headers have a 64 KiB byte limit, are case-insensitive and reject duplicates, folding, invalid header names and control characters. Disposition parameters reject duplicates and unsafe field names; declared part sizes must be nonnegative safe decimal integers.

Rate limiting

import { createRateLimiter } from 'stitchkit/server'

const limiter = createRateLimiter()
// in onRequest:
if (!limiter.check(ip, { window: 60_000, max: 60 })) {
  return new Response('Too many', { status: 429 })
}

Event bus

import { createEventBus } from 'stitchkit/server'

const bus = createEventBus<{ 'user.created': { id: string } }>()
bus.on('user.created', ({ id }) => sendWelcome(id))
bus.emit('user.created', { id: '1' })

A typed in-process pub/sub — decouple a handler from the side effects of its write without reaching for an external queue.

OpenAPI

generateOpenApiDocument builds an OpenAPI 3.1 document straight from the contracts — the contract is the spec, no decorators or hand-maintained annotations (→ ADR 0018). openApiRoute serves it as a raw route:

import { generateOpenApiDocument, openApiRoute } from 'stitchkit/server'

const doc = generateOpenApiDocument({
  info: { title: 'My API', version: '1.0.0' },
  services: [users, orders],
})
createServer({ services: [users, orders], rawRoutes: [openApiRoute('/openapi.json', doc)] })

Only HTTP-exposed methods appear (an MCP/agent-only tool is skipped).

OpenAPI 3.1 has no standard multi-segment path parameter. For a contract path ending in a named wildcard such as /*filePath, Stitchkit keeps the literal runtime path, omits filePath from the standard in: path parameter list, and emits x-stitchkit-trailing-wildcard on the operation with its parameter name, schema and semantics. A generic OpenAPI client therefore cannot invent catch-all expansion; use Stitchkit’s typed client or teach the generator that extension.

Curating the spec — includeMethod

To publish a subset — a public spec that advertises only some methods without revealing the rest — pass includeMethod. It keeps the core generic: you decide the policy, filtering on anything the method carries. The recommended declarative allowlist marks endpoints with the existing meta passthrough and keeps those:

// contract — declarative, one source of truth
getBalance: { method: 'GET', path: '/balance', desc: '…', scope: 'account',
              meta: { public: true }, output: BalanceSchema }

// generation — the app's policy
const publicDoc = generateOpenApiDocument({
  info: { title: 'Public API', version: '1.0.0' },
  services: [account],
  includeMethod: (m) => m.meta?.public === true,
})

An excluded method’s whole entry — path and every schema inlined within it — is simply never emitted, so nothing about a hidden endpoint leaks.

The filter advertises; it does not authorize. Hiding a method from the spec does not protect it — it is still callable, and the auth scope gate is the only thing guarding it. And because openApiRoute closes over the document you hand it, the filter only matters if you feed it a filtered one: serve two documents — a full internal spec and a filtered public spec on separate routes — never one unfiltered openApiRoute on a public path.

const internal = openApiRoute('/internal/openapi.json', fullDoc)   // behind auth
const publicSpec = openApiRoute('/openapi.json', publicDoc)         // curated