Skip to content

Adaptadores HTTP ​

O Basalt não está preso a uma única framework HTTP. O pipeline de rotas — validação, enrichers, guards, contexto e mapeamento de erros — vive num core neutro (@basaltkit/http), e cada framework é um adaptador fino por cima. Escreve as tuas rotas, tenancy, auth e permissões uma vez, e corre-as em Fastify, Express ou Hono sem alterações.

AdaptadorPacoteServe com
Fastify@basaltkit/fastifyapp.container.get(FASTIFY).listen({ port })
Express@basaltkit/expressapp.container.get(EXPRESS).listen(port)
Hono@basaltkit/hono@hono/node-server, Bun, Deno, ou um export fetch de edge

As mesmas rotas em todo o lado ​

ts
import { route, HttpError } from '@basaltkit/http' // ou de '@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
    },
  }),
]

Escolhe um adaptador — tudo o resto (resolvers de tenancy, guards de auth, permissões, validação Zod, o formato de erro padronizado) comporta-se de forma idêntica:

ts
import { fastifyPlugin, FASTIFY } from '@basaltkit/fastify'

const app = await createApp({ plugins: [/* … */, fastifyPlugin({ routes })] }).boot()
await app.container.get(FASTIFY).listen({ port: 3000 })
ts
import { expressPlugin, EXPRESS } from '@basaltkit/express'

const app = await createApp({ plugins: [/* … */, expressPlugin({ routes })] }).boot()
app.container.get(EXPRESS).listen(3000)
ts
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 })

Exemplo vivo — o playground ​

O apps/playground do repositório é a mesma lista neutra de route() (um pequeno CRUD de Projetos + multi-tenancy) servida nos três adaptadores. Só muda a última linha do buildApp() — escolhe o runtime com uma variável de ambiente:

bash
pnpm --filter playground dev               # fastify (por omissão)
ADAPTER=express pnpm --filter playground dev
ADAPTER=hono    pnpm --filter playground dev

O seu tests/adapters.e2e.test.ts corre o fluxo idêntico sobre um socket real em Fastify, Express e Hono — a prova executável de que as rotas são neutras ao runtime.

Exemplo completo — Fastify ​

Instala o adaptador e o Fastify:

bash
pnpm add @basaltkit/core @basaltkit/fastify fastify @basaltkit/tenancy @basaltkit/auth @basaltkit/permissions zod

As rotas são tipadas a partir dos seus schemas Zod e protegidas declarativamente através de meta. Os enrichers correm primeiro (a tenancy resolve o tenant, a auth lê o token Authorization: Bearer para ctx().user); depois correm os guards (meta: { auth: true } exige um utilizador, meta: { can: '…' } exige uma permissão). Um guard rejeita lançando uma exceção — nunca escreves essa verificação à mão.

Declarar meta de segurança sem o plugin que a aplica falha no boot (UnguardedRouteMetaError) em vez de servir a rota aberta silenciosamente. Quando a autenticação acontece genuinamente numa edge exterior, opta por sair por adapter com fastifyPlugin({ routes, allowUnguardedMeta: true }) (Express e Hono aceitam a mesma opção; passa ['auth'] para dispensar uma única chave).

src/routes.ts:

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 = [
  // Pública — params tipados a partir do schema Zod.
  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
    },
  }),

  // Requer um utilizador autenticado (o guard de auth lê `meta.auth`).
  route({
    method: 'POST',
    url: '/projects',
    body: z.object({ name: z.string().min(1) }),
    meta: { auth: true }, // sem utilizador → 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
    },
  }),

  // Requer uma permissão específica (o guard de permissões lê `meta.can`).
  route({
    method: 'DELETE',
    url: '/projects/:id',
    params: z.object({ id: z.string() }),
    meta: { can: 'projects:delete' }, // permissão em falta → 403
    async handler({ params }) {
      return { deleted: projects.delete(params.id) }
    },
  }),
]

src/server.ts — liga os plugins e arranca. A ordem em plugins não importa (o Basalt arranca-os por ordem de dependência); os enrichers e guards registam-se a si próprios no pipeline por onde cada rota corre:

