The functions that declare what an application serves: route() for an
HTTP endpoint, ws() for a WebSocket endpoint, controller() to name a
set of them and give them dependencies, and group() to mount them under
a prefix with hooks.
: the method is
half of a route's identity, and an application that remembers its
routes — for a generated client, for tooling — needs to know which one
this is.
method: "POST",
path: "/orders"
Route path with :param segments, e.g. "/orders/:id/cancel".
Must start with /, contain no empty segments and no trailing slash;
a malformed literal is a compile error.
This route's own body ceiling, overriding the application's.
One number cannot serve a JSON API and an upload endpoint at once:
raising the application's limit for the sake of one route lowers the
floor everywhere else, which is how a default that fits nothing gets
chosen. The ceiling belongs where the exception is.
maxBodySize: 64*1024,
docs?: RouteDocs |undefined
Documentation metadata for OpenAPI generation.
docs: {
summary?: string |undefined
summary: "Place an order",
tags?: readonly string[] |undefined
tags: ["orders"] },
handler: (
ctx: {
readonly out: Outgoing & DeclaredOutgoing<201>;
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
sku: string;
qty: number;
};
}
ctx) => {
ctx: {
readonly out: Outgoing & DeclaredOutgoing<201>;
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
sku: string;
qty: number;
};
}
ctx.
out: Outgoing & DeclaredOutgoing<201>
Response parameters for the serialized handler result.
out.
status: 201|undefined
status=201;
return
constorders: {
create(body: {
sku:string;
qty:number;
}): {
id:number;
sku:string;
qty:number;
};
}
orders.create(
ctx: {
readonly out: Outgoing & DeclaredOutgoing<201>;
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
sku: string;
qty: number;
};
}
ctx.
body: {
sku: string;
qty: number;
}
body);
},
});
Field
Type
Default
method
"GET" | "POST" | "PUT" | "PATCH" | "DELETE"
required
HEAD and OPTIONS are answered by every path itself
route() returns a RouteDef: the configuration, branded. Its handler
keeps its inferred type, so a unit test can call it with testCtx().
isRoute(value) tells a RouteDef from anything else.
Every part is optional. A part without a schema is not validated. Without
one, params is still on ctx as strings and a body the route reads as
parsed; the other parts are not on ctx.
Part
Validates
Adds to ctx
params
path parameters, as strings
params as the schema’s output; without a schema, params holds the strings
query
the query string; a repeated key is an array
query
headers
request headers, with lower-case names
headers
cookies
the cookie header, signed cookies opened
cookies
body
the parsed body
body
response
the result
nothing; it limits what the handler may return and ctx.out.status
Parts are validated in the order params, query, headers, cookies,
body. All their issues are collected into one 422, or the configured
status. Any Standard Schema validator works.
response is a single schema, or a map by status:
Map value
Means
a schema
the body of that status
null
the status has no body
{ body?, headers?, cookies?, contentType? }
the body, the headers and cookies the response carries, and the media type of a body that is not JSON; without body or contentType, no body
With a map, the handler may set only a declared status. With
validateResponses on, a response that leaves with an undeclared status
is a 500. default and a range such as 4XX, OpenAPI’s own keys, are
taken for the document and declare no status. Any other key that is not a
status, an entry key other than body, headers, cookies and
contentType, or a contentType other than a bare type or range makes
route() throw. See
Responses.
204, or ctx.out.status. Under a status whose contentType is not JSON, a compile error, or a 500 when responses are validated
a Response
sent unchecked; ctx.out.status is ignored
another value
JSON, checked by the response schema of its status; 200, or ctx.out.status. Under a status whose contentType is not JSON, a compile error, or a 500 when responses are validated
a ReadableStream, generator or async iterable
compile error; 500 at runtime
With response, the value must be the schema’s output or a Response.
With a map, it may be any declared JSON body, or undefined when a status
has no body. A status whose contentType is not JSON takes a Response
only.
The body is read only when the route declares schema.body, bodyType or
rawBody. The route decides how it is parsed; the content-type header
is ignored.
bodyType
ctx.body before validation
Unparsable
"json"
unknown
400 MALFORMED_JSON
"form"
Record<string, string | File | (string | File)[]>
400 MALFORMED_FORM
"text"
string
never
"stream"
ReadableStream<Uint8Array>, counted against the limit
never
Two combinations are compile errors:
bodyType: "stream" with schema.body: a stream cannot be validated;
rawBody: true with "form" or "stream": a form’s bytes are not
kept, and a stream is already the raw body. route() also throws on
this one at runtime.
the handshake’s path, checked by the same rules as a route’s: a literal at compile time, every path when ws() runs
schema
{ params?, query?, headers?, message? }
the handshake’s parts, and every text frame
hooks
hooks keyed by slot
run for the handshake
docs
{ summary?, description? }
for readers only; socket endpoints are not in OpenAPI
open
(socket) => unknown
the socket is open
message
(socket, message) => unknown
a frame arrived: the schema’s output, or string | Buffer without schema.message
invalid
(socket, issues) => unknown
a frame failed schema.message; without this handler the socket closes with 1007
close
(socket, code, reason) => unknown
the socket closed
drain
(socket) => unknown
the socket is writable again after backpressure
ping
(socket, data) => unknown
a ping frame arrived
pong
(socket, data) => unknown
a pong frame arrived
until
AbortSignal | (() => AbortSignal | undefined)
closes the endpoint’s sockets with 1001 when it fires
socket is Bun’s ServerWebSocket. Its data is the handshake’s context
without req, server, out, route and startedAt. A handler’s
failure is reported with source: "websocket". A plain GET on the path
is a 426. isWs(value) tells a WsDef from anything else. See
WebSockets.
Returns build, named. Calling it with the dependencies gives an object
whose route and ws() fields are the controller’s endpoints.
exportconstnotesController=controller("Notes", ({
notes: NotesRepo
notes }: {
notes: NotesRepo
notes:NotesRepo }) => ({
list: route({
method: "GET"
The method this route answers.
Kept as a literal rather than widened to
Method
: the method is
half of a route's identity, and an application that remembers its
routes — for a generated client, for tooling — needs to know which one
this is.
method: "GET",
path: "/notes"
Route path with :param segments, e.g. "/orders/:id/cancel".
Must start with /, contain no empty segments and no trailing slash;
a malformed literal is a compile error.
path: "/notes",
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
}) => Note[]
The endpoint logic; ctx is fully inferred, never annotate it.
The return type is inferred rather than demanded, and checked twice
over. Against the route's own contract, by the
HandlerResult
bound on R: answering with something response never declared is a
compile error that says so, instead of a structural diff against
Response. And against what the framework can serialize at all, by
the intersected
ValidateResult
: a stream handed over bare is
refused whether or not the route declared anything, because that is
the case no contract covers — without a response schema
HandlerResult is unknown and accepts every value there is.
Mounts children (controllers, routes, ws() endpoints, groups) under
prefix, and runs hooks for every route below.
prefix starts with /, is not / itself, and has no trailing /, no
//, no :param, no *, and none of {, }, ?. A literal prefix is
checked at compile time, and every prefix when group() runs.
A group hook may require only what its slot guarantees and what the
group’s earlier hooks contribute. What it contributes is not typed in
handlers.
A group’s hooks do not run for a 404, a 405 or an OPTIONS request.
isGroup(value) tells a GroupNode from anything else.