Skip to content

Request bodies

A route declares how its body is read, and the framework reads it that way, within a size limit.

bodyType sets the body’s wire shape:

bodyType ctx.body before validation
"json" unknown the default
"form" a record of strings and Files 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: {
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;
}) => {
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
const ImageUpload: 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.

type
: "image" }),
}),
);
export const imagesController = controller("Images", () => ({
upload: 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: "/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
:
const ImageUpload: TypeBoxSchema<Type.TObject<{
title: Type.TString;
image: Type.TUnsafe<File>;
}>>
ImageUpload
},
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: {
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;
return await
const images: {
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.

Each is the same 413:

{ "status": 413, "message": "Body exceeds the configured limit", "error": "BODY_TOO_LARGE" }

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.

maxBodySize
: 5 * 1024 ** 3,
handler: (ctx: {
readonly params: {
name: string;
};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: ReadableStream<Uint8Array<ArrayBufferLike>>;
}) => Promise<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
: async (
ctx: {
readonly params: {
name: string;
};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: ReadableStream<Uint8Array<ArrayBufferLike>>;
}
ctx
) => {
await
const storage: {
put(key: string, body: ReadableStream<Uint8Array>): Promise<void>;
}
storage
.put(
ctx: {
readonly params: {
name: string;
};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: ReadableStream<Uint8Array<ArrayBufferLike>>;
}
ctx
.
params: {
name: string;
}
params
.
name: string
name
,
ctx: {
readonly params: {
name: string;
};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: ReadableStream<Uint8Array<ArrayBufferLike>>;
}
ctx
.
body: ReadableStream<Uint8Array<ArrayBufferLike>>
body
);
},
});

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:

import type { Requires } from "@tetsujs/core";
import { hook, httpError, route } from "@tetsujs/core";
import { z } from "zod";
const PaymentEvent = z.object({ id: z.string(), amount: z.number() });
const signed = hook.beforeValidation((
ctx: Requires<{
rawBody: Uint8Array;
}>
ctx
: Requires<{
rawBody: Uint8Array<ArrayBufferLike>
rawBody
: Uint8Array }>) => {
if (!verify(
ctx: Requires<{
rawBody: Uint8Array;
}>
ctx
.
rawBody: Uint8Array<ArrayBufferLike>
rawBody
,
ctx: Requires<{
rawBody: Uint8Array;
}>
ctx
.
req: Request & {
readonly cookies?: Bun.CookieMap;
}

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.

MDN Reference

headers
.get("x-signature"))) {
throw httpError(401, "BAD_SIGNATURE");
}
});
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: "/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: {
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;
};
}) => 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
) =>
const payments: {
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.

The Webhooks guide builds a complete receiver on this.