Skip to content

route

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.

function route(config: RouteConfig): RouteDef
const Order = z.object({ id: z.number(), sku: z.string(), qty: z.number() });
route({
method: "POST"

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
: "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.

path
: "/orders",
schema: {
body: z.object({ sku: z.string(), qty: z.number().int().min(1) }),
response: { 201: Order },
},
maxBodySize?: number | undefined

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
const orders: {
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
path string literal required the path, :param segments included
handler (ctx) => result required ctx is inferred; never annotate it
schema SchemaConfig none request parts and the response, below
hooks hooks keyed by slot none the route’s own hooks, see Hook slots
bodyType "json" | "form" | "text" | "stream" "json" how the body is read
maxBodySize number the application’s this route’s body limit in bytes
rawBody boolean false keep the body’s bytes as ctx.rawBody
docs RouteDocs none documentation only, below

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.

  • starts with /; no empty segment (//); no trailing /, except the root path /;
  • a parameter is :name and fills a whole segment;
  • * only as the whole last segment;
  • {id}, ? and a bare : are refused, not matched literally.

A literal path is checked at compile time, and every path is checked again when route() runs.

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.

Return Result
undefined 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.

docs takes summary, description and tags, plus:

  • deprecated: the route stays in the document, marked deprecated;
  • hidden: true leaves the route out of the document, still served; false keeps it in when its handler was annotated to hide it;
  • operationId: replaces the default, the controller’s name joined with the field’s.

Only @tetsujs/openapi reads docs. It has no runtime effect.

function ws(config: WsConfig): WsDef
Field Type
path string literal 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.

function controller(name: string, build: (...deps) => routes): (...deps) => routes

Returns build, named. Calling it with the dependencies gives an object whose route and ws() fields are the controller’s endpoints.

export const notesController = 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: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}) => 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.

handler
: () =>
notes: NotesRepo
notes
.all() }),
}));
type NotesDeps = Parameters<typeof notesController>[0];
  • An empty name throws.
  • Two controllers of one application may not share a name: their operationIds would collide. createApp refuses it.
  • Parameters<typeof notesController>[0] is the dependencies’ type.
  • An object not built by controller() is mounted too. It is named after its class, or unnamed if it is an object literal.
function group(
prefix: string,
config: { hooks?: GroupHooks; children: readonly object[] },
): GroupNode

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.

See Groups and mounting.