ts
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() adiciona /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)))
}

Um pedido a POST /projects sem token recebe um 401 AUTH_REQUIRED; um DELETE /projects/:id de um utilizador sem projects:delete recebe um 403 — ambos com o corpo de erro padronizado, e nenhuma das verificações escrita dentro de um handler.

Exemplo completo — Express ​

Instala o adaptador e o Express:

bash
pnpm add @basaltkit/core @basaltkit/http @basaltkit/express express

src/app.ts — liga os teus plugins e rotas (isto é idêntico para cada adaptador exceto na última linha):

ts
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 }), // ← a única linha específica do adaptador
    ],
  })
}

src/server.ts — arranca, escuta e encerra de forma limpa:

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

O expressPlugin adiciona express.json() (1 MiB, bodyLimit) por ti. Para integrar numa app Express existente, passa-a: expressPlugin({ app: myExistingApp, routes }).

Exemplo completo — Hono ​

Instala o adaptador, o Hono e (para Node) o servidor Node:

bash
pnpm add @basaltkit/core @basaltkit/http @basaltkit/hono hono @hono/node-server

src/app.ts é o mesmo que acima com honoPlugin({ routes }) no lugar de expressPlugin({ routes }). Depois serve-o em Node:

ts
// 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 ​

O Hono corre em qualquer runtime — exporta o fetch da app e deixa a plataforma servi-lo:

ts
// Entry point Bun / Deno / Cloudflare Workers
import { HONO } from '@basaltkit/hono'
import { buildApp } from './app.js'

const app = await buildApp().boot()
export default { fetch: app.container.get(HONO).fetch }

Aviso: Runtimes de edge

O core HTTP, as rotas, tenancy, auth, permissões e os plugins de edge de security/metrics/tracing correm no edge. Infraestrutura só-de-Node — @basaltkit/queue (BullMQ), @basaltkit/prisma, ficheiros locais @basaltkit/storage — não está disponível em Workers/Deno-deploy; usa aí drivers baseados em HTTP.

Uploads ​

Os uploads de ficheiros também são neutros em relação ao adaptador. Dá a uma rota body: upload({ … }) do @basaltkit/http e ela aceita multipart/form-data nos três frameworks, sem @fastify/multipart, multer nem o parseBody do Hono. O Basalt tem o seu próprio parser em stream, sem dependências e segundo o RFC 7578.

ts
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>
  },
})
  • O pipeline corre primeiro. Pre-hooks (rate limit, CORS), enrichers (tenant, utilizador) e guards (auth, can, …) correm todos antes de ser lido um único byte do body. Um upload rejeitado recebe resposta sem ter sido recebido.
  • Em stream, nunca em buffer. body.files é um iterável assíncrono. O stream de cada ficheiro só é lido da rede à medida que o consomes (com backpressure). Um ficheiro que saltes é descartado quando pedes o seguinte. body.fields vai sendo preenchido à medida que as partes chegam: um campo enviado antes de um ficheiro já está disponível quando esse ficheiro é entregue, e todos estão disponíveis quando files se esgota.
  • Os limites valem sobre os bytes recebidos, não sobre o que o cliente declara. Um Content-Length acima de maxBytes é recusado antes de ler seja o que for.
  • Os nomes de ficheiro são sanitizados: diretórios (../../x, C:\x), caracteres de controlo e overrides bidi são removidos, por isso filename é uma etiqueta segura. Mesmo assim, nunca é uma chave de armazenamento. declaredType é o que o cliente afirma, por isso faz sniffing dos bytes (validate.sniff do @basaltkit/files) antes de confiar nele.
  • Nada fica pendurado. Quando o handler retorna (ou lança) sem ler tudo, o resto é drenado em segundo plano até maxBytes e a resposta leva Connection: close.
  • Direto para o armazenamento. file.declaredLength é o Content-Length da própria parte, quando o cliente enviou um — passa-o a files.upload(file.stream, { contentLength }) para que um backend que precisa de um tamanho exato (S3) faça stream em vez de buffer. Sê honesto quanto a isto: o RFC 7578 não exige um Content-Length por parte e nenhum browser o envia, por isso normalmente é undefined. O Content-Length do pedido (body.contentLength) cobre todas as partes mais o enquadramento, por isso é um limite superior para um ficheiro, nunca o seu tamanho. Sem um tamanho declarado, o @basaltkit/files limita a escrita com validate.maxSize.
