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 })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.
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 { MemoryAccessStore, permissionsPlugin } from '@basaltkit/permissions'
import { routes } from './routes.js'
const access = new MemoryAccessStore()
await access.grantToUser('user-ada', ['projects:delete'], 'global')
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() 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.
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. - The handler's
request/replyare the neutral types; reach the underlying framework object viarequest.rawwhen you truly need it.
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.