← 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
file File the multipart upload, if any
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.

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

createServer

createServer(config) builds the router and starts Bun.serve(). It returns the Bun server instance.

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)
maxUploadBytes default multipart upload cap (bytes); per-route EndpointDef.maxUploadBytes overrides
port / hostname listen address — port defaults to 3000
cors CORS policy — { origin, … }
hooks lifecycle hooks (see below)
logging true for built-in request logs, or a custom StitchLogger
traceId override per-request trace-id resolution
websocket Bun WebSocket handlers — e.g. from createSocketIOServer
routes / development / bun passthrough to Bun.serve

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: { beforeHandle: 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: { beforeHandle: 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 both the handler and beforeHandle/afterHandle/onError:

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.

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; the core attaches no meaning beyond this lookup (→ ADR 0024). When each scope needs a different handler-context shape, declare one createImplement<Ctx>() per scope rather than a single superset context.

Lifecycle hooks

Four hooks wrap every contract request, in order:

createServer({
  services,
  hooks: {
    onRequest(req)               { /* logging, global rate limit — may return a Response to short-circuit */ },
    beforeHandle(ctx, endpoint)  { /* auth, scope checks — throw to reject */ },
    afterHandle(ctx, result, ep) { /* transform the result, set cache headers */ },
    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.
  • beforeHandle — runs after the context is built, before the handler. Throw an AppError to reject. This is where auth lives — createAuthHook is a beforeHandle.
  • afterHandle — receives the handler result; return a replacement to transform it.
  • onError — receives any thrown error; return a Response to customise the error body. Without it, errors render through the standard envelope.

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

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',
      handler: (req, ctx) => handleWebhook(ctx.params.provider, req),
    },
  ],
})

A path may be exact, carry :param segments, or end in /* for a prefix wildcard — and the two combine: /app/:slug/* matches /app/x/a/b with ctx.params.slug === 'x' and the remainder in ctx.params['*'] (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.

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
  }
}

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 { serveFile } from 'stitchkit/server'

createServer({
  services,
  rawRoutes: [
    {
      // `ALL` — so a HEAD probe also reaches serveFile (raw routes match the
      // method exactly, and `HEAD` is not a contract `HttpMethod`); serveFile
      // itself handles GET + HEAD and answers 405 for anything else.
      method: 'ALL',
      path: '/media/:id',
      handler: (req, ctx) =>
        serveFile(req, { path: pathForId(ctx.params.id), filename: 'clip.mp4' }),
    },
  ],
})

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.

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
parseMultipart() parse a multipart/form-data request with a size cap
createRateLimiter() per-key token-bucket rate limiting
createCache() + cacheHeaders() in-memory TTL cache; Cache-Control builder
createEventBus<EventMap>() typed in-process pub/sub

SSE streaming

import { streamSSE } from 'stitchkit/server'

async function* tokens() { yield 'a'; yield 'b' }
return streamSSE(tokens())            // → a text/event-stream Response

The client side is parseSSE.

Multipart

import { parseMultipart } from 'stitchkit/server'

const { file, fields } = await parseMultipart(req, { maxBytes: 10_000_000 })

When an endpoint declares multipart, the framework parses the upload for you and the file arrives as ctx.file — call parseMultipart directly only from a raw route.

The upload cap defaults to 25 MB. Raise it per route with EndpointDef.maxUploadBytes, or set a server-wide default with createServer({ maxUploadBytes }) — a per-route value wins over the global:

// contract
upload: { method: 'POST', path: '/', desc: 'Upload a video',
          multipart: 'file', maxUploadBytes: 200 * 1024 * 1024 }

// server — default for every multipart route that declares no own cap
createServer({ services, maxUploadBytes: 50 * 1024 * 1024 })

Rate limiting

import { createRateLimiter } from 'stitchkit/server'

const limiter = createRateLimiter({ capacity: 60, refillPerSecond: 1 })
// in onRequest: if (!limiter.take(ip)) 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).

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