Opção de upload()PredefiniçãoAcima do limite
maxBytes (obrigatória)nenhuma413 PAYLOAD_TOO_LARGE, para o pedido inteiro incluindo o enquadramento multipart
maxFiles (obrigatória)nenhuma400 TOO_MANY_FILES
maxFileBytesmaxBytes413 PAYLOAD_TOO_LARGE
maxFields50400 TOO_MANY_FIELDS
maxFieldBytes64 KiB413 PAYLOAD_TOO_LARGE
maxHeaderBytes8 KiB (por parte)400 MALFORMED_MULTIPART
allowedTypesqualquer415 UNSUPPORTED_MEDIA_TYPE para uma parte de ficheiro cujo tipo declarado não está na lista (image/png, ou image/*)

Outros erros: 415 UNSUPPORTED_MEDIA_TYPE se o pedido não for multipart/form-data. 400 MALFORMED_MULTIPART para uma boundary em falta, repetida ou inválida, um body que acaba antes da boundary de fecho (upload truncado ou abortado), cabeçalhos de parte mal formados ou dobrados, uma parte multipart/* aninhada, ou um Content-Transfer-Encoding diferente de binary.

Cada adaptador limita-se a entregar o stream cru do pedido. O Fastify recebe um parser multipart/form-data de passagem, registado apenas quando existe uma rota de upload e nunca por cima de um que tenhas registado tu. As outras rotas Fastify continuam a responder 415. Os parsers json()/urlencoded() do Express nunca leem multipart. O Hono salta o buffer do bodyLimit para multipart; uma rota que não é de upload continua a analisar um body multipart dentro do bodyLimit. No OpenAPI, o body do pedido da rota fica documentado como multipart/form-data.

Corpos de pedido em bruto (assinaturas de webhook) ​

Há corpos que não podem ser analisados de todo. A Stripe, a Paddle, a Lemon Squeezy, a Dropbox, a Microsoft Graph e o GitHub assinam os octetos que enviaram, por isso uma assinatura só pode ser verificada contra esses bytes exactos. JSON.stringify do objecto analisado não é uma aproximação deles — espaços diferentes, ordem de chaves diferente, 1.50 reimpresso como 1.5 — e verificar contra isso falha em todas as entregas legítimas.

Dê a essa rota body: rawBody({ … }) de @basaltkit/http e ela recebe os bytes intactos nas três frameworks:

ts
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(),                                  // os bytes, descodificados como UTF-8
      request.headers['stripe-signature'] as string,
      process.env.STRIPE_WEBHOOK_SECRET!,
    )
    // body.bytes        → Buffer, exactamente o que chegou
    // body.contentType  → 'application/json' (essência, em minúsculas)
    // body.contentLength→ o que o cliente declarou, quando declarou
    return { received: true }
  },
})
  • O pipeline corre primeiro, tal como em upload(): pre-hooks (rate limit, CORS), enrichers e guards correm todos antes de ler um único byte do corpo. Um corpo que a rota nunca chega a ler é drenado e a resposta leva Connection: close, por isso nada fica pendurado.
  • Nada analisa os bytes — nem a Basalt, nem os parsers da própria aplicação. O handler recebe um Buffer e request.body fica undefined.
  • O limite vale sobre os bytes recebidos, não sobre o que o cliente declara. Um Content-Length acima de maxBytes é recusado antes de se ler o que quer que seja.
  • As rotas vizinhas não são afectadas. Uma rota rawBody() numa aplicação não muda como qualquer outra rota é analisada ou validada.
  • No OpenAPI o corpo do pedido é publicado como bytes opacos (*/*, format: binary) em vez de um esquema inventado.
