HTTP Adapters
Basalt is not tied to one HTTP framework. The route pipeline — validation, enrichers, guards, context and error mapping — lives in a neutral core (@basaltkit/http), and each framework is a thin adapter over it. Write your routes, tenancy, auth and permissions once, and run them on Fastify, Express or Hono unchanged.
| Adapter | Package | Serve with |
|---|---|---|
| Fastify | @basaltkit/fastify | app.container.get(FASTIFY).listen({ port }) |
| Express | @basaltkit/express | app.container.get(EXPRESS).listen(port) |
| Hono | @basaltkit/hono | @hono/node-server, Bun, Deno, or an edge fetch export |
The same routes everywhere
import { route, HttpError } from '@basaltkit/http' // or from '@basaltkit/fastify'
import { z } from 'zod'
export const routes = [
route({
method: 'GET',
url: '/things/:id',
params: z.object({ id: z.string() }),
async handler({ params }) {
const thing = await find(params.id)
if (!thing) throw new HttpError(404, 'THING_NOT_FOUND', 'Not found')
return thing
},
}),
]Pick an adapter — everything else (tenancy resolvers, auth guards, permissions, Zod validation, the standardized error shape) behaves identically:
import { fastifyPlugin, FASTIFY } from '@basaltkit/fastify'
const app = await createApp({ plugins: [/* … */, fastifyPlugin({ routes })] }).boot()
await app.container.get(FASTIFY).listen({ port: 3000 })import { expressPlugin, EXPRESS } from '@basaltkit/express'
const app = await createApp({ plugins: [/* … */, expressPlugin({ routes })] }).boot()
app.container.get(EXPRESS).listen(3000)import { honoPlugin, HONO } from '@basaltkit/hono'
import { serve } from '@hono/node-server'
const app = await createApp({ plugins: [/* … */, honoPlugin({ routes })] }).boot()
serve({ fetch: app.container.get(HONO).fetch, port: 3000 })Live example — the playground
The repo's apps/playground is the same neutral route() list (a small Projects CRUD + multi-tenancy) served on all three adapters. Only the last line of buildApp() changes — pick the runtime with an env var:
pnpm --filter playground dev # fastify (default)
ADAPTER=express pnpm --filter playground dev
ADAPTER=hono pnpm --filter playground devIts tests/adapters.e2e.test.ts runs the identical flow over a real socket on Fastify, Express and Hono — the executable proof that routes are runtime-neutral.
Complete example — Fastify
Install the adapter and Fastify:
pnpm add @basaltkit/core @basaltkit/fastify fastify @basaltkit/tenancy @basaltkit/auth @basaltkit/permissions zodRoutes are typed from their Zod schemas and protected declaratively through meta. Enrichers run first (tenancy resolves the tenant, auth reads the Authorization: Bearer token into ctx().user); then guards run (meta: { auth: true } demands a user, meta: { can: '…' } demands a permission). A guard rejects by throwing — you never write that check by hand.
Declaring security meta without the plugin that enforces it fails at boot (UnguardedRouteMetaError) instead of silently serving the route open. When authentication genuinely happens at an outer edge, opt out per adapter with fastifyPlugin({ routes, allowUnguardedMeta: true }) (Express and Hono take the same option; pass ['auth'] to waive a single key).
src/routes.ts:
import { ctx } from '@basaltkit/core'
import { route, HttpError } from '@basaltkit/fastify'
import { z } from 'zod'
const projects = new Map<string, { id: string; name: string }>()
export const routes = [
// Public — params typed from the Zod schema.
route({
method: 'GET',
url: '/projects/:id',
params: z.object({ id: z.string() }),
async handler({ params }) {
const project = projects.get(params.id)
if (!project) throw new HttpError(404, 'PROJECT_NOT_FOUND', 'Not found')
return project
},
}),
// Requires an authenticated user (auth guard reads `meta.auth`).
route({
method: 'POST',
url: '/projects',
body: z.object({ name: z.string().min(1) }),
meta: { auth: true }, // no user → 401 AUTH_REQUIRED
async handler({ body }) {
const project = { id: crypto.randomUUID(), name: body.name }
projects.set(project.id, project)
ctx().logger.info({ owner: ctx().user?.email }, 'project created')
return project
},
}),
// Requires a specific permission (permissions guard reads `meta.can`).
route({
method: 'DELETE',
url: '/projects/:id',
params: z.object({ id: z.string() }),
meta: { can: 'projects:delete' }, // missing permission → 403
async handler({ params }) {
return { deleted: projects.delete(params.id) }
},
}),
]src/server.ts — wire the plugins and boot. The order in plugins doesn't matter (Basalt boots them in dependency order); enrichers and guards register themselves into the pipeline every route runs through:
import { createApp, ctx } from '@basaltkit/core'
import { fastifyPlugin, FASTIFY } from '@basaltkit/fastify'
import { headerResolver, MemoryTenantSource, tenancyPlugin } from '@basaltkit/tenancy'
import { authPlugin, authRoutes, MemoryUserSource } from '@basaltkit/auth'
import { GLOBAL_SCOPE, MemoryAccessStore, permissionsPlugin } from '@basaltkit/permissions'
import { routes } from './routes.js'
const access = new MemoryAccessStore()
await access.grantToUser('user-ada', ['projects:delete'], GLOBAL_SCOPE)
const app = await createApp({
plugins: [
tenancyPlugin({ source: new MemoryTenantSource(), resolvers: [headerResolver()] }),
authPlugin({ secret: process.env.APP_SECRET!, users: new MemoryUserSource() }),
permissionsPlugin({ store: access }),
// authRoutes() adds /auth/register, /auth/login, /auth/me, …
fastifyPlugin({ routes: [...routes, ...authRoutes()] }),
],
}).boot()
const server = app.container.get(FASTIFY)
await server.listen({ port: 3000 })
console.log('http://localhost:3000')
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.once(signal, () => server.close().then(() => app.shutdown()).then(() => process.exit(0)))
}A request to POST /projects without a token gets a 401 AUTH_REQUIRED; a DELETE /projects/:id from a user lacking projects:delete gets a 403 — both with the standardized error body, and neither check written inside a handler.
Complete example — Express
Install the adapter and Express:
pnpm add @basaltkit/core @basaltkit/http @basaltkit/express expresssrc/app.ts — wire your plugins and routes (this is identical for every adapter except the last line):
import { createApp } from '@basaltkit/core'
import { expressPlugin } from '@basaltkit/express'
import { headerResolver, MemoryTenantSource, tenancyPlugin } from '@basaltkit/tenancy'
import { healthPlugin, metricsPlugin, securityPlugin } from '@basaltkit/http'
import { routes } from './routes.js'
export function buildApp() {
return createApp({
plugins: [
tenancyPlugin({ source: new MemoryTenantSource(), resolvers: [headerResolver()] }),
securityPlugin({ rateLimit: { limit: 300, windowMs: 60_000 }, headers: true }),
healthPlugin({ checks: { db: () => ({ ok: true }) } }),
metricsPlugin(),
expressPlugin({ routes }), // ← the only adapter-specific line
],
})
}src/server.ts — boot, listen, and shut down cleanly:
import { EXPRESS } from '@basaltkit/express'
import { buildApp } from './app.js'
const app = await buildApp().boot()
const server = app.container.get(EXPRESS).listen(3000, () => console.log('http://localhost:3000'))
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.once(signal, () => server.close(async () => { await app.shutdown(); process.exit(0) }))
}expressPlugin adds express.json() (1 MiB, bodyLimit) for you. To integrate into an existing Express app, pass it in: expressPlugin({ app: myExistingApp, routes }).
Complete example — Hono
Install the adapter, Hono, and (for Node) the Node server:
pnpm add @basaltkit/core @basaltkit/http @basaltkit/hono hono @hono/node-serversrc/app.ts is the same as above with honoPlugin({ routes }) in place of expressPlugin({ routes }). Then serve it on Node:
// src/server.ts
import { serve } from '@hono/node-server'
import { HONO } from '@basaltkit/hono'
import { buildApp } from './app.js'
const app = await buildApp().boot()
serve({ fetch: app.container.get(HONO).fetch, port: 3000 }, (info) =>
console.log(`http://localhost:${info.port}`),
)Bun, Deno, Cloudflare Workers, edge
Hono runs on any runtime — export the app's fetch and let the platform serve it:
// Bun / Deno / Cloudflare Workers entry
import { HONO } from '@basaltkit/hono'
import { buildApp } from './app.js'
const app = await buildApp().boot()
export default { fetch: app.container.get(HONO).fetch }Edge runtimes
The HTTP core, routes, tenancy, auth, permissions and the security/metrics/tracing edge plugins run on the edge. Node-only infrastructure — @basaltkit/queue (BullMQ), @basaltkit/prisma, local file @basaltkit/storage — is not available in Workers/Deno-deploy; use HTTP-based drivers there.
Uploads
File uploads are adapter-neutral too. Give a route body: upload({ … }) from @basaltkit/http and it accepts multipart/form-data on all three frameworks, with no @fastify/multipart, multer or Hono parseBody involved. Basalt has its own streaming parser. It has no dependencies and follows RFC 7578.
import { route, upload } from '@basaltkit/http'
route({
method: 'POST',
url: '/documents',
body: upload({ maxBytes: 20 * 1024 * 1024, maxFiles: 3, allowedTypes: ['application/pdf', 'image/*'] }),
meta: { auth: true, rateLimit: { limit: 10, windowMs: 60_000, key: 'user' } },
async handler({ body }) {
for await (const file of body.files) {
// file: { field, filename, declaredType, stream: Readable }
await files.upload(file.stream, { name: file.filename, contentType: file.declaredType })
}
return { fields: body.fields } // Record<string, string>
},
})- The pipeline runs first. Pre-hooks (rate limit, CORS), enrichers (tenant, user) and guards (
auth,can, …) all run before a single body byte is read. A rejected upload is answered without being received. - Streamed, never buffered.
body.filesis an async iterable. Each file'sstreamis read from the network only as you consume it (backpressure included). A file you skip is discarded when you ask for the next one.body.fieldsfills as parts arrive: a field sent before a file is available when that file is yielded, and all of them oncefilesis exhausted. - Limits hold on the bytes received, not on what the client declares. A
Content-LengthovermaxBytesis refused before reading anything. - Filenames are sanitised: directories (
../../x,C:\x), control characters and bidi overrides are stripped, sofilenameis a safe label. It is still never a storage key.declaredTypeis the client's claim, so sniff the bytes (@basaltkit/filesvalidate.sniff) before trusting it. - Nothing hangs. When the handler returns (or throws) without reading everything, the rest is drained in the background up to
maxBytesand the response carriesConnection: close. - Straight into storage.
file.declaredLengthis the part's ownContent-Length, when the client sent one — pass it tofiles.upload(file.stream, { contentLength })so a backend that needs an exact size (S3) streams instead of buffering. Be honest about it: RFC 7578 does not require a per-partContent-Lengthand no browser sends one, so it is usuallyundefined. The request'sContent-Length(body.contentLength) covers every part plus the framing, so it is an upper bound for one file, never its size. Without a declared length@basaltkit/filesbounds the write withvalidate.maxSizeinstead.
upload() option | Default | Past it |
|---|---|---|
maxBytes (required) | none | 413 PAYLOAD_TOO_LARGE, for the whole request including multipart framing |
maxFiles (required) | none | 400 TOO_MANY_FILES |
maxFileBytes | maxBytes | 413 PAYLOAD_TOO_LARGE |
maxFields | 50 | 400 TOO_MANY_FIELDS |
maxFieldBytes | 64 KiB | 413 PAYLOAD_TOO_LARGE |
maxHeaderBytes | 8 KiB (per part) | 400 MALFORMED_MULTIPART |
allowedTypes | any | 415 UNSUPPORTED_MEDIA_TYPE for a file part whose declared type is not listed (image/png, or image/*) |
Other errors: 415 UNSUPPORTED_MEDIA_TYPE if the request is not multipart/form-data. 400 MALFORMED_MULTIPART for a missing, repeated or invalid boundary, a body that ends before the closing boundary (truncated or aborted upload), malformed or folded part headers, a nested multipart/* part, or a Content-Transfer-Encoding other than binary.
Each adapter only hands over the raw request stream. Fastify gets a pass-through multipart/form-data parser, registered only when an upload route exists and never over one you registered yourself. Other Fastify routes still answer 415. Express's json()/urlencoded() parsers never read multipart. Hono skips its bodyLimit buffering for multipart; a non-upload route still parses a multipart body within bodyLimit. In OpenAPI the route's request body is documented as multipart/form-data.
Raw request bodies (webhook signatures)
Some bodies must not be parsed at all. Stripe, Paddle, Lemon Squeezy, Dropbox, Microsoft Graph and GitHub all sign the octets they sent, so a signature can only be checked against those exact bytes. JSON.stringify of the parsed object is not an approximation of them — different whitespace, different key order, 1.50 re-printed as 1.5 — and verifying against it fails every genuine delivery.
Give such a route body: rawBody({ … }) from @basaltkit/http and it receives the untouched bytes on all three frameworks:
import { rawBody, route } from '@basaltkit/http'
route({
method: 'POST',
url: '/webhooks/stripe',
body: rawBody({ maxBytes: 64 * 1024 }),
async handler({ body, request }) {
const event = stripe.webhooks.constructEvent(
body.text(), // the bytes, decoded as UTF-8
request.headers['stripe-signature'] as string,
process.env.STRIPE_WEBHOOK_SECRET!,
)
// body.bytes → Buffer, exactly what arrived
// body.contentType → 'application/json' (essence, lower-cased)
// body.contentLength→ what the client declared, when it declared one
return { received: true }
},
})- The pipeline runs first, exactly as for
upload(): pre-hooks (rate limit, CORS), enrichers and guards all run before a single body byte is read. A body the route never gets to read is drained and the response carriesConnection: close, so nothing hangs. - Nothing parses the bytes — not Basalt, not the app's own parsers. The handler gets a
Buffer, andrequest.bodystays undefined. - The cap holds on the bytes received, not on what the client declares. A
Content-LengthovermaxBytesis refused before anything is read. - Neighbouring routes are untouched. A
rawBody()route in an app does not change how any other route is parsed or validated. - In OpenAPI the request body is published as opaque bytes (
*/*,format: binary) rather than an invented schema.
rawBody() option | Default | Past it |
|---|---|---|
maxBytes | 1 MiB | 413 PAYLOAD_TOO_LARGE, on the declared Content-Length or on the bytes received |
Other errors: 400 BAD_REQUEST when the body ends mid-flight (client hung up), and 500 RAW_BODY_UNAVAILABLE when the request declared bytes (a Content-Length above zero, or a Transfer-Encoding) and no adapter could supply them — a refusal, deliberately, rather than a reconstruction.
A request that declared no body is a different case: Content-Length: 0, or no framing headers at all, yields a zero-length Buffer. That is a fact about the request rather than a guess about a message, and it is the shape several providers validate a webhook URL with — Microsoft Graph posts ?validationToken=… with no body at all, before the subscription it would sign for exists. Refusing those would surface a body-parser problem as subscriptionValidationFailed, pointing the operator at entirely the wrong thing.
What each adapter does — and the one caveat
| Adapter | How the bytes survive | Caveat |
|---|---|---|
| Fastify | rawBody() routes are mounted in their own encapsulated scope whose only content-type parser hands the request stream over unread, for any content type. | None. Your own parsers (the adapter's JSON one, @fastify/multipart, anything you registered) are never removed or overridden — they keep serving every other route, and a non-JSON body on a JSON route still answers 415. |
| Hono | The plugin's bounded pre-read and its pre/after hooks step aside for these paths, so the web Request's own stream still carries the octets. | None. bodyLimit does not apply to the route; its own maxBytes does. |
| Express | expressPlugin gives express.json() and express.urlencoded() a type filter that returns false for rawBody() paths, so body-parser never reads them, plus a verify hook that keeps the buffer as a second line of defence. Both are installed only when a rawBody() route exists. | One, and it is real — see below. |
The Express caveat: express.json() is mounted on the whole app, so if you bring your own app with its own parser already mounted, body-parser consumes the stream before any Basalt route runs and the original bytes are gone. Give it the verify hook and they survive:
import express from 'express'
import { captureRawBody, expressPlugin } from '@basaltkit/express'
const app = express()
app.use(express.json({ verify: captureRawBody }))
app.use(express.urlencoded({ extended: false, verify: captureRawBody }))
expressPlugin({ app, routes })The common verify: (req, _res, buf) => { req.rawBody = buf } convention is honoured too, so an app already doing that needs no change. With neither, the route answers 500 RAW_BODY_UNAVAILABLE. That is the point: it refuses rather than verify a signature against a message nobody sent.
Streaming responses
A handler can return a stream instead of a JSON payload: stream(source, options) from @basaltkit/http is the neutral streaming response, and each adapter sends it over its own transport without ever buffering it.
import { route, stream } from '@basaltkit/http'
route({
method: 'GET',
url: '/invoices/:id/pdf',
meta: { auth: true },
async handler({ params }) {
const { record, stream: body } = await files.downloadStream(params.id)
return stream(body, {
contentType: record.contentType,
contentLength: record.size, // omit when unknown — the response is then chunked
filename: record.name, // Content-Disposition: attachment, sanitised
})
},
})source is a Node Readable, a web ReadableStream, or any AsyncIterable<Uint8Array>. Options: contentType (default application/octet-stream), contentLength, filename, disposition ('attachment' by default — an uploaded HTML/SVG file must never render on your origin), extra headers, and status.
The same guarantees on all three
These are not "best effort": one shared parity suite runs them against Fastify, Express and Hono, and a download of several MiB is compared byte for byte on each.
| Behaviour | Fastify | Express | Hono |
|---|---|---|---|
| How it is sent | reply.send(readable) (Fastify's stream path) | pipeline(readable, res) | Response over a web ReadableStream |
| Never buffered, real backpressure — a slow client slows the source | ✅ | ✅ | ✅ |
| Client disconnects → the source is destroyed (no leaked file descriptor or S3 socket) | ✅ | ✅ | ✅ (request.signal) |
| Error before the first byte → normal JSON error body, streaming headers withdrawn | ✅ | ✅ | ✅ |
| Error after the headers → connection cut, nothing appended to the partial body | ✅ | ✅ | ✅ (body errored) |
That late failure reported once through onError (STREAM_FAILED, status 500) | ✅ | ✅ | ✅ |
HEAD → the headers a GET would carry, no body, source released unread | ✅ | ✅ | ✅ |
Content-Length / Content-Disposition (RFC 5987) | ✅ | ✅ | ✅ |
Filenames are sanitised
filename goes through the same sanitizeFilename() the multipart parser uses — directories (../../x, C:\x), control characters and bidi overrides are stripped — and is then written as a quoted printable-ASCII filename= plus an RFC 5987 filename*=UTF-8''… when anything was lost. A client-supplied name can never inject a header.
There is no maxDurationMs
Unlike sse(), a streamed body has no framework-level lifetime cap — a large download legitimately takes a long time, and a cap would truncate it. Bound it at the server: fastifyPlugin({ fastify: { requestTimeout, connectionTimeout } }), Express's server.setTimeout(), or your runtime's own limit on Hono.
meta: { etag: true } is skipped for a streamed body — there is no payload to hash, and hashing the marker would answer 304 for a body that was never sent.
Wire behaviour — identical on all three
The same route answers the same bytes, whichever adapter serves it. A shared parity suite (wireParitySuite in @basaltkit/http's tests) holds Fastify, Express and Hono to each of these:
| Behaviour | All three adapters |
|---|---|
| A handler returns a string | text/plain; charset=utf-8 — never text/html. To serve HTML, set it: reply.header('content-type', 'text/html; charset=utf-8').send(html) |
| Which bodies are JSON | application/json or a +json type (application/merge-patch+json), parameters and case ignored. text/plain; application/json is not JSON — it is CORS-safelisted, so a cross-site page can send it without a preflight |
| Malformed JSON | 400 { "error": { "code": "BAD_REQUEST", "message": "Malformed request body." } } |
| Empty JSON body | request.body is undefined (no body) |
| Default body limit | 1 MiB (DEFAULT_BODY_LIMIT) → 413 PAYLOAD_TOO_LARGE; bodyLimit on Express/Hono, fastify: { bodyLimit } on Fastify |
| Repeated query key | ?a=1&a=2 → { a: ['1', '2'] }; ?c[d]=1 → { 'c[d]': '1' } (no nested objects) |
request.url | path + query string (/items?x=1), never an absolute URL |
| Routing | case-sensitive, no trailing-slash alias: /Items and /items/ do not reach /items (on the app expressPlugin creates; an Express app you bring keeps its own settings) |
sse() stream | keeps the CORS, security, rate-limit and x-request-id headers set before it; headers are sent at once, before the first event |
| An error outside a route (pre-hook, edge route, body) | neutral JSON envelope, reported through onError — Express's errorHandler, Hono's app.onError (errorHandler: false to opt out) |
An upstream SDK error carrying status/type | 500 INTERNAL_ERROR. Only errors thrown on purpose (HttpError) or marked expose: true choose their status |
| After-hooks (metrics, tracing) | run once per request — also for an sse() stream and a response the client abandoned; a failing one is reported as AFTER_HOOK_FAILED and never changes the response |
Known differences that remain, all deliberate or harmless:
- Other body types. A body that is neither JSON nor a form reaches the handler as a string on Hono and on Fastify for
text/plain; Fastify answers415to other types; Express leavesrequest.bodyundefined. Declare abodyschema and send JSON — or takerawBody()for anything else. - Dot segments. Hono (through the web
URL) resolves/a/../adminto/admin; Fastify and Express route the path as sent and answer404. Each adapter's pre-hooks and routes see the same path, so a path check cannot be walked around — but do not rely on either behaviour. x-request-idis set by the route pipeline, so it is on every response a route produced and absent from a404for an unknown path or a pre-hook's own answer (a429, a preflight), on all three.request.ipbehind a proxy. Each adapter reports the socket address by default. Behind a proxy you trust, enable it explicitly — Fastifyfastify: { trustProxy }, Expressapp.set('trust proxy', …)on an app you pass in, HonogetClientIp— or every client shares the proxy's IP (and one rate-limit bucket)./metricsand/openapi.jsonare public: edge routes skip enrichers and guards. Keep them off the public listener, or put a pre-hook in front.
How it works
@basaltkit/httpdefines the neutralHttpRequest/HttpReplyand therunRoutepipeline. Enrichers and guards (tenancy, auth, permissions) register into thehttp:enrichers/http:guardsmetadata buckets — they are framework-agnostic and every adapter runs them.- Each adapter maps its framework's request/response to the neutral shape, invokes
runRoute, and maps thrown errors with the sharedtoErrorResponse— so a validation failure is400 HTTP_VALIDATIONand anHttpError(404)is a 404 with the same body on all three. Unmatched routes get the same treatment: every adapter serves the neutral404 { "error": { "code": "NOT_FOUND", … } }instead of its framework's default (opt out withnotFound: falseon the adapter plugin). A structured payload (new HttpError(422, code, message, { details })) is sanitised and serialised aserror.detailsby that same neutral serializer, so it is identical on the three — see Structured error details. - The handler's
request/replyare the neutral types; reach the underlying framework object viarequest.rawwhen you truly need it.
Options reference
All three plugins share the same core options; each accepts its framework's native extras.
| Option | Type | Default | Adapters | Why |
|---|---|---|---|---|
routes | BasaltRoute[] | [] | all | The neutral routes to mount. |
allowUnguardedMeta | boolean | string[] | fail loud at boot | all | Waives the boot check that every route declaring a guarded security key (meta.auth/can/teamRole/scopes/subscribed/feature) has a registered guard enforcing it (UnguardedRouteMetaError otherwise). Only for deployments where protection genuinely happens at an outer edge. Never waives the route-meta validators (InvalidRouteMetaError). |
notFound | boolean | true (neutral 404 body) | all | Pass false to opt out of the shared 404 { error: { code: 'NOT_FOUND' } } and keep the framework default. |
fastify | FastifyServerOptions | {} | fastify | Passed to the Fastify() constructor (logger, trustProxy, …). |
app | native instance | created for you | express, hono | Bring your own express() / new Hono() and Basalt mounts onto it. |
bodyLimit | number (bytes) | 1 MiB | express, hono | Rejects oversized bodies with 413 (PAYLOAD_TOO_LARGE) — the same default as Fastify's own. On Express it is the JSON/form parsers' limit (body-parser alone would stop at 100 KiB). On Hono — which has no default cap — it is enforced on the bytes actually read: a chunked/streamed body without Content-Length is counted while buffering and cut off at the limit. An upload() route is bounded by its own maxBytes instead (streamed, never buffered). |
getClientIp | (c: Context) => string | undefined | socket address (@hono/node-server, Bun) | hono | Sets request.ip, the key for per-client rate limiting and the IP login throttle. On an edge runtime or behind a trusted proxy, supply it (e.g. (c) => c.req.header('cf-connecting-ip') on Cloudflare). When no IP resolves, a one-time warning is printed and rate limits share one bucket. Never read X-Forwarded-For unless a proxy you control overwrites it. |
errorHandler | boolean | true | express, hono | Hono: an app.onError that answers errors raised outside a route (pre-hook, edge route, body) with the neutral JSON envelope and reports them, instead of a plain-text 500 (an HTTPException from your own middleware keeps its response). Express: a final (err, req, res, next) middleware that turns body-parser and pre-hook errors into the neutral JSON envelope (400 BAD_REQUEST, 413 PAYLOAD_TOO_LARGE, 415 UNSUPPORTED_MEDIA_TYPE, otherwise 500 INTERNAL_ERROR) instead of Express's HTML page with a stack trace. Pass false only if you mount your own error handler after boot. |
Failure modes
| You see | It means | Do |
|---|---|---|
UnguardedRouteMetaError at boot | a route declares security meta no registered guard enforces | register the enforcing plugin, or allowUnguardedMeta (see Security) |
InvalidRouteMetaError at boot | a plugin's route-meta validator refused a value (e.g. an unknown meta.teamRole) | fix the value; allowUnguardedMeta does not waive it (see Security) |
500 HTTP_GUARDS_UNRUNNABLE | the route pipeline carries guards but no container, so none of them could run | pass container to the pipeline — every shipped adapter does; only hand-built pipelines can hit this |
400 HTTP_VALIDATION | body/query/params failed the route's Zod schema | the response lists the part and per-field issues |
404 { code: 'NOT_FOUND' } on a route you defined | the route wasn't registered on this adapter instance | check it is in routes: [...] of the adapter plugin that booted |
413 PAYLOAD_TOO_LARGE | body exceeded the body limit (1 MiB by default on all three) | raise bodyLimit (fastify: { bodyLimit } on Fastify) deliberately |
400 BAD_REQUEST | the body could not be parsed (malformed JSON; on Express also a corrupt encoding) | send a valid body |
400 HTTP_VALIDATION for a JSON body you did send | its Content-Type is not application/json or +json (e.g. text/plain) | send a JSON media type |
400 MALFORMED_MULTIPART / TOO_MANY_FILES, 413, 415 on an upload() route | the upload broke a limit or the multipart framing | see Uploads |
[basalt:hono] Could not resolve the client IP warning | this runtime exposes no socket address to the adapter | pass honoPlugin({ getClientIp }) |
Edge plugins are neutral too
The edge plugins target a neutral HttpServer (the HTTP_SERVER token, which every adapter provides), so they run on all three frameworks unchanged: securityPlugin, metricsPlugin, healthPlugin, tracingPlugin and openapiPlugin. Add them to plugins: [...] next to any adapter.
createApp({
plugins: [
expressPlugin({ routes }), // or fastifyPlugin / honoPlugin
securityPlugin({ rateLimit, cors, headers: true }),
healthPlugin({ checks }),
metricsPlugin(),
tracingPlugin({ exporter }),
openapiPlugin({ info }),
],
})The one exception is idempotencyPlugin, which intercepts the response body — that remains Fastify-specific for now.