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#

PropertyTypeDescription
ctx.requestRequestRaw Bun Request object
ctx.paramsRecord<string, string>Route parameters (:id, *wildcard)
ctx.queryRecord<string, string>Parsed query string (lazily cached)
ctx.userRecord<string, unknown> | undefinedJWT payload (undefined before auth)
ctx.ipstringClient IP (reads X-Forwarded-For)
ctx.storeRecord<string, unknown>Per-request key-value store
ctx.diDIDependency injection container values

Route Parameters#

typescript
// 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["*"]); // shorthand

Query Parameters#

typescript
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=10

Client IP#

typescript
app.get("/ip", (ctx) => {
  return ctx.json({ ip: ctx.ip });
});
Info
ctx.ip reads from the X-Forwarded-For header (first value) if present, otherwise returns "127.0.0.1".

Per-Request Store#

typescript
// 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#

MethodReturnDescription
await ctx.body<T>()Promise<T>Parse JSON body (cached after first call)
await ctx.formData()Promise<FormData>Parse multipart/form-data (cached)
typescript
// 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 });
});
Info
Both ctx.body() and ctx.formData() cache their result. You can call them multiple times without re-parsing.

Cookies#

MethodReturnDescription
ctx.getCookie(name)string | undefinedGet a single cookie by name
ctx.getCookies()Record<string, string>Get all cookies as a map
typescript
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():

typescript
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: [] });
});
Warning
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:

ReturnSerialized asContent-TypeStatus
stringnew Response(string)text/plain; charset=utf-8200
number / boolean / bigintString(value)text/plain; charset=utf-8200
object / arrayResponse.json(value)application/json200
null / undefined / voidempty body-204
Blob / ArrayBuffer / Uint8Array / ReadableStreamnew Response(value)from value200
Responsepassthroughas-isas-is
typescript
// 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);
Info
Type 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#

MethodDescription
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#

typescript
// Default 200
return ctx.json({ message: "Hello" });

// Custom status
return ctx.json({ created: true }, 201);

Success Envelope#

typescript
// { success: true, message: "Success", data: [...] }
return ctx.success(users);

// { success: true, message: "Created", data: {...} }, 201
return ctx.success(user, "Created", 201);

Pagination#

typescript
// 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#

typescript
// { success: false, message: "Validation failed", details: [...] }
return ctx.error("Validation failed", 422, errors);

Redirect#

typescript
// 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:

typescript
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):

typescript
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";
});
Info
Sucrose detects 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#

typescript
app.get("/events", (ctx) => {
  return ctx.sse((stream) => {
    stream.sendData("connected");
    stream.sendEvent("update", { count: 42 });
    stream.onClose(() => {
      console.log("Client disconnected");
    });
  });
});
OptionTypeDefaultDescription
sendInitialbooleantrueSend initial "connected" event
initialEventstring"connected"Custom event name for initial
retrynumberundefinedClient reconnection timeout (ms)

Raw Request Access#

typescript
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:

typescript
// 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#

typescript

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