Opção de rawBody()PredefiniçãoAo ultrapassar
maxBytes1 MiB413 PAYLOAD_TOO_LARGE, sobre o Content-Length declarado ou sobre os bytes recebidos

Outros erros: 400 BAD_REQUEST quando o corpo termina a meio (o cliente desligou) e 500 RAW_BODY_UNAVAILABLE quando o pedido declarou bytes (um Content-Length acima de zero, ou um Transfer-Encoding) e nenhum adaptador os conseguiu fornecer — uma recusa, deliberadamente, em vez de uma reconstrução.

Um pedido que não declarou corpo nenhum é um caso diferente: Content-Length: 0, ou nenhum header de framing, devolve um Buffer de comprimento zero. Isso é um facto sobre o pedido e não um palpite sobre uma mensagem — e é a forma com que vários fornecedores validam um URL de webhook: a Microsoft Graph faz POST de ?validationToken=… sem corpo nenhum, antes de a subscrição para a qual assinaria sequer existir. Recusar esses faria um problema de body-parser aparecer como subscriptionValidationFailed, apontando o operador para a coisa completamente errada.

O que cada adaptador faz — e a única ressalva ​

AdaptadorComo os bytes sobrevivemRessalva
FastifyAs rotas rawBody() são montadas num scope encapsulado próprio cujo único content-type parser entrega o stream do pedido por ler, para qualquer content type.Nenhuma. Os seus parsers (o JSON do adaptador, @fastify/multipart, qualquer um que tenha registado) nunca são removidos nem sobrepostos — continuam a servir todas as outras rotas, e um corpo não-JSON numa rota JSON continua a responder 415.
HonoA leitura limitada do plugin e os seus pre/after hooks afastam-se destes caminhos, por isso o stream do próprio Request web continua a levar os octetos.Nenhuma. O bodyLimit não se aplica à rota; aplica-se o maxBytes dela.
ExpressO expressPlugin dá ao express.json() e ao express.urlencoded() um filtro type que devolve falso para os caminhos rawBody(), por isso o body-parser nunca os lê, mais um hook verify que guarda o buffer como segunda linha de defesa. Ambos são instalados apenas quando existe uma rota rawBody().Uma, e é real — ver abaixo.

A ressalva do Express: o express.json() é montado sobre toda a aplicação, por isso se você trouxer a sua própria aplicação com o parser já montado, o body-parser consome o stream antes de qualquer rota Basalt correr e os bytes originais desaparecem. Dê-lhe o hook verify e eles sobrevivem:

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

A convenção comum verify: (req, _res, buf) => { req.rawBody = buf } também é honrada, por isso uma aplicação que já faça isso não precisa de mudar nada. Sem nenhuma das duas, a rota responde 500 RAW_BODY_UNAVAILABLE. É esse o objectivo: recusa em vez de verificar uma assinatura contra uma mensagem que ninguém enviou.

Respostas em stream ​

Um handler pode devolver um stream em vez de um payload JSON: stream(source, options) do @basaltkit/http é a resposta em stream neutra, e cada adaptador envia-a pelo seu próprio transporte sem nunca a guardar em buffer.

ts
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,   // omite quando é desconhecido — a resposta fica chunked
      filename: record.name,        // Content-Disposition: attachment, sanitizado
    })
  },
})

source é um Readable do Node, um ReadableStream web, ou qualquer AsyncIterable<Uint8Array>. Opções: contentType (predefinição application/octet-stream), contentLength, filename, disposition ('attachment' por omissão — um ficheiro HTML/SVG enviado por um utilizador nunca pode renderizar na tua origem), headers extra e status.

As mesmas garantias nos três ​

Isto não é "na medida do possível": uma única suite de paridade corre-as contra Fastify, Express e Hono, e um download de vários MiB é comparado byte a byte em cada um.

