npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

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

About

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

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

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

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

Open Software & Tools

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

© 2026 – Pkg Stats / Ryan Hefner

@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).

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 / body already validated and coerced, typed from the schema literals. The return type is derived from the responses map — returning an undeclared status or a wrong body shape is a compile error.
  • Eval-free. The default engine is @amritk/runtime-validators — no new 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, the hc replacement 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

Serving it — one Api, two adapters, a recipe per framework

Requests and responses

Middleware, security, state

Engines

Integration recipes

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-route guards cover 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 createDocs serves.
  • 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/client is the browser-safe entry: everything above — createClient, defineContract, the opt-in serializers, the error predicates, the …Of type helpers, and the client-side auth helpers (createCsrfHeader, createTokenRefresh, createRefreshFetch) — with an import graph that never touches a server module or a node: built-in, guaranteed by a test. Importing from the root @amritk/api works too (sideEffects: false tree-shakes the server half out of the final bundle), but the root barrel makes bundlers resolve the server adapters and print node:http/node:stream externalization warnings along the way; the subpath never triggers them.

  • Replies are a discriminated union on status, derived from the responses map. JSON statuses carry a typed body (parsed eagerly); statuses declared with a raw contentType carry only the untouched Response — 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/cookies are required and schema-typed, headers accepts the declared shape plus ad-hoc extras, and a per-call signal cancels. Contracts with no request slots call with no argument at all (client.health()). Every call also accepts fetchOptions (per-call RequestInit extras, merged over the client-level ones) and timeoutMs (overriding the client default; a timeout and a caller signal compose via AbortSignal.any). Requests send accept: application/json unless a header overrides it.

  • Cookies and browsers: the cookies slot serializes into the cookie request header, which browsers forbid scripts from setting — it works from Node/undici/workers only, and is opt-in for exactly that reason: register cookies: appendCookies to use it; a browser bundle omits it and never carries the code. Browser cookie auth uses server-set cookies plus fetchOptions: { 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 consumed Response and the parse error as cause, instead of a bare SyntaxError.

  • Everything beyond plain JSON calls is an opt-in import: JSON bodies and the raw text/bytes bodies (sent verbatim) are built in; the rest is registered explicitly so a JSON-only, static-path app bundles none of it. Contracts with bodyType: 'form' / 'multipart' (urlencoded pairs / FormData with File values intact) need their serializer, {param} path templates need pathParams: buildParamPath (segment-encoded; greedy {path+} keeps its slashes), query strings need queryParams: toSearchParams (array values repeat the key, undefined skipped), and the Node-only cookies slot needs cookies: 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 (any bodyType, 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 unread Response rides on the error. Declare the statuses you want to handle in the contract.

  • Name wire types from the contracts — the …Of helpers 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, undefined when undeclared), and ClientReplyOf / RouteReplyOf (the client and handler reply unions). Error payloads become named exports instead of inline as { ... } 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 ?query suffixes).

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. toNodeHandler deliberately 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 env in createApi({ context }) — Workers bindings on Workers, but the Server on 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's app.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 ExecutionContextwaitUntil, 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 app

Hono 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 / array properties 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 so additionalProperties rules still apply.
  • Declaring request.body makes 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 a 400 { error: 'invalid_json' }; a form/multipart body that fails to parse is a 400 { error: 'invalid_body' }.
  • A request whose content-type contradicts the declared body type answers 415 { 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 bare curl and hand-rolled clients keep working. JSON accepts application/json and +json structured suffixes.
  • request.headers takes 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 an in: 'header' OpenAPI parameter — so x-api-key-style auth requirements document themselves.
  • request.cookies works the same way for the cookie header: 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 an in: 'cookie' OpenAPI parameter.
  • HEAD is served automatically wherever GET is (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 explicit head route overrides the fallback for its path.
  • A known path requested with the wrong method answers 405 { error: 'method_not_allowed' } with a sorted allow header (advertising HEAD whenever GET is served, and OPTIONS always); unknown paths stay 404.
  • OPTIONS on a known path answers 204 with the same allow header automatically; declaring an explicit options route overrides it. CORS preflights are answered earlier by the createCors gate when configured.
  • Validation failures answer 400 with { error: 'validation_failed', source, errors } where errors carries the same { message, path } shape as @amritk/runtime-validators and source is params, query, headers, or body. The errors option 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 adaptertoNodeHandler 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 first x-forwarded-for hop — 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 a key that reads a proxy-verified IP (the rightmost untrusted x-forwarded-for hop for your topology) or an authenticated user id from locals.

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