multipart/form-data and application/x-www-form-urlencoded
"text"
string
the body as text
"stream"
ReadableStream<Uint8Array>
the body unread, for the handler to consume
The route decides, not the content-type header, which many clients leave
out: fetch sends none for a plain string body. A body that does not parse
as the declared shape is a 400, MALFORMED_JSON or MALFORMED_FORM.
With a body schema, ctx.body is the schema’s output. Without one, it is
the parsed shape from the table:
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: "/notes/import"
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/import",
bodyType?:"text"|undefined
The wire shape of the request body: "json" (the default), "form",
"text", or "stream" for a ReadableStream the handler reads itself,
counted against maxBodySize.
Declaring it makes the body be read even without a body schema — a
form of files alone needs no schema to be parsed. The declaration also
decides how the bytes are parsed: the content-type header is never
consulted, so a body that does not parse as the declared shape is a
400 rather than a silent reinterpretation.
bodyType: "text",
handler: (ctx: {
readonlyparams: {};
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlybody:string;
}) => {
lines: number;
}
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 params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: string;
}
ctx) => ({
lines: number
lines:
ctx: {
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: string;
}
ctx.body.split("\n").
length: number
Gets or sets the length of the array. This is a number one higher than the highest index in the array.
length }),
body: string
});
A route reads its body only when it has a body schema, a bodyType or
rawBody: true. Otherwise the body is never read and ctx.body does not
exist. The body is read after the beforeParse hooks, so a hook there that
refuses a request costs no reading at all.
A handler can still read ctx.req.body itself, but maxBodySize does not
apply to it. To read the body yourself, declare "stream" instead.
A "form" body is parsed with Bun’s own parser. Files arrive as File
values in ctx.body, next to the text fields, and are validated like any
other field. With TypeBox, file() and files() from
@tetsujs/typebox describe them; with Zod,
z.file():
import { controller, route } from"@tetsujs/core";
import { file, Type, tb } from"@tetsujs/typebox";
const
constImageUpload:TypeBoxSchema<Type.TObject<{
title:Type.TString;
image:Type.TUnsafe<File>;
}>>
ImageUpload=tb(
Type.Object({
title: Type.TString
title: Type.String({
minLength?: number |undefined
Specifies the minimum number of characters allowed in the string.
Must be a non-negative integer.
minLength: 1 }),
image: Type.TUnsafe<File>
image: file({
maxSize?: FileSize |undefined
Largest accepted size.
maxSize: "2m",
type?: string | readonly string[] |undefined
Accepted MIME types. A bare prefix matches a family: "image" accepts
image/png and image/webp, "image/png" accepts only that one.
: 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: "/images"
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: "/images",
bodyType?:"form"|undefined
The wire shape of the request body: "json" (the default), "form",
"text", or "stream" for a ReadableStream the handler reads itself,
counted against maxBodySize.
Declaring it makes the body be read even without a body schema — a
form of files alone needs no schema to be parsed. The declaration also
decides how the bytes are parsed: the content-type header is never
consulted, so a body that does not parse as the declared shape is a
400 rather than a silent reinterpretation.
bodyType: "form",
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: 3*1024*1024,
schema?: {
readonly body: TypeBoxSchema<Type.TObject<{
title:Type.TString;
image:Type.TUnsafe<File>;
}>>;
} |undefined
Validation schemas; an absent part is neither parsed nor typed.
schema: {
body: TypeBoxSchema<Type.TObject<{
title:Type.TString;
image:Type.TUnsafe<File>;
}>>
body:
constImageUpload:TypeBoxSchema<Type.TObject<{
title:Type.TString;
image:Type.TUnsafe<File>;
}>>
ImageUpload },
handler: (ctx: {
readonlyparams: {};
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlybody: {
title:string;
image:File;
};
}) =>Promise<{
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: async (
ctx: {
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
title: string;
image: File;
};
}
ctx) => {
ctx: {
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
title: string;
image: File;
};
}
ctx.
out: Outgoing
Response parameters for the serialized handler result.
out.
status: number |undefined
status=201;
returnawait
constimages: {
save(title:string, image:File):Promise<{
id:string;
}>;
}
images.save(
ctx: {
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
title: string;
image: File;
};
}
ctx.
body: {
title: string;
image: File;
}
body.
title: string
title,
ctx: {
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
title: string;
image: File;
};
}
ctx.
body: {
title: string;
image: File;
}
body.image);
image: File
},
}),
}));
A field sent once is a value, a repeated one an array, as in the query
string.
A file input left empty is dropped, as if the field were not sent, so an
optional file field works on an ordinary HTML form.
A file’s type is the MIME type the client declared, not what the bytes
are.
maxBodySize counts the whole form, multipart framing included.
Without a schema, ctx.body is the record of fields and files as they
arrived.
maxBodySize is the most a route reads, in bytes. It is 1 MiB by default,
set for the application on createApp, and overridden per route, as the
upload above does:
import { createApp } from"@tetsujs/core";
createApp({
routes: object
The topology: a group, a controller, or an array of either.
routes,
maxBodySize?: number |undefined
Maximum size in bytes of a request body the framework reads: a route's
with a body schema, a bodyType or rawBody. Defaults to 1 MiB.
A content-length above the limit is rejected with a 413 before a
single byte is read; a chunked request is dropped as soon as the
buffered stream crosses the limit. Either way an oversized body costs
no parsing and no validation. A "stream" body is the exception: it is
counted as the handler reads it, so its 413 comes with the handler
already running, and none comes if the handler stops reading first.
The chunked rejection abandons the stream mid-flight, which leaves the
connection's framing broken: the client gets its 413, but that
connection is spent and the next request over it fails. Declared
bodies are read to the end and cost the connection nothing.
The framework only guards the body it parses itself: a handler reading
ctx.req directly is not capped. The ceiling for everything else is
Bun's own maxRequestBodySize (default 128 MiB), set where the app is
served: Bun.serve({ ...app, maxRequestBodySize }).
maxBodySize: 256*1024 });
Raise it on the route that needs more, not for the whole application.
An oversized body is refused without being buffered:
A content-length above the limit is refused before any byte is read.
A chunked body is counted as it arrives and refused at the first chunk
that crosses the limit. The rest is left unread, so that connection
cannot carry another request.
A "stream" body is counted as the handler reads it.
A "stream" body reaches the handler unread, so a large upload is never
held in memory:
import { route } from"@tetsujs/core";
route({
method: "PUT"
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: "PUT",
path: "/backups/:name"
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: "/backups/:name",
bodyType?:"stream"|undefined
The wire shape of the request body: "json" (the default), "form",
"text", or "stream" for a ReadableStream the handler reads itself,
counted against maxBodySize.
Declaring it makes the body be read even without a body schema — a
form of files alone needs no schema to be parsed. The declaration also
decides how the bytes are parsed: the content-type header is never
consulted, so a body that does not parse as the declared shape is a
400 rather than a silent reinterpretation.
bodyType: "stream",
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.
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.
The limit fails later here: the handler is already running when it is
crossed. The stream errors with the 413, and whatever reads it rethrows
it. Cleaning up a partial write is the handler’s job, in a finally.
A "stream" body cannot have a body schema, since nothing is read before
the handler runs. Declaring both is a compile error.
Bun.serve has its own maxRequestBodySize, 128 MiB by default, and
refuses a larger body with a bare 413 before the application sees it.
When any maxBodySize in the application is above that, createApp() puts
a slightly higher maxRequestBodySize on the app, and
Bun.serve({ ...app }) picks it up. It is only ever raised, and a value
written after the spread still wins. A handler that reads ctx.req itself
is limited by this cap alone.
For files of many megabytes, the usual design keeps them out of the
application: the route checks who is asking and returns a pre-signed URL,
and the client uploads straight to object storage.
A webhook is signed over the exact bytes it was sent as. rawBody: true
keeps them in ctx.rawBody, next to the parsed and validated ctx.body.
The bytes are there from beforeValidation on, so a hook in that slot can
check the signature before anything is validated:
The raw incoming request, always available as an escape hatch.
On a request that matched a route, Bun's router delivers its own
request object carrying cookies — a Bun.CookieMap whose mutations
are applied to the response as Set-Cookie automatically, with Bun's
defaults (Path=/; SameSite=Lax). The field is optional because it
does not exist where Bun's router was not involved: the 404 fallback
and unit-tested handlers. ctx.out.headers.append("set-cookie", ...)
is the fallback that works everywhere.
req.
headers: Headers
The headers read-only property of the with the request.
: 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: "/webhooks/payments"
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: "/webhooks/payments",
rawBody?:true|undefined
Keeps the bytes the body was read from, as ctx.rawBody, next to the
body parsed from them — for a webhook, whose signature is over the
bytes it was sent as, and whose payload is handled through a schema.
The bytes are there from beforeValidation on, so a hook can check
the signature before the body is validated, and they are typed only on
a route that asks — the hook that needs them says so with
Requires<{ rawBody: Uint8Array }>. A json or text body only: a
form is parsed natively and a stream is the raw body already.
Only the route that asks pays for it: its body is held twice, as bytes
and as what was parsed from them.
rawBody: true,
schema: { body: PaymentEvent },
hooks: { beforeValidation: [signed] },
handler: (ctx: {
readonlyparams: {};
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyrawBody:Uint8Array;
readonlybody: {
id:string;
amount:number;
};
}) =>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 params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly rawBody: Uint8Array;
readonly body: {
id: string;
amount: number;
};
}
ctx) =>
constpayments: {
record(event: {
id:string;
amount:number;
}):void;
}
payments.record(
ctx: {
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly rawBody: Uint8Array;
readonly body: {
id: string;
amount: number;
};
}
ctx.
body: {
id: string;
amount: number;
}
body),
});
ctx.rawBody is typed only on a route that asks for it, so a hook that
needs it cannot be mounted on one that does not. It works with json and
text bodies; with form or stream it is a compile error. The body is
held twice, as bytes and as what was parsed, so ask for it only where a
signature needs it.