ComportamentoFastifyExpressHono
Como é enviadoreply.send(readable) (o caminho de stream do Fastify)pipeline(readable, res)Response sobre um ReadableStream web
Nunca em buffer, backpressure real — um cliente lento abranda a fonte✅✅✅
O cliente desliga-se → a fonte é destruída (sem descritor de ficheiro nem socket S3 pendurado)✅✅✅ (request.signal)
Erro antes do primeiro byte → corpo de erro JSON normal, cabeçalhos de stream retirados✅✅✅
Erro depois dos cabeçalhos → ligação cortada, nada acrescentado ao corpo parcial✅✅✅ (corpo em erro)
Essa falha tardia é reportada uma vez via onError (STREAM_FAILED, estado 500)✅✅✅
HEAD → os cabeçalhos que um GET levaria, sem corpo, fonte libertada sem ser lida✅✅✅
Content-Length / Content-Disposition (RFC 5987)✅✅✅

Os nomes de ficheiro são sanitizados

filename passa pelo mesmo sanitizeFilename() que o parser multipart usa — diretórios (../../x, C:\x), caracteres de controlo e overrides bidi são removidos — e é depois escrito como um filename= ASCII imprimível entre aspas mais um filename*=UTF-8''… do RFC 5987 quando algo se perdeu. Um nome vindo do cliente nunca consegue injetar um cabeçalho.

Não existe maxDurationMs

Ao contrário do sse(), um corpo em stream não tem limite de duração ao nível da framework — um download grande demora legitimamente muito tempo, e um limite truncá-lo-ia. Limita-o no servidor: fastifyPlugin({ fastify: { requestTimeout, connectionTimeout } }), o server.setTimeout() do Express, ou o limite do teu runtime no Hono.

meta: { etag: true } é ignorado para um corpo em stream — não há payload para fazer hash, e fazer hash do marcador responderia 304 para um corpo que nunca foi enviado.

Comportamento na rede — idêntico nos três ​

A mesma rota responde os mesmos bytes, seja qual for o adaptador que a serve. Uma suite de paridade partilhada (wireParitySuite nos testes do @basaltkit/http) obriga Fastify, Express e Hono a cumprir cada um destes pontos:

ComportamentoNos três adaptadores
Um handler devolve uma stringtext/plain; charset=utf-8 — nunca text/html. Para servir HTML, define-o: reply.header('content-type', 'text/html; charset=utf-8').send(html)
Que corpos são JSONapplication/json ou um tipo +json (application/merge-patch+json), ignorando parâmetros e maiúsculas. text/plain; application/json não é JSON — é CORS-safelisted, por isso uma página de outro site pode enviá-lo sem preflight
JSON malformado400 { "error": { "code": "BAD_REQUEST", "message": "Malformed request body." } }
Corpo JSON vaziorequest.body é undefined (sem corpo)
Limite de corpo por omissão1 MiB (DEFAULT_BODY_LIMIT) → 413 PAYLOAD_TOO_LARGE; bodyLimit no Express/Hono, fastify: { bodyLimit } no Fastify
Chave de query repetida?a=1&a=2 → { a: ['1', '2'] }; ?c[d]=1 → { 'c[d]': '1' } (sem objectos aninhados)
request.urlcaminho + query string (/items?x=1), nunca um URL absoluto
Routingsensível a maiúsculas, sem alias de barra final: /Items e /items/ não chegam a /items (na app que o expressPlugin cria; uma app Express tua mantém as suas definições)
Stream sse()mantém os headers de CORS, segurança, rate limit e x-request-id definidos antes dele; os headers saem logo, antes do primeiro evento
Um erro fora de uma rota (pre-hook, rota de edge, corpo)envelope JSON neutro, reportado via onError — o errorHandler do Express, o app.onError do Hono (errorHandler: false para sair)
Um erro de SDK externo com status/type500 INTERNAL_ERROR. Só erros lançados de propósito (HttpError) ou marcados expose: true escolhem o seu status
After-hooks (métricas, tracing)correm uma vez por pedido — também num stream sse() e numa resposta que o cliente abandonou; um que falhe é reportado como AFTER_HOOK_FAILED e nunca altera a resposta

