: 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/:id"
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/:id",
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {
id:string;
};
}) => {
id: string;
title: string;
}
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: (
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}
ctx) => ({
id: string
id:
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}
ctx.
params: {
id: string;
}
params.
id: string
id,
title: string
title: "First note" }),
}),
}));
Never annotate ctx. Its type is inferred from the rest of the
configuration: the path parameters, the validated parts, and what hooks
add. The fields are:
Field
Required
method
yes
GET, POST, PUT, PATCH or DELETE
path
yes
the path, with :param segments
handler
yes
the function that answers the request
schema
no
schemas for params, query, headers, cookies, body and response — see Validation
: 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: "/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.
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: (
ctx: {
readonly out: Outgoing;
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
title: string;
};
}
ctx) => {
ctx: {
readonly out: Outgoing;
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
title: string;
};
}
ctx.
out: Outgoing
Response parameters for the serialized handler result.
out.
status: number |undefined
status=201;
return
constnotes: {
add(title:string):Note;
remove(id:number):void;
}
notes.add(
ctx: {
readonly out: Outgoing;
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
title: string;
};
}
ctx.
body: {
title: string;
}
body.
title: string
title);
},
}),
remove: route({
method: "DELETE"
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: "DELETE",
path: "/notes/:id"
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/:id",
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {
id:string;
};
}) =>void
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: (
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}
ctx) => {
constnotes: {
add(title:string):Note;
remove(id:number):void;
}
notes.remove(Number(
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}
ctx.
params: {
id: string;
}
params.
id: string
id));
},
}),
export: 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/export"
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/export",
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
}) => Response
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: () =>newResponse("id,title\n", {
headers?: HeadersInit |undefined
headers: { "content-type": "text/csv" } }),
}),
}));
Set the status and headers of a JSON result on ctx.out; a Response
carries its own. A handler may be async.
Responses covers response schemas, redirects
and streams, and Errors covers what a thrown error
becomes.
Each :name segment becomes a field of ctx.params, typed from the path
literal:
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: "/users/:userId/notes/:noteId"
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: "/users/:userId/notes/:noteId",
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {
userId:string;
noteId:string;
};
}) => {
userId: string;
noteId: string;
}
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: (
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
userId: string;
noteId: string;
};
}
ctx) =>
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
userId: string;
noteId: string;
};
}
ctx.params,
params: {
userId: string;
noteId: string;
}
});
Parameters are strings. To get a number, a UUID or an enum, declare a
params schema; see Validation. A path
without parameters has an empty ctx.params.
A path starts with / and has no empty segments and no trailing slash. A
parameter takes a whole segment. A * is allowed only as the whole last
segment, where it matches the rest of the path:
import { route } from"@tetsujs/core";
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: "/users/:userId/notes/:noteId"
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: "/users/:userId/notes/:noteId",
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {
userId:string;
noteId:string;
};
}) =>undefined
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: () =>undefined });
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: "/files/*"
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: "/files/*",
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
}) =>undefined
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: () =>undefined });
These are the rules of Bun’s router, and a path that breaks them does not
compile. That includes two spellings that look right but are not: {id}
and the optional parameter :id?, which Bun’s router does not support:
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: "/users/{id}",
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
}) =>undefined
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: () =>undefined });
Error ts(2322) ― A parameter is ':id', not '{id}': Bun's router matches braces as the characters they are, so '/users/{id}' answers only a request for that literal path
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: "/users/:id?",
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {
"id?":string;
};
}) =>undefined
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: () =>undefined });
Error ts(2322) ― Bun's router has no optional parameters: ':id?' matches only a present value and names the parameter 'id?'
A path the compiler cannot see, such as one built from a variable, is
checked with the same rules when route() runs.
createApp() also refuses two mistakes at startup: the same method and
path declared twice, and two paths that differ only in parameter names,
such as /users/:id and /users/:userId. Bun’s router treats those as
one pattern.
A * captures nothing: ctx.params has no field for it. Read the rest of
the path from ctx.req.url.
A route declares GET, POST, PUT, PATCH or DELETE. Every path
answers HEAD and OPTIONS on its own: HEAD runs the path’s GET
route and sends the headers without the body, and OPTIONS answers 204
with an Allow header. Any other method on a known path gets 405 with
the same Allow.
: 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: "/users/:id"
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: "/users/:id",
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {
id:string;
};
}) => {
id: string;
}
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: (
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}
ctx) => {
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}
ctx.
route: RouteInfo
The route this request matched, or nothing when none did.
Optional because a 404, a 405 and a preflight run the pipeline
too, and there is no route behind them — the same reason req.cookies
is optional. In a route's own context the field is not optional: a
hook mounted on a route, and its handler, always have one.
It is the field that makes an observer able to name the endpoint
rather than the URL: ctx.route.path is /users/:id, where
new URL(ctx.req.url).pathname is /users/42. The difference is
cosmetic in a log line and structural in a metric, where a label built
from the second one grows a new series per identifier.
route.
path: string
The path as the route declared it, with group prefixes joined and
:params left as they are — /api/users/:id, never /api/users/42.
path; // "/users/:id"
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}
ctx.
route: RouteInfo
The route this request matched, or nothing when none did.
Optional because a 404, a 405 and a preflight run the pipeline
too, and there is no route behind them — the same reason req.cookies
is optional. In a route's own context the field is not optional: a
hook mounted on a route, and its handler, always have one.
It is the field that makes an observer able to name the endpoint
rather than the URL: ctx.route.path is /users/:id, where
new URL(ctx.req.url).pathname is /users/42. The difference is
cosmetic in a log line and structural in a metric, where a label built
from the second one grows a new series per identifier.
route.
method: string
The method this route answers.
method; // "GET"
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}
ctx.
route: RouteInfo
The route this request matched, or nothing when none did.
Optional because a 404, a 405 and a preflight run the pipeline
too, and there is no route behind them — the same reason req.cookies
is optional. In a route's own context the field is not optional: a
hook mounted on a route, and its handler, always have one.
It is the field that makes an observer able to name the endpoint
rather than the URL: ctx.route.path is /users/:id, where
new URL(ctx.req.url).pathname is /users/42. The difference is
cosmetic in a log line and structural in a metric, where a label built
from the second one grows a new series per identifier.
route.
controller?: string |undefined
The name of the controller the route was collected from — the one
controller() gave it, or its class's. Absent for an object literal
and a route mounted standalone, which have nothing to be named after.
controller; // "Users"
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}
ctx.
route: RouteInfo
The route this request matched, or nothing when none did.
Optional because a 404, a 405 and a preflight run the pipeline
too, and there is no route behind them — the same reason req.cookies
is optional. In a route's own context the field is not optional: a
hook mounted on a route, and its handler, always have one.
It is the field that makes an observer able to name the endpoint
rather than the URL: ctx.route.path is /users/:id, where
new URL(ctx.req.url).pathname is /users/42. The difference is
cosmetic in a log line and structural in a metric, where a label built
from the second one grows a new series per identifier.
route.
name?: string |undefined
The field name the route was declared under, when it had one.
Absent for a RouteDef mounted on its own, which has no field to be
named by. It is the same name @tetsujs/openapi builds an
operationId from, so a metric labelled with it and an operation in
the document agree on what the endpoint is called.
name; // "get"
return {
id: string
id:
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}
ctx.
params: {
id: string;
}
params.
id: string
id };
},
}),
}));
path is the template with group prefixes joined, never the URL. Use it
to label logs and metrics: a label built from the URL creates a new series
for every id. controller and name are absent for a route mounted on
its own, outside a controller. In hooks that also run for a 404, a 405
or a preflight, ctx.route is optional, because no route matched.
Routing is done by Bun’s native router. createApp() turns each declared
path into one entry of the routes object Bun.serve takes, and that
entry picks the route by method. There is no second matcher, so the
precedence between overlapping paths is Bun’s.
app.fetch only handles requests that matched no path: it answers 404,
or runs the fallback option of createApp. Calling app.fetch directly
does not route, so integration tests go through a real server; see
Testing.