Context#
The Context object (ctx) is passed to every route handler and middleware. It provides access to the request, response helpers, validated data, and shared state.
Request Properties#
| Property | Type | Description |
|---|---|---|
| ctx.request | Request | Raw Bun Request object |
| ctx.params | Record<string, string> | Route parameters (:id, *wildcard) |
| ctx.query | Record<string, string> | Parsed query string (lazily cached) |
| ctx.user | Record<string, unknown> | undefined | JWT payload (undefined before auth) |
| ctx.ip | string | Client IP (reads X-Forwarded-For) |
| ctx.store | Record<string, unknown> | Per-request key-value store |
| ctx.di | DI | Dependency injection container values |
Route Parameters#
// Classic style
app.get("/users/:id", (ctx) => {
const { id } = ctx.params; // string
return ctx.json({ userId: id });
});
// Elysia-style destructuring + flexible return
app.get("/users/:id", ({ params }) => params); // { id: "123" } as JSON
app.get("/users/:id", ({ params: { id } }) => id); // "123" as text/plain
app.get("/files/*", (ctx) => {
const path = ctx.params["*"]; // wildcard match
return ctx.json({ filePath: path });
});
app.get("/files/*", ({ params }) => params["*"]); // shorthandQuery Parameters#
app.get("/search", (ctx) => {
const { q, page, limit } = ctx.query;
// All values are strings
return ctx.json({ query: q, page, limit });
});
// GET /search?q=buntok&page=1&limit=10Client IP#
app.get("/ip", (ctx) => {
return ctx.json({ ip: ctx.ip });
});ctx.ip reads from the X-Forwarded-For header (first value) if present, otherwise returns "127.0.0.1".Per-Request Store#
// Use ctx.store to share data between middleware
const auth = async (ctx, next) => {
ctx.store.user = await verifyToken(ctx);
return next();
};
app.get("/profile", auth, (ctx) => {
return ctx.json({ user: ctx.store.user });
});Request Body#
| Method | Return | Description |
|---|---|---|
| await ctx.body<T>() | Promise<T> | Parse JSON body (cached after first call) |
| await ctx.formData() | Promise<FormData> | Parse multipart/form-data (cached) |
// JSON body
app.post("/users", async (ctx) => {
const { name, email } = await ctx.body();
return ctx.json({ created: true }, 201);
});
// FormData (file uploads)
app.post("/upload", async (ctx) => {
const form = await ctx.formData();
const file = form.get("avatar");
return ctx.json({ uploaded: true });
});ctx.body() and ctx.formData() cache their result. You can call them multiple times without re-parsing.Cookies#
| Method | Return | Description |
|---|---|---|
| ctx.getCookie(name) | string | undefined | Get a single cookie by name |
| ctx.getCookies() | Record<string, string> | Get all cookies as a map |
app.get("/profile", (ctx) => {
const token = ctx.getCookie("token");
const all = ctx.getCookies();
return ctx.json({ token, cookies: all });
});Validated Data#
After using zValidator(), access validated (and typed) data via ctx.valid():
import { zValidator, z } from "@buntok/core/middlewares/validator";
const userSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
app.post("/users", zValidator("body", userSchema), (ctx) => {
const data = ctx.valid("body"); // typed as { name: string; email: string }
return ctx.json({ created: true, ...data });
});
app.get("/search", zValidator("query", searchSchema), (ctx) => {
const { q } = ctx.valid("query"); // typed from schema
return ctx.json({ results: [] });
});ctx.valid() throws if no zValidator() ran for that target. This fails loudly instead of returning undefined.Flexible Return (Elysia-style)#
Handlers may return primitives/objects directly - the framework auto-serializes via toResponse(). Both styles work side-by-side:
| Return | Serialized as | Content-Type | Status |
|---|---|---|---|
| string | new Response(string) | text/plain; charset=utf-8 | 200 |
| number / boolean / bigint | String(value) | text/plain; charset=utf-8 | 200 |
| object / array | Response.json(value) | application/json | 200 |
| null / undefined / void | empty body | - | 204 |
| Blob / ArrayBuffer / Uint8Array / ReadableStream | new Response(value) | from value | 200 |
| Response | passthrough | as-is | as-is |
// All of these are valid handlers
app.get("/hello", () => "hello world"); // text/plain
app.get("/json", () => ({ hello: "buntok" })); // json
app.get("/num", () => 42); // text/plain "42"
app.get("/users", () => [{ id: 1 }, { id: 2 }]); // json array
app.get("/empty", () => null); // 204
app.get("/custom", () => new Response("hi", { status: 201 })); // passthrough
// Destructuring (ctx is an instance with params/query/store/ip/...)
app.get("/users/:id", ({ params }) => params); // { id: "123" }
app.get("/users/:id", ({ params: { id } }) => id); // "123"
app.get("/search", ({ query }) => query.q); // ?q=...
app.get("/profile", ({ store, request }) => store.user);HandlerReturn (src/buntok.ts:139, exported from @buntok/core) covers all cases. toResponse() and toResponseMaybeAsync() are also exported from @buntok/core for custom wrapping.Response Methods#
| Method | Description |
|---|---|
| ctx.json(data, status?) | JSON response (default: 200) |
| ctx.success(data?, message?, status?) | Success envelope: { success, message, data } |
| ctx.paginate(data, total, page, limit, message?, status?) | Offset-based pagination with meta |
| ctx.cursorPaginate(data, nextCursor, message?, status?) | Cursor-based pagination (infinite scroll) |
| ctx.error(message, status?, details?) | Error response: { success: false, message, details } |
| ctx.text(text, status?) | Plain text response |
| ctx.html(html, status?) | HTML response |
| ctx.redirect(url, status?) | Redirect (301, 302, 303, 307, 308) |
| ctx.status(code) | Empty response with status code |
| ctx.htmlStream(generator, options?) | Streaming HTML via async generator - yields chunks progressively |
| ctx.sse(handler, options?) | Server-Sent Events stream |
JSON Response#
// Default 200
return ctx.json({ message: "Hello" });
// Custom status
return ctx.json({ created: true }, 201);Success Envelope#
// { success: true, message: "Success", data: [...] }
return ctx.success(users);
// { success: true, message: "Created", data: {...} }, 201
return ctx.success(user, "Created", 201);Pagination#
// Offset-based pagination
app.get("/users", async (ctx) => {
const page = Number(ctx.query.page) || 1;
const limit = Number(ctx.query.limit) || 10;
const users = await db.user.findMany({ skip: (page - 1) * limit, take: limit });
const total = await db.user.count();
return ctx.paginate(users, total, page, limit);
});
// Response:
// {
// success: true,
// message: "Success",
// data: [...],
// meta: {
// currentPage: 1,
// perPage: 10,
// total: 50,
// lastPage: 5,
// hasMore: true
// }
// }
// Cursor-based pagination
return ctx.cursorPaginate(users, nextCursor);Error Response#
// { success: false, message: "Validation failed", details: [...] }
return ctx.error("Validation failed", 422, errors);Redirect#
// 302 redirect (default)
return ctx.redirect("/login");
// 301 permanent redirect
return ctx.redirect("/new-url", 301);Post-Response Hooks#
Register hooks that run after the response is finalized (once ctx.set.headers are merged) - useful for logging, metrics, or response transformation. Return a modified response, or undefined to keep the original:
app.get("/api", (ctx) => {
ctx.onAfterResponse((res) => {
console.log("Response status:", res.status);
// Return modified response, or undefined to keep original
return res;
});
return ctx.json({ data: "hello" });
});Custom Response Headers (ctx.set)#
Mutate response headers per-request with ctx.set.headers (typed HTTPHeaders: well-known headers autocomplete, values string | number with numbers stringified, and "set-cookie" accepts string | string[] and appends each cookie). The map is merged into the final response after built-in headers, so your values win (for example over X-Powered-By and x-request-id):
app.get("/custom-headers", (ctx) => {
ctx.set.headers["x-powered-by"] = "benchmark";
ctx.set.headers["x-a"] = 1; // number → "1"
ctx.set.headers["set-cookie"] = ["a=1; Path=/", "b=2; Path=/"];
return "ok";
});ctx.set / set.headers / onAfterResponse usage (including a destructured set param and the alias (c) => c.set.headers) and keeps the full Context for that route.Server-Sent Events#
app.get("/events", (ctx) => {
return ctx.sse((stream) => {
stream.sendData("connected");
stream.sendEvent("update", { count: 42 });
stream.onClose(() => {
console.log("Client disconnected");
});
});
});| Option | Type | Default | Description |
|---|---|---|---|
| sendInitial | boolean | true | Send initial "connected" event |
| initialEvent | string | "connected" | Custom event name for initial |
| retry | number | undefined | Client reconnection timeout (ms) |
Raw Request Access#
app.get("/info", (ctx) => {
// Headers
const contentType = ctx.request.headers.get("Content-Type");
const auth = ctx.request.headers.get("Authorization");
// Method
const method = ctx.request.method;
// URL
const url = ctx.request.url;
return ctx.json({ method, url, contentType });
});Type Generics#
The Context class accepts two generic parameters for type-safe DI and params:
// Context<DI, Params>
// DI: type of the dependency injection container
// Params: type of route parameters (inferred from path)
app.get("/users/:id", (ctx) => {
// ctx.params.id is typed as string
// ctx.di is typed based on your DI setup
});Full Example#
import { z } from "@buntok/core/middlewares/validator";
const app = new Buntok();
app.get("/", (ctx) => {
return ctx.json({
ip: ctx.ip,
method: ctx.request.method,
url: ctx.request.url,
});
});
app.get("/search", zValidator("query", z.object({
q: z.string(),
page: z.coerce.number().default(1),
})), (ctx) => {
const { q, page } = ctx.valid("query");
return ctx.json({ query: q, page });
});
app.post("/users", zValidator("body", z.object({
name: z.string().min(1),
email: z.string().email(),
})), async (ctx) => {
const data = ctx.valid("body");
return ctx.success(data, "Created", 201);
});
app.get("/dashboard", (ctx) => {
const token = ctx.getCookie("token");
if (!token) return ctx.redirect("/login");
return ctx.json({ user: ctx.user });
});
app.listen(1212);Next Steps#
- Validation - Validate and type request data
- Routing - Route parameters and query strings
- Middleware - Access context in middleware
- SSE - Server-Sent Events