Diferenças conhecidas que ficam, todas deliberadas ou inofensivas:

  • Outros tipos de corpo. Um corpo que não é JSON nem formulário chega ao handler como string no Hono e no Fastify para text/plain; o Fastify responde 415 a outros tipos; o Express deixa request.body undefined. Declara um schema de body e envia JSON — ou usa rawBody() para o resto.
  • Segmentos de ponto. O Hono (via o URL web) resolve /a/../admin para /admin; o Fastify e o Express encaminham o caminho tal como veio e respondem 404. Os pre-hooks e as rotas de cada adaptador vêem o mesmo caminho, por isso uma verificação de caminho não pode ser contornada — mas não dependas de nenhum dos comportamentos.
  • x-request-id é definido pelo pipeline da rota: está em todas as respostas que uma rota produziu e falta num 404 de um caminho desconhecido ou na resposta própria de um pre-hook (um 429, um preflight), nos três.
  • request.ip atrás de um proxy. Cada adaptador reporta o endereço do socket por omissão. Atrás de um proxy de confiança, activa-o explicitamente — Fastify fastify: { trustProxy }, Express app.set('trust proxy', …) numa app que passes, Hono getClientIp — senão todos os clientes partilham o IP do proxy (e um só bucket de rate limit).
  • /metrics e /openapi.json são públicos: as rotas de edge saltam enrichers e guards. Mantém-nas fora do listener público, ou põe um pre-hook à frente.

Como funciona ​

  • @basaltkit/http define os neutros HttpRequest / HttpReply e o pipeline runRoute. Os enrichers e guards (tenancy, auth, permissões) registam-se nos buckets de metadata http:enrichers / http:guards — são agnósticos à framework e cada adaptador corre-os.
  • Cada adaptador mapeia o request/response da sua framework para o formato neutro, invoca runRoute, e mapeia os erros lançados com o partilhado toErrorResponse — por isso uma falha de validação é 400 HTTP_VALIDATION e um HttpError(404) é um 404 com o mesmo corpo nos três. Rotas não correspondidas recebem o mesmo tratamento: todos os adapters servem o neutro 404 { "error": { "code": "NOT_FOUND", … } } em vez do default da sua framework (desativa com notFound: false no plugin do adapter). Um payload estruturado (new HttpError(422, code, message, { details })) é sanitizado e serializado como error.details pelo mesmo serializador neutro, por isso é idêntico nos três — vê Detalhes estruturados de erro.
  • O request / reply do handler são os tipos neutros; alcança o objeto subjacente da framework via request.raw quando realmente precisares.

Referência de opções ​

Os três plugins partilham as mesmas opções centrais; cada um aceita os extras nativos da sua framework.

OpçãoTipoDefaultAdaptersPorquê
routesBasaltRoute[][]todosAs rotas neutras a montar.
allowUnguardedMetaboolean | string[]falha alto no boottodosDispensa o check de boot de que cada rota que declara uma chave de segurança guardada (meta.auth/can/teamRole/scopes/subscribed/feature) tem um guard registado a aplicá-la (UnguardedRouteMetaError caso contrário). Só para deployments onde a proteção acontece genuinamente numa edge exterior. Nunca dispensa os validadores de meta de rota (InvalidRouteMetaError).
notFoundbooleantrue (corpo 404 neutro)todosPassa false para sair do 404 { error: { code: 'NOT_FOUND' } } partilhado e manter o default da framework.
fastifyFastifyServerOptions{}fastifyPassado ao construtor Fastify() (logger, trustProxy, …).
appinstância nativacriada por ti ou pelo pluginexpress, honoTraz o teu próprio express() / new Hono() e o Basalt monta-se nele.
bodyLimitnumber (bytes)1 MiBexpress, honoRejeita bodies grandes demais com 413 (PAYLOAD_TOO_LARGE) — o mesmo default do próprio Fastify. No Express é o limite dos parsers JSON/formulário (o body-parser sozinho parava nos 100 KiB). No Hono — que não tem limite por omissão — é aplicado aos bytes efectivamente lidos: um body chunked/em stream sem Content-Length é contado durante a leitura e cortado no limite. Uma rota upload() é limitada pelo seu próprio maxBytes (em stream, nunca em buffer).
getClientIp(c: Context) => string | undefinedendereço do socket (@hono/node-server, Bun)honoDefine request.ip, a chave do rate limiting por cliente e do throttle de login por IP. Num runtime edge ou atrás de um proxy de confiança, fornece-o (ex.: (c) => c.req.header('cf-connecting-ip') na Cloudflare). Quando nenhum IP é resolvido, é emitido um aviso único e os rate limits partilham um só bucket. Nunca leias X-Forwarded-For a não ser que um proxy teu o reescreva.
errorHandlerbooleantrueexpress, honoHono: um app.onError que responde a erros levantados fora de uma rota (pre-hook, rota de edge, corpo) com o envelope JSON neutro e os reporta, em vez de um 500 em texto (uma HTTPException do teu próprio middleware mantém a sua resposta). Express: middleware final (err, req, res, next) que transforma erros do body-parser e dos pre-hooks no envelope JSON neutro (400 BAD_REQUEST, 413 PAYLOAD_TOO_LARGE, 415 UNSUPPORTED_MEDIA_TYPE, caso contrário 500 INTERNAL_ERROR) em vez da página HTML do Express com stack trace. Passa false só se montares o teu próprio error handler depois do boot.

