http
http() is overloaded by option shape:
http({ path, method?, mount? })returns a Source. Use with.from(...)to expose a route over HTTP. RequiresdefineConfig({ servers: {...}, http: {...} }): the named server owns the listener (host, port), thehttpkey owns routing and authentication. Bun runtimes bind viaBun.servenatively; Node 22+ uses a thinnode:httpshim. Zero runtime dependencies.http({ url, ... })returns an Enricher (a pull-in). Use with.to()/.enrich()/.tap()to call a remote HTTP endpoint.
The discriminator is the presence of path (source) vs url (client).
HTTP source (inbound)
http(options: HttpServerOptions): Source<HttpRequestBody>
The listener (host, port) lives on defineConfig({ servers }) and authentication on the server and its mounts, not on the source. Routes only declare which request they want and which mount they live on. This example walls /api behind the server's JWT validator and keeps the catch-all mount public:
// craft.config.ts
import { defineConfig, jwt } from '@routecraft/routecraft'
export const craftConfig = defineConfig({
servers: {
default: {
port: 8080,
host: '0.0.0.0',
auth: jwt({
secret: process.env.JWT_SECRET!,
issuer: process.env.JWT_ISSUER!,
audience: process.env.JWT_AUDIENCE!,
}),
},
},
http: {
mounts: {
api: { path: '/api' }, // inherits the server wall
default: { path: '/', auth: false }, // public catch-all
},
},
})
// routes/orders.ts
import { craft, http, noop, DefaultExchange } from '@routecraft/routecraft'
// GET /api/orders/:id (walled: the api mount inherits the server validator)
export const getOrder = craft()
.id('get-order')
.description('Fetch an order by id')
.from(http({ mount: 'api', path: '/orders/:id', method: 'GET' }))
.process(async (ex) => {
const { id } = ex.headers['routecraft.http.params']!
return DefaultExchange.rewrap(ex, { body: await loadOrder(id) })
})
.to(noop())
// POST /api/orders
export const createOrder = craft()
.id('create-order')
.description('Create an order')
.input({ body: createOrderSchema })
.authorize({ scopes: ['orders.write'] })
.from(http({ mount: 'api', path: '/orders', method: 'POST' }))
.transform((body) => saveOrder(body))
.to(noop())
// DELETE /api/orders/:id -> 204 when body is undefined
export const deleteOrder = craft()
.id('delete-order')
.authorize({ roles: ['admin'] })
.from(http({ mount: 'api', path: '/orders/:id', method: 'DELETE' }))
.process(async (ex) => {
await deleteOrderById(ex.headers['routecraft.http.params']!.id)
return DefaultExchange.rewrap(ex, { body: undefined })
})
.to(noop())
// Public endpoint: lives on a public mount (see Mounts below), so the
// credential is never inspected and no auth events fire.
export const health = craft()
.id('health-extra')
.from(http({ path: '/health-extra', method: 'GET' }))
.transform(() => ({ status: 'ok' }))
.to(noop())
// Identity-demanding endpoint on a public mount: .authorize() forces
// verification through the server validator for exactly this route.
export const account = craft()
.id('account')
.authorize({})
.from(http({ path: '/account', method: 'GET' }))
.process(async (ex) =>
DefaultExchange.rewrap(ex, { body: `hello, ${ex.principal?.subject}` }),
)
.to(noop())
Source options (http(options) with .from(...)):
Request metadata on the exchange
routecraft.http.method-- request method (typedHttpMethod).routecraft.http.path-- matched pattern (e.g./orders/:id).routecraft.http.url-- raw request URL (path + query).routecraft.http.params--Record<string, string>of URL-decoded path params.routecraft.http.query--Record<string, string>of query params.routecraft.http.rawHeaders--Record<string, string>of the raw request headers, lower-cased. This is the open-ended pass-through wire-header remainder (the parsed envelope above is promoted to its own keys); it mirrorsroutecraft.mail.rawHeaders.routecraft.http.rawBody--Uint8Arrayof the exact wire bytes of the request body. Only present when the route opted in viahttp({ rawBody: true }); empty-body requests carry an empty array. Opt-in because of retention and exposure, not cost: the bytes already exist in memory during parsing, but attaching them pins a buffer of up tomaxBodySizefor the exchange lifetime and surfaces raw payload bytes to anything that logs the exchange headers.routecraft.auth.principal-- the authenticatedPrincipal(when auth is configured).ex.principalis sugar over this header.
Request body parsing (driven by Content-Type)
application/json-> parsed object.text/*-> string.application/x-www-form-urlencoded-> object built fromURLSearchParams.multipart/form-data-> WebFormData(withFileentries for uploads).- anything else ->
Uint8Array.
Cap controlled by http: { maxBodySize?: number } (default 10 MB). Larger requests return 413 Payload Too Large.
Response convention (deterministic, override via exchange headers)
undefined/null->204 No Content.- string ->
200,Content-Type: text/plain; charset=utf-8. Uint8Array/ArrayBuffer->200,Content-Type: application/octet-stream.- object / array ->
200,Content-Type: application/json; charset=utf-8. AsyncIterable->200, streamed as Server-Sent Events. See Streaming responses.ReadableStream->200, streamed as raw chunked bytes with no framing.
Override via the exchange before the response is built:
routecraft.http.response.status-> numeric status (e.g.201).routecraft.http.response.contentType-> explicit content-type.routecraft.http.response.headers-> extra response headers.
Streaming responses (SSE)
A route whose final body is an AsyncIterable answers with
Content-Type: text/event-stream; charset=utf-8 and Cache-Control: no-cache,
and each yielded value becomes one SSE frame. There is no sse() adapter and
no separate mode: SSE is an HTTP response, so it is one more row in the table
above and the whole pre-from chain still applies once, at request entry.
craft()
.id("order-events")
.from(http({ path: "/orders/:id/events", method: "GET" }))
.transform(async function* (_, ex) {
const { id } = ex.headers["routecraft.http.params"]!;
for await (const update of watchOrder(id)) {
yield { event: "update", id: update.sequence, data: update };
}
})
.to(noop());
What each yielded value becomes:
data that is not a string is JSON-encoded; a string with newlines becomes
several data: lines, per the spec. retry is sent only when it is a whole
number of milliseconds. Newlines in event and id are stripped, because the
grammar has no escape for them and a value carrying one could otherwise close
the frame and forge the next.
The stream opens with a : open comment. Neither runtime puts the status line
on the wire before the body's first chunk, so without it a stream that is quiet
to begin with leaves the client's fetch unresolved and an EventSource
without its open event. Conforming clients discard comments.
Resilience operations apply up to the first byte, and no further. Once the
status line is sent, the response cannot be replayed or replaced: retry()
cannot re-run a half-delivered stream, and timeout() bounds time to first
byte rather than the life of the stream. A stream that fails midway ends
unterminated and the failure is logged; there is no status left to change it
to. Put per-item error handling inside the producer, not around the route.
Raw streams and other formats
A ReadableStream is already bytes: it passes through unframed with
Content-Type: application/octet-stream and no cache directive, and the caller
owns every header. Reach for it when the route has its own wire format.
Framing follows the body type, not the content type. Overriding
routecraft.http.response.contentType to application/x-ndjson changes the
header, not what an iterable of objects turns into, so a route serving ndjson
yields strings (which pass through verbatim) and writes its own newlines.
Reconnection
A reconnecting EventSource sends the Last-Event-ID header, which arrives on
the exchange like any other request header, at
routecraft.http.rawHeaders["last-event-id"]. Replay is the route's own
business: only the route knows what its id values mean and how far back it
can rewind. Set id on the frames you want a client to be able to resume from.
Bounding open streams
A streaming response is exempt from the listener's idle reaper, which is otherwise the only limit on how long a connection may sit open. Two options on the server definition put a ceiling back:
servers: {
public: {
port: 3000,
shutdownGrace: "30s",
idleTimeout: "255s", // reap window for ordinary connections
maxStreamingRequests: 500, // streams this listener carries at once
},
}
idleTimeout takes a Duration and defaults
to "255s", the value that was previously fixed. That is Bun's maximum for a
per-connection idle timeout, so a larger value is refused at construction
naming the limit rather than being clamped: a config honoured on Node and
capped on Bun would mean two different things depending on where it ran.
maxStreamingRequests defaults to 500 and answers 503 with Retry-After
once the listener is full. It is a backstop, not a capacity plan. Without it a
client that opens streams and never reads them walks the process to its
file-descriptor limit, taking every other in-flight request with it; the cap
sits below that cliff so an operator gets a clean refusal instead. The number
is the same kind of number as maxBodySize's 10 MB, which nobody mistakes for
the right body size.
It counts requests that claim a streaming slot, which is how a route's streaming body is admitted here and how the ops event tail is admitted on its mount. A surface that declares itself long-lived for every request instead, which today means the MCP mount, opts out at mount level and is not counted. So the cap bounds the streams this listener admits per request rather than every long-lived connection it carries: size it against the other surfaces sharing the same listener rather than treating it as the total.
This is the listener's bound, not a route's.
.concurrency({ max, mode: "reject" })
is the per-route bound an author opts into, with that route's own semantics.
The two are complements: the route operation governs a route that chooses it,
and the listener cap protects the process whatever any route declares,
including the routes that never thought about it. Reach for .concurrency()
to shape one endpoint's admission, and leave maxStreamingRequests as the
floor nothing falls through.
Authority does not outlive the credential
Admission is checked once, when the request arrives. On an ordinary request that covers the whole request; on a stream held open for hours it would otherwise cover an unbounded window.
So a stream admitted on a principal carrying an expiry closes when that expiry
passes. There is no attempt at a 401, because one cannot follow a 200 that
is already on the wire. Closing is enough: an EventSource reconnects by
specification, the reconnect meets ordinary admission, and the refusal comes
from the path that already answers it. A principal with no expiry is left
alone, since a credential that does not expire granting a stream that does not
expire is the operator's choice honoured rather than a gap.
Authenticating a browser client
EventSource cannot set an Authorization header, so a browser consumer
authenticates with apiKey in query
mode:
http: { auth: apiKey({ in: "query", name: "api_key" }) }
A query string is not a private channel. It lands in access logs, proxy logs and browser history, and it is visible to anything that sees the URL. Issue credentials for this channel short-lived and narrowly scoped, and rely on the expiry behaviour above to close the stream when they lapse so the client reconnects with a fresh one.
Keeping a stream alive
A quiet stream has two natural enemies. The listener's own idle reaper is
handled: a streaming response claims a per-request slot that exempts it, so it
is not cut short, and that slot is what maxStreamingRequests counts. Intermediaries between the client and the process are not, so a route
that can go minutes without an event should yield an SSE comment periodically.
Behind nginx, proxy_buffering off (or an X-Accel-Buffering: no response
header via the override) stops the proxy holding frames back.
Open streams end when the context begins stopping, so a graceful shutdown does
not wait out its whole grace window on connections that would have closed on
request. plugin:http:request:completed fires when the stream closes, with
status: 200 and a durationMs spanning request receipt to stream end.
Observing a disconnect
A client that disconnects cancels the route's iterator, visible as a return()
delivered into a generator's finally block or as a for await loop exiting.
That return is delivered late for a producer deferred on an inner await.
Async iteration settles a pending step before it delivers a return, so a
generator sitting on await nextRowFromTheDatabase() learns nothing about the
disconnect until that await resolves on its own. A source that can be quiet for
minutes therefore holds the run, and the connection's resources, for as long as
its own next item takes.
The fix is the same periodic wake that keeps the connection alive, so one
mechanism buys both. Race the slow source against a timer and yield an SSE
comment when the timer wins: the loop reaches its next yield, which is where a
pending return() lands. Hold the source's promise across the race rather than
starting a fresh one each pass, or a keep-alive tick abandons the event that was
already in flight.
const KEEP_ALIVE = Symbol("keep-alive");
function withWake<T>(pending: Promise<T>, ms: number): Promise<T | typeof KEEP_ALIVE> {
let timer: ReturnType<typeof setTimeout> | undefined;
const wake = new Promise<typeof KEEP_ALIVE>((resolve) => {
timer = setTimeout(() => resolve(KEEP_ALIVE), ms);
});
return Promise.race([pending, wake]).finally(() => clearTimeout(timer));
}
craft()
.id("slow-feed")
.from(http({ path: "/feed", method: "GET" }))
.transform(async function* () {
let pending = nextEvent();
for (;;) {
const next = await withWake(pending, 15_000);
if (next === KEEP_ALIVE) {
yield ": keep-alive\n\n";
continue;
}
pending = nextEvent();
yield { event: "update", data: next };
}
})
.to(noop());
The interval bounds how long a disconnected client's run survives it, so pick it for the reap you are avoiding rather than making it as long as an intermediary allows. The ops event tail is built this way. Neither shipped consumer is exposed: an agent stream aborts its run from inside the adapter, and the tail's keep-alive settles its own await.
Built-in endpoints
Registered alongside user routes; user routes with the same path always win.
GET /health->200{ status: "ok" }. A constant: it reports that the listener is up and nothing else.GET /ready->200{ status: "ready", routes }for authenticated callers;{ status: "ready" }for anonymous callers when globalauthis configured. Also a constant, which is to say it cannot go red.
These two never fail, so neither can tell an orchestrator to restart or derotate anything. For probes that answer from real route, breaker and dependency state, use the ops plugin, which serves /health, /health/live and /health/ready. Enabling it on the same server stands the /health built-in down and answers that path with the real report.
GET /openapi.json-> OpenAPI 3.1 document built from the route registry. Paths, methods, summaries, descriptions, and path params populate in v1; request/response body schemas are stubs until the Standard-Schema-to-JSON-Schema follow-up lands.
Configuring built-ins
Every built-in takes the same { enabled?, requireAuth? } shape under http: { builtins }. Inspired by Spring Boot Actuator's management.endpoint.<name>.enabled plus show-details: when-authorized, compressed to a single boolean for the auth gate.
defineConfig({
servers: { default: { port: 8080 } },
http: {
auth: jwt({ ... }),
builtins: {
health: { enabled: true }, // defaults
ready: { enabled: true, requireAuth: true },
openapi: { enabled: true, requireAuth: false },
},
},
})
What requireAuth does, per endpoint:
Defaults match security best practice per endpoint:
health:enabled: true(k8s liveness must be open).ready:enabled: true, requireAuth: true(gates theroutescount from anonymous callers; matches Spring Actuator's default).openapi:enabled: true, requireAuth: false(matches the Stripe / GitHub / Twilio / OpenAI convention of publishing the schema publicly).
enabled: false returns 404 for that path. requireAuth has no effect when no global auth is configured (collapses to false because there is nothing to authenticate against).
OpenAPI info block
builtins.openapi.info populates the OpenAPI document's info object. When omitted, title and version auto-detect from the nearest package.json (walks up from process.cwd()); supply either field explicitly to override.
builtins: {
openapi: {
info: {
title: "Orders API", // overrides package.json `name`
version: "1.2.3", // overrides package.json `version`
description: "Customer order management.",
contact: { name: "Platform Team", email: "platform@example.com" },
license: { name: "MIT", url: "https://opensource.org/license/mit" },
},
},
},
Auto-detection is conservative: only name and version are pulled because both are public by nature once a package is published to npm. description, contact, and license stay opt-in because package.json often carries internal context (TODO notes, author emails, license boilerplate) you may not want leaking through a publicly served document. Set them explicitly to publish them. When no package.json is reachable (single-file bundled binaries, Docker scratch images), the document falls back to Routecraft HTTP API / 0.0.0.
Workspace containers are excluded from auto-detection. If the nearest package.json is a monorepo root (it declares a workspaces field, or a pnpm-workspace.yaml sits beside it), the walk yields nothing and the neutral fallbacks apply. A workspace container is repository infrastructure, not a service identity: it is typically private, and release tooling never versions it, so its version silently goes stale. Running an app from a monorepo root therefore serves Routecraft HTTP API / 0.0.0 until you set builtins.openapi.info (or run from the app's own directory). A private: true manifest without workspaces is still used; an unpublished app's own name and version is exactly the identity its document should carry.
Auth
http: { auth } accepts:
jwt({...})/jwks({...})-- bearer token with validator (same shape MCP uses).apiKey({ keys: [...] })-- static allowlist. Reads from a header (defaultx-api-key) or, within: "query", a query parameter (defaultapi_key).apiKey({ verify: (key) => Principal | null })-- custom verifier that resolves to a per-user principal.apiKey({ keys: [...], scopes: [...] })-- scopes granted to every caller the static allowlist admits, so a scope check (an ops management tier, or a route's.authorize({ scopes })) can pass without an identity provider. Every listed key carries the same scopes; a flat allowlist has nowhere to hang per-key authority. Refused alongsideverifywithRC5003, because a verifier's returned principal already declares what it carries.
Verification runs at most once per incoming request, and only when the mount posture demands it (see Mounts and authentication below). When admitted, the resolved Principal lands on the exchange (routecraft.auth.principal), and per-route guards via the existing .authorize({ roles, scopes, predicate }) builder take it from there.
API-key name matching follows each location's convention: header names are case-insensitive (per HTTP), so the name is matched case-insensitively; query parameter names are case-sensitive (per the URL spec), so the name must match exactly. Note the default name differs by location: x-api-key for headers, api_key for query.
OAuth 2.1 is reserved in the auth union for a future release.
Mounts and authentication
The mount, not the route, decides authentication. http.mounts declares named path-scoped surfaces, each a complete self-description (path, server, auth); each is a wall or public, and routes belong to exactly one:
defineConfig({
servers: { default: { port: 8080, auth: jwks({ ... }) } },
http: {
mounts: {
api: { path: '/api' }, // inherits the server wall
default: { path: '/', auth: false }, // public catch-all
},
},
})
Rules that keep this safe:
auth: falsemeans no wall, never no validator: it removes the demand while keeping the inherited validator reachable for routes that pull..authorize()can only make a route stricter, never looser; there is no route-level way to weaken a mount's wall.- Route
paths are relative to the mount prefix, so a route'/orders/:id'on theapimount serves/api/orders/:id. - An omitted
mountresolves only to a mount literally nameddefault; with mounts configured and none nameddefault, the route fails loudly instead of landing on an unintended surface. - A route cannot be carved out under another mount's prefix: a
default-mount route resolving inside/apifails bind-time claim validation. - Mount
paths are static canonical pathname prefixes: no?,#,:paramsegments, empty segments, backslashes, or percent-encoding, and no.or..segments, spaces, or anything else URL parsing would rewrite into a different pathname. Anything else is refused at construction with RC5003. - The top-level
http: { auth }remains as sugar for a singledefaultmount at/.
The identity-when-present personalisation pattern (formerly auth: "optional") has no mount-level equivalent: a route is walled, identity-demanding via .authorize(), or credential-blind.
Mounts and servers
Each mount also names its listener: server defaults to 'default', so an app with one listener names it default and never writes the key. A mount on another listener says so itself; there is no plugin-level default to override, the same rule the single-mount shorthand follows (with no mounts, the top level of http is the one mount, and server there is its key).
defineConfig({
servers: {
default: { port: 8080, host: '0.0.0.0' },
internal: { port: 9090 },
},
http: {
mounts: {
api: { path: '/api' }, // servers.default
admin: { path: '/admin', server: 'internal' }, // its own listener
},
},
})
Two mounts may claim the same path on different listeners; the same path on one listener is refused at construction. The built-ins (/health, /ready, /openapi.json) serve from the mount named default at / and describe only routes on that mount's server, so an internal listener's inventory never leaks through the public OpenAPI document. The /health built-in also stands down for an ops mount on the same listener only; an ops surface on another listener leaves this mount's /health serving its own constant body.
Signed webhooks
Webhook providers (GitHub, Stripe, and others) HMAC-sign the exact bytes they POST. Re-serialising the parsed body is not byte-faithful (key order, whitespace, and unicode escaping all differ), so verification needs the raw wire bytes. The source covers this two ways.
Built-in verification (preferred). Declare the check on the source and the plugin verifies the raw bytes before any route step runs. A missing, invalid, or expired signature returns 401 { error: "unauthorized", reason } and emits auth:rejected with scheme: "signature". Comparison is timing-safe.
// GitHub: X-Hub-Signature-256 = "sha256=<hmac-sha256-hex>"
export const githubHook = craft()
.id('github-hook')
// On a public mount: the signature IS the credential.
.from(http({
path: '/hooks/github',
method: 'POST',
signature: {
header: 'x-hub-signature-256',
secret: process.env.GITHUB_WEBHOOK_SECRET!,
scheme: 'hmac-sha256-hex',
prefix: 'sha256=',
},
}))
.transform((event) => handlePush(event))
.to(noop())
// Stripe: Stripe-Signature = "t=<unix>,v1=<hmac-sha256-hex over `t.body`>"
export const stripeHook = craft()
.id('stripe-hook')
.from(http({
path: '/hooks/stripe',
method: 'POST',
signature: {
header: 'stripe-signature',
secret: process.env.STRIPE_WEBHOOK_SECRET!,
scheme: 'stripe-timestamped',
// toleranceSec: 300 (default) bounds replay of captured deliveries
},
}))
.transform((event) => handlePaymentEvent(event))
.to(noop())
// Standard Webhooks (Resend, Bird, Svix and anything else on the spec):
// webhook-id, webhook-timestamp and webhook-signature, signed over
// `<id>.<timestamp>.<body>`. The spec fixes the header names, so there is
// nothing to configure but the secret.
export const birdHook = craft()
.id('bird-whatsapp-inbound')
.from(http({
path: '/hooks/bird',
method: 'POST',
signature: {
scheme: 'standard-webhooks',
secret: process.env.BIRD_WEBHOOK_SECRET!,
},
}))
.transform((event) => toInboundMessage(event))
.to(direct('agent'))
Schemes: "hmac-sha256-hex" (hex HMAC-SHA256, optional prefix such as GitHub's sha256=), "hmac-sha1-hex" (legacy providers; prefer sha256 when offered), "stripe-timestamped" (t=<unix>,v1=<hex> with freshness checking; expired timestamps reject with reason signature expired), and "standard-webhooks" (the specification, used by Resend, Bird and Svix). The rejection reasons are a bounded vocabulary: missing signature header, invalid signature, signature expired.
"standard-webhooks" differs from the other three in what it needs from you, because the specification fixes what they leave open:
- There is no
headerto set. The specification fixes all three names (webhook-signature,webhook-id,webhook-timestamp), so this scheme takes a secret and nothing else, and the option type refuses a header on it.headerstays required for the other three schemes, which fix no names of their own. - The secret is base64. Pass it as the provider shows it,
whsec_prefix and all; the prefix is stripped and the rest decoded to the signing key. Padding is optional. A secret that is not base64 fails at thehttp({...})call site rather than on the first delivery. - Several signatures may arrive at once, space separated, which is what key rotation looks like. Any
v1entry that matches admits the delivery. toleranceSecworks as it does for Stripe, defaulting to 300 seconds, and a delivery outside it rejectssignature expired. A delivery missingwebhook-idorwebhook-timestamprejectsmissing signature header: nothing was presented to verify.- A passing signature authenticates the sending system, not a person. The gate admits the request and attaches no principal. Whatever the payload says about an end user is the sender's claim, so treat it as identification and not as authentication.
- Redeliveries inside the tolerance window are yours to deduplicate. The window bounds replay by age, and the specification's answer to the rest is
webhook-id, which is stable across retries. Read it fromroutecraft.http.rawHeaders['webhook-id']when a duplicate delivery would do damage; the raw headers are on every HTTP exchange and need no opt-in, so deduplicating costs you nothing extra to retain. prefixis ignored, as it is forstripe-timestamped.
This covers the symmetric half of the specification. Asymmetric signatures (v1a, ed25519, whpk_ keys) are not verified, and a v1a entry is skipped like any other unsupported version. Note also that Svix sends svix-id, svix-timestamp and svix-signature unless the sender is on a tier that white-labels them to the webhook- prefix. A svix- delivery does not verify through this option: use the manual escape hatch below for one, or ask the sender to white-label its headers. The scheme deliberately offers no per-header override, since renaming only the signature header would build a route that constructs cleanly and then rejects every delivery.
Rules enforced at construction (RC5003 from the http({...}) call site): signature requires a body-bearing method (POST, PUT, PATCH), a non-empty secret, and a known scheme. At request time, oversized bodies still return 413 before any signature computation, and an empty body on a signature-gated route is verified rather than waved through. The gate is independent of the mount wall; webhook endpoints typically live on a public mount, since the signature is the credential.
Manual verification (escape hatch). For providers whose scheme is not built in, opt in to the raw bytes and verify in a route step. Note the semantics differ from the built-in gate: .filter() drops an unsigned or invalid delivery (the exchange ends with a route:exchange:dropped event and the sender receives the route's normal empty response), it does not return 401 or raise RC5039. That is usually fine for webhooks, since providers only distinguish 2xx from non-2xx, but if you need an explicit 401 use the built-in signature option or set the response status yourself before dropping.
.from(http({ path: '/hooks/custom', method: 'POST', rawBody: true }))
.filter((ex) => {
const signature = ex.headers['routecraft.http.rawHeaders']?.['x-custom-signature']
if (!signature) return { reason: 'missing x-custom-signature header' }
return verifyMySignature(ex.headers['routecraft.http.rawBody']!, signature)
})
RC5039 applies only to the built-in signature gate; it is documented on the errors reference.
Acknowledge, then process
A webhook sender is not waiting for your pipeline. Bird treats anything past a few seconds as a failed delivery and redelivers under the same webhook-id for about 27 hours; Stripe and Svix behave the same way; the Standard Webhooks specification says outright to acknowledge before processing. A route that answers with its result is therefore wrong for a webhook whose work takes tens of seconds, such as one that leads to a model turn.
respond is a function that decides when the caller is answered, and with what. It is called once per request, after every gate has passed and after the pipeline has been started, and the response is sent when it returns. So the function itself decides whether the caller waits:
// Acknowledge and process. `finished` is never touched, so the answer goes
// out now and the pipeline keeps running.
respond: () => ({ status: 202 })
// Answer with the result. This is what the framework does when `respond` is
// omitted, written out.
respond: async ({ finished }) => ({ status: 200, body: (await finished).body })
// Decide per request. A descriptor is sent as soon as it is returned;
// `undefined` defers to the pipeline and answers with its result.
respond: ({ request }) => isPing(request.body) ? { status: 202 } : undefined
The webhook case in full:
export const birdHook = craft()
.id('bird-hook')
// Route scope: both sit before .from(), so they bound and guard the whole
// detached pipeline. maxQueue is the part that bounds ADMISSION; without
// it the wait line grows without limit (see Bounding the work below).
.error((error) => reportDeliveryFailure(error))
.concurrency({ max: 8, maxQueue: 100 })
.from(http({
path: '/hooks/bird',
method: 'POST',
signature: { scheme: 'standard-webhooks', secret: process.env.BIRD_WEBHOOK_SECRET! },
respond: () => ({ status: 202 }),
}))
.process(async (ex) => {
await handleDelivery(ex)
return ex
})
.to(noop())
The responder receives { request, finished }. request is what the route sees: the parsed body, the lower-cased headers, the path params, the method, the matched pattern, and the principal where one was admitted. It is deliberately not the Request, whose body has already been read to parse it and to verify the signature and so cannot be read again. finished is the promise of the completed exchange, already running; the framework has claimed its rejection, so ignoring it is safe.
It returns a descriptor, { status, headers?, body? }, which the dispatcher serialises by the same rules it applies to a pipeline result. Omit body for an empty response, which is what a bare acknowledgement wants. Returning undefined instead of a descriptor defers to the pipeline: the dispatcher waits for finished and answers with its result, exactly as when the option is absent.
status is required and must be an integer between 200 and 599; anything else is refused rather than passed to the platform, because an omitted status would otherwise be a silent 200 that a webhook sender reads as an acknowledgement. A status that forbids a body (204, 205, 304, 101) sends none, so the same responder behaves identically under Bun and Node rather than serving on one and failing on the other. A responder that throws answers 500, and the pipeline it already started keeps running, so the sender's redelivery will duplicate it.
Every gate still runs before the responder is called: path and method matching, the body size limit, body parsing, signature verification, and the mount's credential check where it has one. Rejections are unchanged, so a bad signature is still a 401, the sender still sees it, and the responder never runs.
What changes, once a responder answers before the pipeline finishes, is where a failure goes. The response is gone by then, so a failure reaches the route's .error() handler and the ordinary error events (route:error, context:error, route:exchange:failed), and nowhere else. Give such a route an .error() handler; without one the failure is visible only to an event listener and the log.
A graceful shutdown still waits for a detached run. The run is the route's in-flight work from the moment it starts, and the context drains that work before any listener closes, bounded by the same shutdown deadline as everything else. Once shutdown has begun a responder is not called at all: the request answers 503 with retry-after rather than an answer this process cannot promise to honour, and the sender redelivers to the next instance.
Two refusals, both wider than they look. Nothing can tell before calling a responder whether it will await the pipeline, because that is decided inside the function. Both guards therefore cover every responder, including one that would have awaited and been perfectly safe:
- A responder on a route that also uses
.batch()is refused withRC5003at subscribe. A batched message waits in the buffer instead of running, so it is not in-flight work and a graceful shutdown discards it, while the responder has already answered. - Once shutdown has begun, every responder gets the
503above, including one that would have awaited.
Bounding the work, and bounding the right thing. Answering early removes the backpressure the caller's own wait used to provide: arrival rate is now entirely the sender's, and a provider replaying a day of backlog hands you all of it at once. .throttle() and .concurrency() before .from() still apply to the detached run, but read what they bound. Their defaults (.concurrency()'s mode: "queue" with no maxQueue, .throttle()'s mode: "delay") cap how many run at once while letting the wait line grow without limit, and every queued exchange pins its parsed body until it runs. That is not a bound on a redelivery burst; it is a slower way to reach the same heap.
Bound admission instead: give .concurrency() a maxQueue, or mode: "reject". Past the bound the exchange fails with RC5026 on the route's .error() channel, which is a delivery you have already answered for, so treat that channel as the place a shed delivery is recorded and rely on the sender's redelivery to bring it back.
A refusal only reaches the sender if it happens before the answer. Every transport gate does: path and method, body size, body parse, signature, and the mount's credential check. The pre-from chain positions that run inside the pipeline do not, .authorize() among them, so a scope denial on a route that answers early stops the work while the caller has already been told 202 and cannot tell a denial from acceptance. A route that must be able to refuse its caller does not answer early.
What a responder gives up. Response hints (routecraft.http.response.status and its siblings) are inert, because the descriptor is the answer and it is built here rather than from the exchange. Streaming and the deferral acknowledgement both live on the framework's own path, so a route that streams omits the option; an exchange body that turns out to be a stream is cancelled once the pipeline finishes, since nothing will read it and it may hold a socket or a file descriptor. And /openapi.json advertises no success code for a route with a responder, because the document cannot know what a function returns; the rejection codes stay, since every gate still runs ahead of it. A route that wants documented success codes omits the option.
What this does not do. A delivery answered for and then lost to a crash is not retried, replayed, or persisted; that is the sender's redelivery to make, which is what deduplicating on webhook-id in the route is for.
Route matching and information disclosure
The dispatcher resolves path/method before running auth, so unmatched paths return 404 and matched paths with a different method return 405 (with an Allow header) even to unauthenticated callers. This is standard HTTP behaviour (Express/Fastify/Hono all do the same), and GET /openapi.json is served publicly by default (matching the Stripe/GitHub/Twilio convention). Both choices are intentional: protection comes from auth on each endpoint, not from hiding the surface. If a deployment genuinely needs route concealment, gate the OpenAPI spec with builtins: { openapi: { requireAuth: true } } (or disable it with enabled: false) and put the service behind a gateway that strips 404/405 differentiation.
Events
server:listening->{ server, port, host }after the named listener binds.server:closed->{ server }after graceful shutdown.plugin:http:request:completed->{ method, path, status, durationMs, routeId?, principal? }per request (toggle withhttp: { events: { perRequest: false } }).auth:success/auth:rejected-- reused from the framework's existing auth event surface (source: "http").
See Server events and HTTP plugin events on the events reference.
HTTP client (outbound)
http<T, R>(options: HttpClientOptions<T>): Enricher<T, HttpResult<R>>
Make HTTP requests. Returns an Enricher (a pull-in) whose fetch produces an HttpResult; it works with .to(), .enrich(), and .tap().
With .enrich() (result replaces the body by default):
// Static GET request - the HttpResult replaces the body
.enrich(http({
method: 'GET',
url: 'https://api.example.com/users'
}))
// Dynamic URL based on exchange data
.enrich(http({
method: 'GET',
url: (exchange) => `https://api.example.com/users/${exchange.body.userId}`
}))
// Merge instead of replace: pick a value with only()
.enrich(
http({ url: (ex) => `https://api.example.com/users/${ex.body.userId}` }),
only((r) => r.body, 'user')
)
// Custom aggregator to control merge behavior
.enrich(
http({ url: 'https://api.example.com/profile' }),
(original, result) => ({
...original,
body: { ...original.body, profileData: result.body }
})
)
With .to() (body replacement) and .tap() (fire-and-forget):
.to(http(...)) invokes the client's fetch and replaces the exchange body with the HttpResult. To merge or preserve the original exchange body, use .enrich() with an aggregator instead; to call an endpoint purely for the side effect, use .tap() (the result is discarded).
.to(http({
method: 'POST',
url: 'https://api.example.com/webhook',
body: (exchange) => exchange.body
}))
.to(http({
method: 'GET',
url: 'https://api.example.com/transform'
}))
.enrich(http({
url: 'https://api.example.com/search',
query: (exchange) => ({ q: exchange.body.searchTerm, limit: 10 })
}))
Client options:
Returns: HttpResult object with status, headers, body, and url.
Response size limit
maxBodySize caps the response body at 10 MB by default. It is the same name and the same number as the http plugin's inbound cap, because it is the same concept on the other side of the framework.
.enrich(http({ url: (ex) => ex.body.url, maxBodySize: 2_000_000 }))
The cap is enforced twice, at the two moments the size can be known:
- A declared
Content-Lengthabove the cap is refused as soon as the response comes back, before the body is read. - Otherwise the body streams and the running byte count is checked as it arrives. The response is abandoned the moment the count crosses the ceiling.
The second is what makes the option bound memory rather than merely bound what the route receives. A chunked response declares no length at all, so without it an endpoint could stream any amount into the process before anything noticed.
Exceeding the cap fails the exchange with RC5061, naming the option, the limit, and the size that was declared or counted. The body is never truncated to fit: half a JSON document parses as though it were whole, and a route acting on it would be quietly wrong instead of loudly failed.
The cap applies to error responses too, so a 500 answering with a gigabyte is refused on the same terms. The size error names the status so the underlying HTTP failure stays legible.
To opt out entirely, say so by name:
.enrich(http({ url, maxBodySize: Infinity }))
Zero and negative values are refused at the http({...}) call site rather than read as "no limit". The two readings of 0 are opposites, so an options object built programmatically cannot arrive at unbounded by accident.
The 10 MB default applies to routes that never set the option. A route already moving larger payloads through http() needs maxBodySize raised to keep working.
Binary responses
The client decodes a response as UTF-8 by default and hands the route a string, parsing JSON when the response says it is JSON. That is right for text and wrong for everything else, because UTF-8 decoding is lossy in one direction: an invalid sequence becomes U+FFFD and re-encodes as three bytes, so the original is gone.
The corruption is silent and asymmetric, which is what makes it dangerous. A JPEG begins ff d8 ff e0, which is not valid UTF-8, so it is destroyed. An Ogg page header is the ASCII OggS, so a voice note passes through unharmed. A route fetching both sees one work and the other fail, which reads as a problem with the file rather than with the transport.
responseBody: 'bytes' turns the decoding off:
.enrich(
http<Delivery, Uint8Array>({ url: (ex) => ex.body.mediaUrl, responseBody: 'bytes' }),
only((r) => r.body, 'media'),
)
The body is a Uint8Array of exactly what arrived, with no JSON parsing. Name it in the call's type arguments, http<In, Uint8Array>({ ... }): the option does not change the result type, because an option value that selects an adapter's type is what the adapter Option Laws forbid, and every way of expressing it here either mistypes the two-type-argument form the examples use or refuses an options object built programmatically. maxBodySize and timeout apply unchanged, counting bytes as they arrive, and an oversized body still fails with RC5061. With throwOnHttpError the failure message names the body's size and content type rather than quoting it, since decoding it for the message would reintroduce the corruption this mode exists to avoid.
The mode is explicit rather than sniffed from the content type. Choosing by content type would silently change the body type of an existing route that receives application/octet-stream today and reads it as a string.
Streaming the response to the route is a different thing and is not available: the body is buffered under the cap either way.
Redirect policy
redirect mirrors the platform's RequestInit.redirect and adds nothing to it. The default is 'follow', exactly as before the option existed.
'manual' exists because a route that validated a URL has that validation bypassed the moment the adapter follows a redirect the route never saw. With 'manual' the route gets the 3xx back and re-runs its own rule on each hop:
import { craft, direct, http, isRedirect, when, log } from '@routecraft/routecraft'
import { z } from 'zod'
// Yours to write: the rule a URL has to pass before this route will fetch it.
const AllowedUrl = z.string().url().refine((u) => new URL(u).host === 'example.com')
// Yours to write: re-run that same rule on the Location target, then call again.
const revalidateAndFollow = (path) =>
path
.transform((body) => ({ url: AllowedUrl.parse(body.headers.location) }))
.enrich(http({ url: (ex) => ex.body.url, redirect: 'manual' }))
craft()
.id('fetch-page')
.input({ body: z.object({ url: AllowedUrl }) })
.from(direct())
.enrich(http({ url: (ex) => ex.body.url, redirect: 'manual' }))
.choice(when(isRedirect, revalidateAndFollow))
.to(log())
Validating once is not enough, and that is the point worth taking from this option.
isRedirect ships with the framework, and HTTP_REDIRECT_STATUSES is the set behind it. Use them rather than writing status >= 300 && status < 400: that includes 304 Not Modified, which is a cache answer rather than a hop, names no Location, and is not what the adapter treats as a redirect either. Deciding what to do on each hop, the revalidateAndFollow above, is the route's own business and the framework ships nothing for it.
The option reports what happened and hands control back. It carries no allowlist, no address classification, and no cross-host rules: deciding whether a URL is acceptable belongs in the route, where a reader can see the rule and change it.
Under 'manual', a 3xx does not trip throwOnHttpError, because it is what the route asked for. Every other non-2xx still does, 304 included, so opting out of following a redirect is not opting out of noticing a 404.
Credentials across a hop. Under 'follow', headers you set on the call are sent to the first origin, and both Node 22+ and Bun drop authorization and cookie when the chain crosses to a different origin. That stripping is the runtime's, not this framework's, so it is worth knowing rather than relying on: with 'manual' the route sees the hop before anything is re-sent, and decides for itself what the next call carries.
Both runtimes return the real 3xx here rather than the opaque filtered response the Fetch spec describes for browsers, so status and Location are readable on Node 22+ and on Bun alike. That is a contract this repo pins with a cross-runtime test rather than an assumption.