@amritk/api
v0.16.1
Published
Framework-agnostic, contract-first API layer built on mjst JSON Schema tooling. Typed routes, fast request/response validation, and OpenAPI 3.2 generation with no extra code — adapters for fetch (Hono, Next.js, Bun, Workers) and Node (Express, Fastify).
Maintainers
Readme
@amritk/api
Contract-first, framework-agnostic API layer built on mjst's
JSON Schema tooling. Declare each route once — method, path, request schemas, response
schemas, handler — and get typed handlers, fast request/response validation,
and an OpenAPI 3.2 document with no extra code. Two thin adapters connect
the same API to every JavaScript server framework — Bun, Cloudflare Workers,
Deno, Hono, Next.js, SvelteKit, Nitro/Nuxt, Elysia (fetch), and node:http,
Express, Fastify, Koa, NestJS (Node) — with a
recipe for each.
- One contract, everything derived. The JSON Schemas in a route type the
handler (via
FromSchema), validate requests at runtime, and embed verbatim into the OpenAPI document — OpenAPI 3.2's schema dialect is JSON Schema Draft 2020-12, so there is no conversion layer to drift. - Fast by structure. All schema work (validator preparation, coercion planning, path parsing) happens once at startup. Per request: an O(1) map hit for static paths, a boolean guard that short-circuits and never allocates on valid input, and error collection that only runs after a guard has already said no. Query strings and bodies are parsed lazily — routes that do not declare them never pay for them.
- Typed end to end. Handlers receive
params/query/bodyalready validated and coerced, typed from the schema literals. The return type is derived from theresponsesmap — returning an undeclared status or a wrong body shape is a compile error. - Eval-free. The default engine is
@amritk/runtime-validators— nonew Function, so it runs under strict CSP, Cloudflare Workers, and React Native. Swappable for generated validators when you want maximum steady-state throughput (see below). - The whole HTTP surface. Streaming/raw replies with client-disconnect signals, raw body access for webhook signatures, body size limits, request-header schemas, hook chains for CORS/rate limits/security headers, and pluggable error envelopes — each shipped in both the runtime and compiled engines.
- Contract/handler split with a derived typed client. Declare contracts as
pure data (
defineContract), bind server handlers separately (implementRoute), and derive a typed fetch client (createClient) from the same literals — no codegen, browser-safe imports, thehcreplacement for teams leaving Hono RPC. - One dependency, many integrations. Drizzle, Better Auth, Sentry, and
typed clients connect through seams —
context,mounts,onError,locals, OpenAPI — not bundled SDKs. Recipes below.
Contents
Getting started
- How this compares to a web framework · what it deliberately does not do
- Usage · Contracts without handlers (browser-safe) · Typed client:
createClient
Serving it — one Api, two adapters, a recipe per framework
- fetch: Bun · Cloudflare Workers · Deno · Hono · Next.js · SvelteKit · Nitro / Nuxt · Elysia
- Node:
node:http· Express · Fastify · Koa · NestJS · anything else
Requests and responses
- Options (
createApi) · Validation semantics · String formats · Branded IDs · Cross-field refinement - Form and multipart bodies · Raw text and binary bodies · Raw request bodies and size limits
- Streaming and raw responses · Returning a raw
Response· Multipleset-cookieheaders · The platform request:request.raw
Middleware, security, state
- Hooks: CORS, rate limits, security headers · Built-in security hooks · Signed cookies
- Framework-parity helpers · Client-side auth refresh · Per-request state:
locals
Engines
Integration recipes
- App context: Drizzle, sessions · Guards · Deny-by-default:
secureRoutes· Auth: Better Auth · Sessions: a production setup - Observability · OpenAPI: servers, auth schemes, components · Error reporting: Sentry · Typed client for external consumers: Hey API · Schemas from Zod, TypeBox, Valibot, Effect
About
How this compares to a web framework
@amritk/api is not a server — handle(ApiRequest) → ApiResponse is the whole
runtime, and a framework hosts it (app.mount('/', toFetchHandler(api))
under Hono, middleware under Express). So the question is rarely
"this or Hono"; it is this inside whatever you already run, weighed against
the stack you would otherwise assemble for a validated, documented API — a
framework plus a validator middleware plus an OpenAPI plugin plus an RPC client
(hono + @hono/zod-validator + @hono/zod-openapi + hc, or the
Express/Fastify equivalents).
Against that stack:
| | framework + validator + OpenAPI plugin | @amritk/api |
|:--|:--|:--|
| Declaring a route | a chain — app.get(path, zValidator('param', schema), handler) — and the OpenAPI plugin adds a second way to declare the same route | one defineRoute object: method, path, request schemas, responses, handler |
| Schema language | Zod / Valibot / TypeBox, converted for the document | JSON Schema Draft 2020-12 — or author in Zod/TypeBox/Valibot/Effect and convert once, at build time |
| OpenAPI | a conversion layer between what runs and what is published | OpenAPI 3.2's schema dialect is Draft 2020-12, so contract schemas embed verbatim — no conversion to drift |
| Responses | inferred from whatever the handler returned, and usually unvalidated | declared: an undeclared status is a compile error, and validateResponses catches shape drift in dev/test |
| Typed client | hc (coupled to the framework) or codegen from the document | createClient from the same literals — no codegen, no round-trip, browser-safe subpath |
| Authorization | middleware, invisible to the document | guards can only deny with a status the contract declares, so the 401 is in the OpenAPI output and in the client's union |
| Production build | none | compileToModule emits a fused handler — inlined guards, schema-derived serializers, a precomputed document |
The through-line: elsewhere a route is a chain of functions and the document is derived by a second mechanism; here the contract is data, and the handler types, the runtime validation, the OpenAPI document, the typed client, and the compiled module are all projections of it. There is one place to edit and nothing to keep in sync.
On speed, the benchmark tables measure the
same three routes through the same web-standard Request objects on workerd,
Node, and Bun. Against hono + zod — the other column that actually validates —
the compiled engine leads every case on all three runtimes. Against unvalidated
bare Hono it leads or matches the GET cases and trails on the POST case, which is
dominated by body parsing that every column pays.
What this deliberately does not do
A framework is still a framework. This package has no:
- Middleware onion. Pre-routing gates (
onRequest), response decorators (onResponse), and per-routeguardscover the same ground with a flatter model — and on the Node adapter there are no hooks at all, by design: you use the host framework's chain. - WebSockets. Server-sent events are first class (
sseStream,formatSse, and streaming responses); socket upgrades are the host's job. - Static file serving, JSX/SSR, or template rendering. Nothing here renders
HTML except the Scalar docs page
createDocsserves. - A plugin ecosystem. CORS, CSRF, rate limiting, security headers, ETag, compression, request IDs, and health checks ship as hook factories; beyond those you write it or take it from the framework you mounted into.
- A router for anything but contracts. A path with no contract is a 404, or falls through to the host — this serves your API surface, not your whole app.
Which is the point: run Hono, Express, or Fastify for the app, and let contracts own the API surface. The recipes mount into either side of an existing app, so adoption is per route, not per repository.
Usage
import { createApi, defineRoute, toFetchHandler } from '@amritk/api'
const getUser = defineRoute({
method: 'get',
path: '/users/{id}',
summary: 'Fetch a user',
request: {
params: { type: 'object', properties: { id: { type: 'integer' } }, required: ['id'] },
query: { type: 'object', properties: { verbose: { type: 'boolean' } } },
},
responses: {
200: { body: { type: 'object', properties: { id: { type: 'integer' }, name: { type: 'string' } }, required: ['id', 'name'] } },
404: {},
},
handler: ({ params, query }) => {
// params.id is a number, query.verbose is boolean | undefined — already
// validated, already coerced from their string transport form.
return params.id === 1 ? { status: 200, body: { id: 1, name: 'Ada' } } : { status: 404 }
},
})
const api = createApi({
routes: [getUser],
info: { title: 'Users API', version: '1.0.0' },
})api.handle is the whole runtime; GET /openapi.json serves the generated
document (configurable via openApiPath) — serialized once per process and
sent with a strong etag + cache-control: no-cache, answering 304 to a
matching if-none-match. Note: for the types to flow, write schemas inline
(as above) or declare shared ones as const — a plain const widens the
literal before defineRoute sees it.
Contracts without handlers (browser-safe)
defineRoute couples the contract to its handler, which is perfect for a
server-only codebase — but a frontend that wants the contract types must not
bundle server code. defineContract declares the same contract as pure
data, implementRoute binds the handler server-side, and the one-shot
defineRoute keeps working unchanged (every route is a contract):
// contracts.ts — imported by server AND browser
import { defineContract } from '@amritk/api/client'
// One object is the single source of truth: the client covers exactly these
// keys, and adding an endpoint here wires it into client and server at once.
export const contracts = {
getUser: defineContract({
method: 'get',
path: '/users/{id}',
request: { params: { type: 'object', properties: { id: { type: 'integer' } }, required: ['id'] } },
responses: {
200: { body: { type: 'object', properties: { id: { type: 'integer' }, name: { type: 'string' } }, required: ['id', 'name'] } },
404: {},
},
}),
getProfile: defineContract({
method: 'get',
path: '/users/{id}/profile',
request: { params: { type: 'object', properties: { id: { type: 'integer' } }, required: ['id'] } },
responses: { 200: {} },
}),
}// routes.ts — server only
import { implementRoute, routeImplementer } from '@amritk/api'
import { contracts } from './contracts'
export const getUser = implementRoute(contracts.getUser, ({ params }) =>
params.id === 1 ? { status: 200, body: { id: 1, name: 'Ada' } } : { status: 404 },
)
// With an app context, bind the implementer once (the routeFactory counterpart):
const implementAppRoute = routeImplementer<AppContext>()
export const getProfile = implementAppRoute(contracts.getProfile, ({ context }) => /* ... */)Typed client: createClient
createClient derives a typed fetch client from a record of contracts — no
codegen, no OpenAPI round-trip, works in any browser/worker/Node bundle. The
same literals that type the handlers type each call, so client and server
cannot drift. This is the framework-agnostic replacement for Hono's hc:
// client.ts — browser bundle; pulls in zero server code
import { buildParamPath, createClient, isUnexpectedStatusError, toSearchParams } from '@amritk/api/client'
import { contracts } from './contracts'
const client = createClient(contracts, 'https://api.example.com', {
headers: () => ({ authorization: `Bearer ${readToken()}` }), // static record or (async) function
fetch: myFetch, // injectable for tests; defaults to global fetch
pathParams: buildParamPath, // opt-in: only needed for {param} paths
queryParams: toSearchParams, // opt-in: only needed for calls that send query
fetchOptions: { credentials: 'include' }, // RequestInit extras (credentials, cache, redirect, …)
timeoutMs: 10_000, // default per-call timeout; composes with a per-call signal
})
const reply = await client.getUser({ params: { id: 7 }, signal: AbortSignal.timeout(5000) })
if (reply.status === 200) reply.body.name // typed from the schema — narrowing on status
if (reply.status === 404) /* declared, typed, no body */;@amritk/api/clientis the browser-safe entry: everything above —createClient,defineContract, the opt-in serializers, the error predicates, the…Oftype helpers, and the client-side auth helpers (createCsrfHeader,createTokenRefresh,createRefreshFetch) — with an import graph that never touches a server module or anode:built-in, guaranteed by a test. Importing from the root@amritk/apiworks too (sideEffects: falsetree-shakes the server half out of the final bundle), but the root barrel makes bundlers resolve the server adapters and printnode:http/node:streamexternalization warnings along the way; the subpath never triggers them.Replies are a discriminated union on
status, derived from theresponsesmap. JSON statuses carry a typedbody(parsed eagerly); statuses declared with a rawcontentTypecarry only the untouchedResponse— read the stream and headers yourself (the AI-chat shape):const chat = await client.chat({ body: { message: 'hi' }, headers: { 'x-api-key': key } }) if (chat.status === 200) for await (const chunk of chat.response.body) render(chunk)Inputs are typed per slot: declared
params/query/body/cookiesare required and schema-typed,headersaccepts the declared shape plus ad-hoc extras, and a per-callsignalcancels. Contracts with no request slots call with no argument at all (client.health()). Every call also acceptsfetchOptions(per-callRequestInitextras, merged over the client-level ones) andtimeoutMs(overriding the client default; a timeout and a callersignalcompose viaAbortSignal.any). Requests sendaccept: application/jsonunless a header overrides it.Cookies and browsers: the
cookiesslot serializes into thecookierequest header, which browsers forbid scripts from setting — it works from Node/undici/workers only, and is opt-in for exactly that reason: registercookies: appendCookiesto use it; a browser bundle omits it and never carries the code. Browser cookie auth uses server-set cookies plusfetchOptions: { credentials: 'include' }.A declared status whose body fails to parse (a proxy truncation, a gateway HTML page under a JSON status) throws a recognizable error —
isMalformedBodyError(error)— carrying the consumedResponseand the parse error ascause, instead of a bareSyntaxError.Everything beyond plain JSON calls is an opt-in import: JSON bodies and the raw
text/bytesbodies (sent verbatim) are built in; the rest is registered explicitly so a JSON-only, static-path app bundles none of it. Contracts withbodyType: 'form'/'multipart'(urlencoded pairs /FormDatawithFilevalues intact) need their serializer,{param}path templates needpathParams: buildParamPath(segment-encoded; greedy{path+}keeps its slashes), query strings needqueryParams: toSearchParams(array values repeat the key,undefinedskipped), and the Node-onlycookiesslot needscookies: appendCookies:import { appendCookies, buildParamPath, createClient, formBodySerializer, multipartBodySerializer, toSearchParams, } from '@amritk/api/client' const client = createClient(contracts, url, { serializers: [formBodySerializer, multipartBodySerializer], // only what you send pathParams: buildParamPath, // only if any path has {params} queryParams: toSearchParams, // only if any call sends query cookies: appendCookies, // only from Node/undici/workers })A call that needs an unregistered piece throws with the fix in the message; JSON-only apps with static paths pass nothing and bundle none of it. A custom
BodySerializer(anybodyType, including'json'to override the built-in encoder) is a{ bodyType, serialize, contentType? }object.Undeclared statuses throw (instead of poisoning the union): catch and inspect with
isUnexpectedStatusError(error)— the unreadResponserides on the error. Declare the statuses you want to handle in the contract.Name wire types from the contracts — the
…Ofhelpers extract every schema-typed shape an app would otherwise re-declare by hand or generate:ResponseBodyOf(one status's body),SuccessBodyOf/ErrorBodyOf(the generated-SDK-style data and error unions, split 2xx vs 4xx/5xx),ResponseStatusOf(the declared statuses, for exhaustive switches),RequestParamsOf/RequestQueryOf/RequestBodyOf/RequestHeadersOf/RequestCookiesOf(the request slots,undefinedwhen undeclared), andClientReplyOf/RouteReplyOf(the client and handler reply unions). Error payloads become named exports instead of inlineas { ... }casts at every use site:import type { ErrorBodyOf, RequestBodyOf, ResponseBodyOf } from '@amritk/api/client' // The 402 body, exactly as the contract declares it — no codegen. export type DemoLimitBody = ResponseBodyOf<typeof contracts.demoChat, 402> // Every declared error payload of the operation, as one union. export type DemoChatError = ErrorBodyOf<typeof contracts.demoChat> // What a form model holds before calling the client. export type DemoChatInput = RequestBodyOf<typeof contracts.demoChat>
For consumers outside the monorepo, the generated OpenAPI document feeds
whichever SDK generator they already use; createClient is the lighter path
for monorepo-internal frontends, and needs no codegen at all.
Browser bundle size: the contract strip
At runtime the client reads only a sliver of each contract — method,
path, request.bodyType, whether a body schema exists, and each response
status's contentType marker. The request/response schemas, refine,
summary/description, and tags are server and OpenAPI freight, and they
scale with route count. @amritk/api/bundler exports the transform that
removes them from defineContract call sites in browser builds — types are
compile-time, so nothing changes for the consumer, and dropped schema
references become tree-shakeable:
stripContractFields(source)— source in, source out, unchanged when there was nothing to rewrite.isScannableId(id)— the module-id filter to put in front of it (TS/JS extensions, tolerating Vite's?querysuffixes).
Deliberately not a plugin per bundler: every bundler exposes a per-module
text hook, the wiring against yours is a few lines, and those lines are
yours to place — which build, which modules, which exclude. The subpath
imports nothing, node:* included, so a config file in any runtime can load
it.
// vite.config.ts — Rollup is the same, minus enforce/apply/ssr
import { isScannableId, stripContractFields } from '@amritk/api/bundler'
const stripContracts = {
name: 'strip-contracts',
enforce: 'pre', // see original sources, ahead of other transforms
apply: 'build', // dev-server modules stay untouched, for debuggability
transform(code: string, id: string, options?: { ssr?: boolean }) {
// SSR modules keep their freight — the server genuinely reads the schemas.
if (options?.ssr === true || !isScannableId(id) || !code.includes('defineContract')) return null
const stripped = stripContractFields(code)
return stripped === code ? null : { code: stripped, map: null }
},
}
export default defineConfig({ plugins: [stripContracts] })// build.ts — Bun.build (esbuild is the same shape; read the file with
// node:fs/promises' readFile). Add it to the browser build only.
import { stripContractFields } from '@amritk/api/bundler'
const stripContracts = {
name: 'strip-contracts',
setup(build: Bun.PluginBuilder) {
build.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, async ({ path }) => {
const source = await Bun.file(path).text()
if (!source.includes('defineContract')) return undefined
const stripped = stripContractFields(source)
return stripped === source ? undefined : { contents: stripped, loader: path.endsWith('x') ? 'tsx' : 'ts' }
})
},
}
await Bun.build({ entrypoints: ['./src/client.ts'], target: 'browser', plugins: [stripContracts] })// strip-contracts-loader.mjs — rspack and webpack; the package is ESM-only,
// so the loader is too (both support ESM loaders).
import { stripContractFields } from '@amritk/api/bundler'
export default function stripContractsLoader(source) {
return source.includes('defineContract') ? stripContractFields(source) : source
}
// rspack.config.mjs — scope it to the contracts you want slimmed
// module: { rules: [{ test: /\.[cm]?[jt]sx?$/, include: /contracts/, use: ['./strip-contracts-loader.mjs'] }] }The strip is line-preserving — removed spans keep their newlines — so downstream sourcemaps stay line-accurate, and returning the source unchanged lets the bundler keep the original code and map.
Or strip once, at publish time
When contracts live in their own package that both the server and the
frontend import, the strip can run in that package's build instead of in
every app downstream — no bundler wiring at all for consumers, whatever they
build with. Emit two artifacts from one source, and split them with
exports:
// scripts/build-client.ts — run after tsc has written dist/
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { transformSync } from 'esbuild'
import { stripContractFields } from '@amritk/api/bundler'
for (const file of await readdir('dist', { recursive: true })) {
if (!file.endsWith('.js')) continue
const source = stripContractFields(await readFile(join('dist', file), 'utf8'))
// Reprint to drop the JSDoc tsc copied into the JS — see below.
const { code } = transformSync(source, { loader: 'js', format: 'esm' })
const out = join('dist-client', file)
await mkdir(dirname(out), { recursive: true })
await writeFile(out, code)
}// package.json — the server gets the schemas, the browser gets the slim copy
"exports": {
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"./client": { "types": "./dist/index.d.ts", "default": "./dist-client/index.js" }
}Both entries point at the same .d.ts, which is the part worth
understanding. Declarations are generated from the original source, so they
carry the full types and the JSDoc you wrote above each contract — hover,
autocomplete, and ResponseBodyOf<…> are identical on both entries, and only
the shipped values differ. Editors and tsc never see the strip. That is the
same trade the bundler hook makes; it just happens once, in the package that
owns the contracts.
Running over emitted JS rather than TypeScript sources is the more reliable
order, too: defineContract survives compilation intact (it is an identity
function, and tsc keeps the call), while the as const and satisfies
suffixes that make the scanner bail on a source file are already gone by
then.
That is also why the script reprints through esbuild. The strip rewrites
contract literals and never touches comments, and tsc copies every JSDoc
block into the .js it emits — so without that step the doc comment above
each contract ships to the browser, where it is dead weight the docs in the
.d.ts already cover. A consumer's production minifier would drop it, but
there is no reason to put it in the package. Do not reach for tsc's
removeComments instead: it strips JSDoc from the declaration files too,
which is precisely the hover help this layout exists to keep. Comments out of
the values, comments kept in the types.
The transform is deliberately conservative: call sites it cannot parse with
certainty (spreads, computed keys, explicit type arguments, aliased imports
of defineContract) are left byte-for-byte untouched, and unknown contract
fields are kept — the failure mode is a bigger bundle, never a broken one.
Per-operation security is not stripped. createClient does not read it
either, but an app plausibly does — attach a bearer token only where a scheme
is declared, skip a call that will certainly 401, hide a control for a scope
the session lacks — and a requirement is tens of bytes against the hundreds a
request schema costs. Everything else on the list is inert in a browser.
Three caveats. First, the strip assumes the browser only calls contracts
through createClient. If your app itself reads contract schemas at runtime
— client-side form validation against contract.request.body, in-browser
OpenAPI rendering — those modules must keep their freight: filter them out in
the hook, or leave the strip off. Second, only direct
defineContract({ ... }) identifier calls are rewritten; a renamed import
or a wrapper function keeps its call sites intact (and its bytes). Third,
this is a size optimization and nothing more — it is not the way to keep a
node:* built-in out of a browser bundle, because bundlers resolve modules
before they eliminate them. Import contracts from @amritk/api/client
instead; that graph is guaranteed node-free.
Measured on a realistic widget consumer — three JSON-only contracts with
static paths, bundled with Bun.build (target: 'browser', minified;
enforced by src/bundler/strip-contract-fields.bundle.test.ts, which bundles
through the Bun.build wiring above):
| Bundle | minified | gzip | | ------------------------------------- | -------- | ------- | | 0.3.0 client (everything built in) | 3.6 kB | 1.7 kB | | 0.4.0 client, no strip | 3.7 kB | 1.7 kB | | 0.4.0 client + strip | 2.7 kB | 1.4 kB | | contract data alone, before → after | 1.3 kB → 0.31 kB | 0.57 kB → 0.19 kB |
The contract-data row is the one that scales: the strip removes ~75% of
every contract's bytes (~0.3 kB minified per route in this fixture), so the
gap widens with route count. The client core itself is a fixed cost, and the
opt-in serializer/path split keeps it flat: form, multipart, and {param}
handling are no longer bundled unless the app registers them.
Serving it
createApi returns an Api, not a server: handle(ApiRequest) → ApiResponse
is the entire runtime. Two adapters bridge it onto the only two HTTP ABIs
JavaScript has, and every framework below is one of the two — there is no
per-framework plugin to install, and no framework-specific code in the package.
| Adapter | Signature | Frameworks |
|:---|:---|:---|
| toFetchHandler(api, options?) | (Request, env?, executionContext?) => Promise<Response> | Bun · Cloudflare Workers · Deno · Hono · Next.js · SvelteKit · Nitro / Nuxt · Elysia |
| toNodeHandler(api, options?) | (IncomingMessage, ServerResponse, next?) => Promise<void> | node:http · Express · Fastify · Koa · NestJS |
Three things every recipe shares:
mounts,onRequest/onResponse, and the built-in security hooks are fetch-adapter features.toNodeHandlerdeliberately omits them: every Node framework below already has a middleware chain for CORS, rate limits, and security headers, and that chain runs before the handler.- The second argument the host passes becomes
envincreateApi({ context })— Workers bindings on Workers, but theServeron Bun and the route context under Next.js. When the context factory needs your own config, pass it explicitly:(request) => handler(request, config). - Contract paths are the full request path. There is no base-path option
on
createApi, so a route declaring/users/{id}matches exactly that. Under a host that serves the handler from/api/…, either declare/api/users/{id}in the contract or mount somewhere that strips the prefix first (Express'sapp.use('/api', …)does).
Bun
import { createApi, toFetchHandler } from '@amritk/api'
import { getUser } from './routes'
const api = createApi({ routes: [getUser] })
Bun.serve({ port: 3000, fetch: toFetchHandler(api) })Cloudflare Workers
const handler = toFetchHandler(api)
export default { fetch: handler } satisfies ExportedHandler<Env>Bindings arrive as env, and the ExecutionContext — waitUntil,
passThroughOnException — as executionContext; both reach the
createApi({ context }) factory untouched. For production Workers, prefer
the compiled engine — same contracts, a
fused handler with inlined guards.
Deno
Deno.serve(toFetchHandler(api))Hono
const app = new Hono()
app.get('/health', (c) => c.text('ok'))
app.mount('/', toFetchHandler(api)) // register last: '/' matches everything
export default appHono forwards its own env and executionCtx to the mounted handler, so
Workers bindings still reach createApi({ context }). Routes registered
before the mount keep winning — that is how a Hono app adopts contracts one
slice at a time.
Next.js (App Router)
// app/api/[[...path]]/route.ts
import { createApi, toFetchHandler } from '@amritk/api'
import { routes } from '@/server/routes'
const handler = toFetchHandler(createApi({ routes }))
// Next calls route handlers as (request, { params }); passing `env` explicitly
// keeps Next's route context out of the context factory.
const route = (request: Request): Promise<Response> => handler(request, process.env)
export { route as GET, route as POST, route as PUT, route as PATCH, route as DELETE }The file's own path is part of the URL, so contracts declare /api/.... Use
@amritk/api/bundler to strip contract schemas from anything the client
bundle imports.
SvelteKit
// src/hooks.server.ts
import { createApi, toFetchHandler } from '@amritk/api'
import type { Handle } from '@sveltejs/kit'
import { routes } from '$lib/server/routes'
const handler = toFetchHandler(createApi({ routes }))
export const handle: Handle = ({ event, resolve }) =>
event.url.pathname.startsWith('/api/') ? handler(event.request, event.platform) : resolve(event)A src/routes/api/[...path]/+server.ts file works too — export
({ request, platform }) => handler(request, platform) as GET/POST/… —
but the hook keeps every contract path in one place.
Nitro / Nuxt
// server/routes/api/[...].ts (Nuxt) — routes/api/[...].ts (standalone Nitro)
import { fromWebHandler } from 'h3'
export default fromWebHandler(toFetchHandler(api))fromWebHandler exists in both h3 v1 (Nitro 2 / Nuxt 3) and h3 v2 (Nitro 3 /
Nuxt 4). Use server/routes/api/… rather than server/api/…: the latter
prefixes /api itself, which would double the prefix your contracts declare.
Elysia
const app = new Elysia()
.get('/health', () => 'ok')
.mount(toFetchHandler(api)) // WinterCG mount — raw Request in, Response out
.listen(3000)node:http
import { createServer } from 'node:http'
import { toNodeHandler } from '@amritk/api'
createServer(toNodeHandler(api)).listen(3000)With no next callback the adapter is terminal: unmatched paths get the
pipeline's own 404. Wrap the returned listener to add cross-cutting behavior
(the fetch adapter's hooks have no counterpart here).
Express
const app = express()
app.use(toNodeHandler(api)) // unmatched paths fall through to the rest of the app
app.get('/legacy/report', legacyReport)
app.listen(3000)Called as middleware, the adapter checks api.matches first and calls next()
when nothing matches, so mounting it early costs unmatched routes one map
lookup. You do not need express.json() — the pipeline parses and
validates declared bodies itself — but an app-wide parser is safe: the adapter
detects the already-drained stream and reads what the parser left on
req.body instead of hanging.
Mounting under a prefix works too — app.use('/api', toNodeHandler(api)) —
because Express strips the mount path from req.url before the handler sees
it, so contracts stay written as /users/{id}.
Express 5 changed wildcard syntax: a catch-all is '/api/auth/*splat', not
'/api/auth/*', which now throws at registration.
Fastify
Fastify routes before it runs hooks, so the adapter attaches as a global
onRequest hook — the last point where the body stream is still untouched by
Fastify's content-type parser. reply.hijack() hands the socket over so
Fastify will not also try to answer:
const nodeHandler = toNodeHandler(api)
app.addHook('onRequest', async (request, reply) => {
const path = request.url.split('?')[0] ?? '/'
// Not ours — returning lets Fastify's router, hooks, and 404 handler take over.
if (!api.matches(request.method, path)) return
reply.hijack()
void nodeHandler(request.raw, reply.raw)
})
app.get('/health', async () => ({ ok: true }))Global onRequest hooks run even when Fastify's own router has no match, which
is what lets contracts serve paths Fastify never heard of. void is safe here:
the adapter never rejects — it answers a 500 while the status line is unsent,
and destroys the socket once bytes are on the wire. Requests handled this way
bypass Fastify's router, per-route hooks, and serializer by design; its
onRequest hooks registered before this one still run, which is where
Fastify-side CORS and rate limits belong.
Koa
Koa has no router of its own, so the adapter is just middleware — but
ctx.respond = false is required, or Koa overwrites the reply after the
adapter has already written it:
const nodeHandler = toNodeHandler(api)
app.use(async (ctx, next) => {
if (!api.matches(ctx.method, ctx.path)) {
await next()
return
}
ctx.respond = false
await nodeHandler(ctx.req, ctx.res)
})NestJS
On the default Express platform the adapter is ordinary middleware:
// main.ts
const app = await NestFactory.create(AppModule)
app.use(toNodeHandler(api))
await app.listen(3000)On FastifyAdapter, use the Fastify recipe against
app.getHttpAdapter().getInstance().
Anything else
Writing an adapter is ~15 lines: construct one
ApiRequest per incoming request and serialize the
ApiResponse that api.handle resolves with. If the host already speaks
Request/Response, toFetchHandler is that adapter;
fetchToNodeHandler goes the other way,
running a fetch handler (including a compiled module's fetch export) on
node:http.
Options (createApi)
| Option | Default | Description |
|:---|:---|:---|
| routes | — | The route contracts (from defineRoute). Duplicate method + path shapes throw at startup. |
| info | placeholder | OpenAPI info block (title, version, description). |
| openApiPath | /openapi.json | Where the document is served. false disables serving. |
| compile | runtime-validators | Swap the validation engine — see below. |
| formats | — | String formats to assert: 'all', or a list like ['uuid', 'email']. Off by default — see String formats. |
| context | — | Per-request app context factory (database handles, sessions). See App context. |
| validateResponses | false | Validate reply bodies (and declared reply headers) against the response contracts; mismatches become a 500. A development/test net. |
| onError | bare 500 | Map a thrown handler error to a response. Receives (error, request, { route, env, executionContext }) — everything error reporting needs. The default never leaks the error message. |
| errors | built-in bodies | Reshape the pipeline's own cold-path responses (notFound, invalidJson, invalidBody, unsupportedMediaType, payloadTooLarge, validationFailed, methodNotAllowed) to match an existing wire format. |
| observe | — | Called once per matched request with { route, request, status, durationMs, env, executionContext } — the seam for per-route latency metrics and structured request logs. See Observability. |
| observeUnmatched | — | The unmatched-request counterpart: called once per 404/405 with route: undefined, for request-logging parity with framework middleware. |
| servers / securitySchemes / security / tags | — | Document-level OpenAPI settings: base URLs, named auth schemes (components.securitySchemes), the default security requirement, and tag objects (name/description/externalDocs). Routes add security / deprecated per operation. |
Validation semantics
- Path and query parameters arrive as strings, so declared
number/integer/boolean/arrayproperties are coerced first (from a plan computed at startup — no per-request schema inspection). A value that does not parse stays a string and fails validation with a proper type error. - Repeated query keys (
?tag=a&tag=b) accumulate into arrays when the schema declares an array; undeclared keys pass through as strings soadditionalPropertiesrules still apply. - Declaring
request.bodymakes a body required. The default encoding is JSON;bodyType: 'form','multipart','text', and'bytes'switch it (see below). A JSON body that fails to parse is a400 { error: 'invalid_json' }; a form/multipart body that fails to parse is a400 { error: 'invalid_body' }. - A request whose
content-typecontradicts the declared body type answers415 { error: 'unsupported_media_type' }before any read. A request with no content-type gets the benefit of the doubt and fails on the parse instead, so barecurland hand-rolled clients keep working. JSON acceptsapplication/jsonand+jsonstructured suffixes. request.headerstakes an object schema whose property names are header names (lookup is case-insensitive; write them lowercase). Only declared headers are read, values coerce like query parameters, and each property becomes anin: 'header'OpenAPI parameter — sox-api-key-style auth requirements document themselves.request.cookiesworks the same way for thecookieheader: only declared names are read (tracking cookies never reach validation), values are unquoted and percent-decoded per the usual middleware conventions, and each property becomes anin: 'cookie'OpenAPI parameter.HEADis served automatically whereverGETis (RFC 9110): the GET pipeline runs — validation, handler, response headers and all — and the adapter discards the body (cancelling streams rather than leaking them). Declaring an explicitheadroute overrides the fallback for its path.- A known path requested with the wrong method answers
405 { error: 'method_not_allowed' }with a sortedallowheader (advertisingHEADwheneverGETis served, andOPTIONSalways); unknown paths stay 404. OPTIONSon a known path answers204with the sameallowheader automatically; declaring an explicitoptionsroute overrides it. CORS preflights are answered earlier by thecreateCorsgate when configured.- Validation failures answer
400with{ error: 'validation_failed', source, errors }whereerrorscarries the same{ message, path }shape as@amritk/runtime-validatorsandsourceisparams,query,headers, orbody. Theerrorsoption reshapes this (and the other built-in bodies) when deployed clients already parse a different envelope.
String formats
format is an annotation in JSON Schema, and both Ajv and
@amritk/runtime-validators make asserting it opt-in. The api follows suit: by
default a param declared { type: 'string', format: 'uuid' } documents itself as
a UUID in the OpenAPI output and accepts any string at runtime.
Pass formats to assert them:
// Every built-in format: uuid, email, date-time, date, time, duration, uri,
// uri-reference, uri-template, hostname, idn-hostname, ipv4, ipv6,
// json-pointer, relative-json-pointer, regex, and the idn-/iri- variants.
const api = createApi({ routes, formats: 'all' })
// Or only the ones you rely on, leaving the rest as documentation.
const api = createApi({ routes, formats: ['uuid', 'email'] })A violation is an ordinary 400 { error: 'validation_failed' } alongside every
other constraint. Format checks are pragmatic regexes rather than RFC-perfect
parsers — they reject obviously-bad input; treat them as a first gate, not as
proof a value is routable or deliverable.
Pass the same value to compileToModule({ formats }) so the compiled module and
the development server agree — a schema carrying format then leaves the
inlinable subset and is checked by the interpreter, which owns the regexes.
formats is ignored when you supply your own compile, since that replaces the
engine it configures.
Branded IDs (nominal types for params)
Path/query params arrive as plain string / number, so nothing stops you from
passing a userId where an orderId is expected. Add an x-mjst brand to
the param schema and mjst intersects a unique nominal marker onto the inferred
type — the runtime still validates the plain base type, but the handler (and the
derived typed client) see a distinct branded id, the same protection Drizzle's
.$type<UserId>() gives a column:
const getUser = defineRoute({
method: 'get',
path: '/users/{id}',
request: {
params: {
type: 'object',
properties: { id: { type: 'string', format: 'uuid', 'x-mjst': { brand: 'UserId' } } },
required: ['id'],
},
},
responses: { 200: { body: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } } },
handler: ({ params }) => {
params.id // (string & { readonly __brand: 'UserId' }) — not a plain string
return { status: 200, body: { id: params.id } }
},
})params.id is now a UserId, so getOrder(params.id) is a compile error unless
getOrder takes a UserId. The brand is type-level only — it adds no runtime
check beyond the base type, and the format: 'uuid' next to it is an annotation
until you opt in with formats. Keep the schema
literal (inline or as const) so the brand survives inference, and use the same
brand shape ({ readonly __brand: 'UserId' }) for your app-side id type — define
it to match, rather than expecting mjst to reuse Drizzle's own brand symbol. See
the x-mjst extension for the full
reference.
Cross-field refinement
Per-slot JSON Schema cannot see across fields. A route (or contract) may
declare refine, which runs (sync or async — a returned promise is awaited)
after every declared slot has validated — so its inputs are already typed
and coerced — and before the context factory and handler. Returned issues reject the request through the
standard validation_failed envelope (and the validationFailed formatter),
with your own path/message; undefined or [] accepts it. A thrown
refine takes the onError path like any handler error:
const chat = defineRoute({
method: 'post',
path: '/chat',
request: { body: chatBodySchema },
refine: ({ body }) => {
const total = body.messages.reduce((n, m) => n + m.content.length, 0)
return total <= 64_000
? undefined
: [{ path: '/messages', message: `total message length ${total} exceeds 64k` }]
},
responses: { 200: { contentType: 'text/event-stream' } },
handler: /* ... */,
})Streaming responses: documenting each item
A raw contentType says what the stream is; OpenAPI 3.2's itemSchema says
what each item in it looks like. Declare it on the response contract beside
contentType and it lands in the document next to schema:
import { sseItemSchema, sseStream } from '@amritk/api'
const tokens = defineRoute({
method: 'get',
path: '/chat/{id}/stream',
request: { params: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
responses: {
200: {
contentType: 'text/event-stream',
// One SSE event, not the whole stream.
itemSchema: sseItemSchema({ type: 'string' }, { event: 'token' }),
},
},
handler: ({ request }) => ({ status: 200, body: sseStream(stream(), { signal: request.signal }) }),
})The sequential media types OpenAPI recognizes are text/event-stream,
application/jsonl, application/json-seq, and multipart/mixed. For the
JSON-lines family the item is your record, so pass the schema directly
(itemSchema: recordSchema). For SSE the item is the event envelope —
{ event, id, data, retry } — with your payload inside data, which is what
sseItemSchema builds so you do not hand-write the wrapper at every route.
itemSchema is documentation only, exactly like a body schema on a raw
status: adapters pass the stream through untouched, so nothing here is
validated at runtime. It does take part in components.schemas hoisting, so a
titled event schema shared across routes appears once and is $referenced.
Form and multipart bodies
bodyType selects how the declared body schema arrives on the wire — the
parser, the 415 check, and the OpenAPI requestBody content key all follow it:
const signup = defineRoute({
method: 'post',
path: '/signup',
request: {
// application/x-www-form-urlencoded: fields coerce like query parameters
// (typed keys coerce from strings, array keys accumulate repeats).
body: {
type: 'object',
properties: { name: { type: 'string', minLength: 1 }, age: { type: 'integer', minimum: 18 } },
required: ['name', 'age'],
},
bodyType: 'form',
},
responses: { 201: {} },
handler: ({ body }) => /* body.age is a number */ ({ status: 201 }),
})
const upload = defineRoute({
method: 'post',
path: '/upload',
request: {
// multipart/form-data: string parts coerce like form fields, file parts
// reach the handler as File objects. Declare file properties WITHOUT a
// `type` keyword ({} or { contentMediaType: 'image/png' }) — a File is
// not a string, so `type: 'string'` would reject it.
body: {
type: 'object',
properties: { title: { type: 'string' }, attachment: {} },
required: ['title', 'attachment'],
},
bodyType: 'multipart',
},
responses: { 200: {} },
handler: async ({ body }) => {
const file = body.attachment as File
await save(file.name, new Uint8Array(await file.arrayBuffer()))
return { status: 200 }
},
})Multipart parsing is delegated to the platform's Response#formData (undici
on Node, native on Workers/Bun/Deno) over the same shared buffered read as
everything else — maxBodyBytes still caps uploads. Repeated file keys keep
the last file; repeated string keys accumulate when the schema declares an
array.
Raw text and binary bodies
bodyType: 'text' and 'bytes' skip parsing entirely: the body is validated
verbatim against the schema and handed to the handler as a string (decoded)
or a Uint8Array — a text/csv upload or a binary blob that still rides the
typed contract and the typed client, no hand-rolled fetch required. The 415
check is lenient (any text/* for text, any media type for bytes), so the
schema is the real gate.
const importCsv = defineContract({
method: 'post',
path: '/import',
// { type: 'string' } for text; {} accepts any bytes.
request: { body: { type: 'string', minLength: 1 }, bodyType: 'text' },
responses: { 200: { body: { type: 'object', properties: { rows: { type: 'integer' } }, required: ['rows'] } } },
})
// server: the handler receives the raw string
implementRoute(importCsv, ({ body }) => ({ status: 200, body: { rows: body.split('\n').length } }))
// client: the body goes on the wire unchanged. text/bytes are built in — no
// serializer to register. A default content type is stamped only when nothing
// else set one, so override it per call for a specific media type.
await client.importCsv({ body: csvText, headers: { 'content-type': 'text/csv' } })Sending these formats from the derived client is opt-in: register
formBodySerializer / multipartBodySerializer in createClient (see the
typed-client section above) so JSON-only apps never bundle them.
Streaming and raw responses
Declare a status with contentType and its body becomes a raw payload — a
ReadableStream<Uint8Array>, Uint8Array, or string that every adapter
sends untouched. This is the AI-token-stream / SSE / CSV-download shape; the
request side stays validated and documented, only the reply is raw:
const chat = defineRoute({
method: 'post',
path: '/chat',
request: { body: chatBodySchema },
responses: { 200: { contentType: 'text/plain; charset=utf-8' } },
handler: ({ body, request }) => ({
status: 200,
// request.signal aborts when the client disconnects — stop generating.
body: streamTokens(body.messages, request.signal),
}),
})Both adapters apply backpressure: the fetch adapter hands the stream to the
platform Response, and the Node adapter awaits drain whenever a write
overruns the socket buffer, so a fast producer never buffers unbounded
memory against a slow client.
Returning a raw Response (escape hatch)
A contentType status keeps the reply typed and documented while letting the
body be raw. When you instead need full control of the entire response —
status, headers, and body all outside the contract — a handler may return
raw(response). The adapters send the wrapped response verbatim (the fetch
adapter returns it as-is, the Node adapter streams it out), still running the
onResponse decorators, and strip its body for HEAD like any other reply:
import { raw } from '@amritk/api'
const proxy = defineRoute({
method: 'get',
path: '/legacy',
responses: { 200: { body: legacySchema } },
// Reuse an existing Response-building helper (or an upstream fetch) unchanged.
handler: async ({ request }) => raw(await fetch(new URL(request.raw as Request), { redirect: 'manual' })),
})This is a deliberate escape hatch: a raw reply skips response validation
entirely (there is no framework-level body to check), so the status it carries
need not appear in responses. Reach for it when porting handlers that already
build Response objects, or when proxying an upstream response; prefer a typed
{ status, body } reply — or a contentType status for raw bodies —
everywhere else, so the contract stays the source of truth.
The raw() wrapper ({ raw: Response }) is not just ergonomics. A bare
Response in the handler's return union carries status: number, which matches
every declared status and so forces TypeScript to check the reply against
Response too — making an ordinary reply whose own status is a union of
declared statuses fail to compile, with a misleading complaint about the status:
// `embed.status` is `502 | 503`, and the contract declares both.
if (!embed.ok) return { status: embed.status, body: { error: embed.error } }raw carries no status, so replies like that infer normally. Returning a bare
Response is no longer accepted as of 0.10.0 — wrap it in raw().
Raw request bodies and size limits
The pipeline only consumes the body stream when a body schema is declared,
and all reads share one buffered copy — so request.readText() /
readBytes() can be called repeatedly, in any combination, and even
alongside a declared body schema (parsed access and the exact signed bytes
in the same handler). A route that only needs the raw bytes — webhook
signature verification, uploads — simply declares no body schema:
const stripeWebhook = defineRoute({
method: 'post',
path: '/billing/webhook',
request: {
headers: { type: 'object', properties: { 'stripe-signature': { type: 'string' } }, required: ['stripe-signature'] },
},
responses: { 200: {}, 400: {} },
handler: async ({ headers, request }) => {
const payload = await request.readText() // exact signed bytes, never re-serialized
const event = await stripe.webhooks.constructEventAsync(payload, headers['stripe-signature'], secret)
// ...
return { status: 200 }
},
})toFetchHandler(api, { maxBodyBytes: 1_000_000 }) (also on toNodeHandler
and compileToModule) rejects larger bodies with a 413 — checked against
content-length up front, enforced on the running byte count as the body
streams in, for pipeline and handler-initiated reads alike. The default is
1 MiB — unbounded reads are opt-in via maxBodyBytes: Infinity, so an
unconfigured deployment is not a memory-exhaustion vector.
When the API is mounted on another server that reads the body first, that
server's own limit can trip before this one. Those foreign body-limit errors
are recognized too, so they answer 413 rather than a generic 500: Fastify's
FST_ERR_CTP_BODY_TOO_LARGE (its bodyLimit), Express's
body-parser/raw-body entity.too.large, and any thrown HTTP error whose
statusCode/status is 413.
The platform request: request.raw
ApiRequest is framework-neutral on purpose, but platforms attach real data
to their native request objects — Cloudflare's request.cf carries geo
coordinates, ASN, TLS metadata. Each adapter exposes its native request as
request.raw: the Web Request on the fetch adapter and compiled engine,
the IncomingMessage on the Node adapter. It is typed unknown because
reading it is platform-specific by design — the cast at the use site is
the honest record of that coupling:
const nearby = defineRoute({
method: 'get',
path: '/nearby',
responses: { 200: { body: resultsSchema } },
handler: ({ request }) => {
const cf = (request.raw as Request & { cf?: IncomingRequestCfProperties }).cf
return { status: 200, body: search(cf?.latitude, cf?.longitude) }
},
})The context factory sees the same request, so platform data can flow into the
app context once instead of per handler. Portable code should keep raw
reads behind a seam (a context field) so only one module knows the platform.
Multiple set-cookie headers
Reply headers accept string | string[] per name. An array is sent as that
many separate header lines — the only correct encoding for repeated
set-cookie, which must never be comma-folded (RFC 6265). This is what
session + CSRF (Better Auth) or session + Stripe-state flows need:
const login = defineRoute({
method: 'post',
path: '/login',
request: { body: credentialsSchema },
responses: { 200: { body: profileSchema } },
handler: async ({ body }) => ({
status: 200,
headers: {
'set-cookie': [
`session=${await createSession(body)}; Path=/; HttpOnly; Secure`,
`csrf=${issueCsrf()}; Path=/; Secure`,
],
},
body: profile,
}),
})Both engines serialize arrays identically (the differential corpus covers
it), and the Node adapter validates each element before writeHead. With
validateResponses on, a declared response-header schema sees the value as
given — a string or the array — so declare anyOf if you validate a header
that can repeat.
Hooks: CORS, rate limits, security headers
Hooks, mounts, and createCors are features of the fetch adapter —
toNodeHandler deliberately omits them, because every Node framework it
plugs into already has a middleware chain for CORS, rate limits, and
security headers (Express/Connect middleware runs before the handler; plain
node:http users can wrap the returned listener).
toFetchHandler takes two hook chains over the raw Request/Response —
deliberately not a middleware onion. onRequest gates run in order before
mounts and routing, and the first returned Response short-circuits;
onResponse decorators run on every outgoing response, including 404s,
gate replies, and mounted routers, which is what security headers and CORS
actually require:
import { createCors, toFetchHandler } from '@amritk/api'
const cors = createCors({ origin: (o) => o, credentials: true, exposeHeaders: ['x-demo-used'] })
// createCors throws at setup on origin: '*' + credentials: true — a
// combination every browser rejects.
const handler = toFetchHandler(api, {
onRequest: [
cors.onRequest, // answers preflights
async (request, env) =>
(await allowed(request, env)) ? undefined : new Response('{"error":"rate_limited"}', { status: 429 }),
],
onResponse: [
cors.onResponse,
(response) => {
response.headers.set('x-frame-options', 'DENY')
},
],
})
// Compiled: compileToModule({ ..., onRequestExports: ['gate'], onResponseExports: ['stamp'] })Built-in security hooks
Rather than hand-roll the gates above, the package ships the common security
middleware as hook factories — the helmet / secure-headers, cors,
rate-limit, and CSRF features every framework in the ecosystem provides,
expressed over the same onRequest/onResponse/locals seams so they work
identically under the runtime and the compiled engine.
import {
createCors,
createCsrf,
createRateLimit,
createSecurityHeaders,
toFetchHandler,
} from '@amritk/api'
const cors = createCors({ origin: (o) => o, credentials: true })
const csrf = createCsrf()
const limit = createRateLimit({ limit: 100, windowMs: 60_000 })
const handler = toFetchHandler(api, {
onRequest: [cors.onRequest, limit.onRequest, csrf.onRequest],
onResponse: [cors.onResponse, limit.onResponse, csrf.onResponse, createSecurityHeaders()],
})createSecurityHeaders(options?) — an onResponse decorator that stamps
the browser-hardening headers (x-content-type-options: nosniff,
x-frame-options: SAMEORIGIN, referrer-policy: no-referrer, the
cross-origin isolation trio, …) only when the handler didn't already set them.
HSTS and CSP default off on purpose: strict-transport-security on a bare
IP or a plain-HTTP dev origin locks browsers out, and no single CSP fits every
app — opt into both explicitly (strictTransportSecurity: true,
contentSecurityPolicy: "…") for a production HTTPS deployment. Any field
takes false to omit or a string to override.
createCors(options) — preflight answerer (onRequest) plus allow/expose
stamper (onResponse), applied to every response including 404s and gate
short-circuits, since a browser drops any reply without the allow-origin
header. It throws at setup on the spec-forbidden origin: '*' +
credentials: true pair. A function origin ((o) => o) is trusted as
written — reflecting every origin with credentials: true turns any site
into a trusted caller, so validate the origin inside the function rather than
echoing it blindly.
createRateLimit(options) — counts each request against a key and
short-circuits over-limit ones with a 429 carrying Retry-After and the
RateLimit-* headers; under the limit it stamps those headers via locals.
The default in-process memoryRateLimitStore() is single-instance and
memory-bounded; pass a shared store (Redis, a Durable Object) for a fleet.
Keying is a security decision. The default key is the client IP read from
cf-connecting-ip/x-real-ip/ the firstx-forwarded-forhop — all client-supplied and spoofable. An attacker rotating the header gets a fresh bucket per request, defeating the limit. Rely on the default only when a trusted proxy overwrites these headers and the origin isn't reachable around it. For a security throttle (login / brute-force), pass akeythat reads a proxy-verified IP (the rightmost untrustedx-forwarded-forhop for your topology) or an authenticated user id fromlocals.
createCsrf(options?) — stateless double-submit-cookie CSRF (the defense
Rails, Laravel, and Hono ship). The gate rejects an unsafe-method request
whose x-csrf-token header doesn't match its csrf_token cookie with a 403
(empty/missing tokens are always rejected — a blank pair never satisfies the
check); the decorator seeds the cookie on any response that lacks one. The
cookie defaults to Path=/; SameSite=Lax; Secure and is intentionally not
HttpOnly — the pattern needs page scripts to read and echo it. Drop Secure
via cookieAttributes only for a plain-HTTP dev origin. Use exempt to skip
bearer-token API paths, where CSRF doesn't apply — exemptBearer is that
predicate written the safe way (see native apps
for why keying on authorization is sound and keying on a missing Origin is a
bypass). On the client, pair it with
createCsrfHeader() — a headers provider for createClient that reads
the csrf_token cookie and echoes it in x-csrf-token:
import { createClient, createCsrfHeader } from '@amritk/api/client'
const client = createClient(contracts, 'https://api.example.com', {
fetchOptions: { credentials: 'include' },
headers: createCsrfHeader(),
})Signed cookies
signCookie / unsignCookie / createSignedCookies sign a value with
HMAC-SHA256 over the Web Crypto API (so the same code runs on Workers, Bun,
Deno, and Node ≥ 20). A signed value is <value>.<base64url-hmac>; tampering
with either half fails verification, which runs through the constant-time
crypto.subtle.verify. This is integrity, not secrecy — the value stays
readable, so sign a session id and keep the session server-side; never put a
secret in it.
import { createSignedCookies } from '@amritk/api'
const cookies = createSignedCookies(env.COOKIE_SECRET)
const setCookie = `sid=${await cookies.sign(sessionId)}; HttpOnly; Secure; SameSite=Lax`
const sessionId = await cookies.unsign(parsedCookie) // undefined if tampered
// Rotate by unsigning against the current secret first, then older ones.Framework-parity helpers
The gates and decorators above are the security half of what a batteries-included
framework ships. The rest is here too, each one composing through an existing
seam (mounts, onRequest/onResponse, locals, the raw reply) rather than
changing the request pipeline — so nothing costs anything until you wire it in:
| Export | What it does | Seam |
|:--|:--|:--|
| createDocs(options?) · docsHtml(options?) | Interactive Scalar API reference page next to openapi.json. The bundle loads from a CDN at view time — pin or self-host it via cdn under a strict CSP. docsHtml returns the markup alone for apps that serve their own page. | mounts |
| createHealth(options?) | Health/readiness endpoint. Runs every probe concurrently and answers 200 {status:'ok'} or 503 {status:'error'} listing which are down — a throwing