Modos de falha ​

VêsSignificaFaz
UnguardedRouteMetaError no bootuma rota declara meta de segurança que nenhum guard registado aplicaregista o plugin que a aplica, ou allowUnguardedMeta (vê Segurança)
InvalidRouteMetaError no booto validador de meta de rota de um plugin recusou um valor (ex.: um meta.teamRole desconhecido)corrige o valor; o allowUnguardedMeta não o dispensa (vê Segurança)
500 HTTP_GUARDS_UNRUNNABLEo pipeline da rota tem guards mas não tem container, por isso nenhum deles pôde correrpassa container ao pipeline — todos os adapters do kit passam; só pipelines feitos à mão chegam aqui
400 HTTP_VALIDATIONo body/query/params falhou o schema Zod da rotaa resposta lista a parte e as issues por campo
404 { code: 'NOT_FOUND' } numa rota que definistea rota não foi registada nesta instância do adapterconfirma que está em routes: [...] do plugin do adapter que arrancou
413 PAYLOAD_TOO_LARGEo body excedeu o limite de corpo (1 MiB por omissão nos três)sobe o bodyLimit (fastify: { bodyLimit } no Fastify) deliberadamente
400 BAD_REQUESTo body não pôde ser interpretado (JSON malformado; no Express também uma codificação corrompida)envia um body válido
400 HTTP_VALIDATION para um corpo JSON que enviasteo Content-Type não é application/json nem +json (ex.: text/plain)envia um media type JSON
400 MALFORMED_MULTIPART / TOO_MANY_FILES, 413, 415 numa rota upload()o upload excedeu um limite ou violou o enquadramento multipartvê Uploads
Aviso [basalt:hono] Could not resolve the client IPeste runtime não expõe o endereço do socket ao adaptadorpassa honoPlugin({ getClientIp })

Os plugins de edge também são neutros ​

Os plugins de edge visam um HttpServer neutro (o token HTTP_SERVER, que cada adaptador fornece), por isso correm nas três frameworks sem alterações: securityPlugin, metricsPlugin, healthPlugin, tracingPlugin e openapiPlugin. Adiciona-os a plugins: [...] ao lado de qualquer adaptador.

ts
createApp({
  plugins: [
    expressPlugin({ routes }),          // ou fastifyPlugin / honoPlugin
    securityPlugin({ rateLimit, cors, headers: true }),
    healthPlugin({ checks }),
    metricsPlugin(),
    tracingPlugin({ exporter }),
    openapiPlugin({ info }),
  ],
})

A única exceção é o idempotencyPlugin, que interceta o corpo da resposta — esse permanece específico do Fastify por agora.

Publicado sob a licença MIT.