Routing#

Buntok provides a fast, type-safe routing system built on a trie-based data structure with AOT compilation. Routes are matched in O(1) for static paths and via efficient trie traversal for dynamic routes.

Basic Routes#

Register handlers for any HTTP method using the corresponding app method:

typescript
import { Buntok } from "@buntok/core";

const app = new Buntok();

// Classic style
app.get("/", (ctx) => {
  return ctx.json({ message: "Hello, World!" });
});

// Elysia-style flexible return (also supported)
app.get("/hello", () => "hello world");                    // text/plain
app.get("/json", () => ({ hello: "buntok" }));             // application/json
app.get("/users/:id", ({ params }) => params);               // { id: "123" }

// Static value handlers - response baked at registration, served natively
app.get("/ping", "pong");                                   // text/plain
app.get("/version", new Response("v2", { status: 200 }));   // Response passthrough

app.post("/users", async (ctx) => {
  const body = await ctx.body();
  return ctx.json({ created: true, ...body }, 201);
});

app.put("/users/:id", (ctx) => {
  return ctx.json({ updated: true });
});

app.delete("/users/:id", (ctx) => {
  return ctx.json({ deleted: true });
});

app.listen(1212);

A handler may also be a raw value (string or Response) instead of a function. The response is recorded at registration and served natively from Bun.serve routes - Bun answers the request without running JS per request. Promotion to native routes is skipped (the normal JS path handles it) when global or route middleware, request logging, dynamic paths, WebSocket paths, or non-standard methods are present.

HTTP Methods#

Buntok supports all standard HTTP methods plus the Bun-specific QUERY method:

MethodHandlerDescription
GETapp.get()Retrieve resources
POSTapp.post()Create resources
PUTapp.put()Replace resources
PATCHapp.patch()Partial update
DELETEapp.delete()Remove resources
OPTIONSapp.options()CORS preflight
HEADapp.head()Headers only (no body)
ALLapp.all()Matches all methods above
QUERYapp.query()Bun-specific: GET with body
Info
app.all() registers a handler for all 7 standard HTTP methods (GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS). It does not include QUERY.
Info
HEAD without a HEAD route: a HEAD request is answered by the GET route (body stripped, 200) when no explicit HEAD route is registered — same as Elysia, Hono, and Express. Paths without a GET route still return 404.
Warning
Route matching: Exact static routes are checked before dynamic routes, so app.get("/users/admin") takes precedence over app.get("/users/:id") regardless of registration order. If you register the same static method and path more than once, the later registration replaces the earlier handler.

Multiple Methods#

Use app.all() to handle multiple methods with a single handler:

typescript
app.all("/health", (ctx) => {
  return ctx.json({ status: "ok" });
});

Route Parameters#

Capture dynamic segments from the URL using the :param syntax. Parameters are extracted from the path and available on ctx.params.

typescript
// Single parameter - classic
app.get("/users/:id", (ctx) => {
  const { id } = ctx.params;
  return ctx.json({ userId: id });
});

// Single parameter - Elysia-style
app.get("/users/:id", ({ params }) => params);               // { id: "123" }
app.get("/users/:id", ({ params: { id } }) => id);          // "123" text/plain

// Multiple parameters
app.get("/posts/:postId/comments/:commentId", (ctx) => {
  const { postId, commentId } = ctx.params;
  return ctx.json({ postId, commentId });
});
app.get("/posts/:postId/comments/:commentId", ({ params }) => params);
Warning
Route parameters are always string. Use Number() or parseInt() to convert to a number.

Type-Safe Parameters#

Buntok uses TypeScript template literal types to infer route parameters at compile time. When you define a route like "/users/:id", the type of ctx.params is automatically { id: string }.

typescript
app.get("/users/:id", (ctx) => {
  // ctx.params is typed as { id: string }
  const { id } = ctx.params; // ✅ Type-safe
  const { name } = ctx.params; // ❌ Type error: 'name' does not exist
});

Wildcard Routes#

Use * to match any path after a prefix. The matched value is accessible via ctx.params["*"].

typescript
app.get("/files/*", (ctx) => {
  const path = ctx.params["*"];
  return ctx.json({ filePath: path });
});
app.get("/files/*", ({ params }) => params["*"]); // shorthand

// GET /files/documents/report.pdf
// Response: { "filePath": "documents/report.pdf" }
Info
Wildcards are useful for serving static files or catching all routes under a prefix.

Query Parameters#

Query parameters are parsed lazily from the URL and available on ctx.query. They are cached after first access.

typescript
app.get("/search", (ctx) => {
  const { q, page, limit } = ctx.query;
  return ctx.json({ query: q, page, limit });
});
app.get("/search", ({ query }) => query); // shorthand

// GET /search?q=buntok&page=1&limit=10
// Response: { "query": "buntok", "page": "1", "limit": "10" }
Warning
Query parameters are always string. For type-safe validation, use zValidator("query", schema).

Route Groups#

Group related routes under a common prefix using app.group(). Groups support nested grouping and shared middleware.

typescript
const api = app.group("/api");

api.get("/users", listUsers);
api.post("/users", createUser);
api.get("/users/:id", getUser);

// Registered routes:
// GET  /api/users
// POST /api/users
// GET  /api/users/:id

Nested Groups#

Groups can be nested. Child groups inherit the parent's prefix and middleware.

typescript
const api = app.group("/api");
const v1 = api.group("/v1");
const v2 = api.group("/v2");

v1.get("/users", listUsersV1);
v2.get("/users", listUsersV2);

// Registered routes:
// GET /api/v1/users
// GET /api/v2/users

Group Middleware#

Apply middleware to all routes in a group using group.use(). Group middleware runs after global middleware but before route-level middleware.

typescript
const api = app.group("/api");
api.use(cors);
api.use(rateLimit);

api.get("/users", listUsers);
api.post("/users", createUser);

// Execution order for GET /api/users:
// cors → rateLimit → listUsers

Middleware#

Middleware functions execute before the final handler. They receive the context and a next function to pass control forward.

Route-Level Middleware#

typescript
const auth = async (ctx, next) => {
  const token = ctx.getCookie("token");
  if (!token) return ctx.json({ error: "Unauthorized" }, 401);
  // verify token...
  return next();
};

app.get("/admin", auth, (ctx) => {
  return ctx.json({ secret: "data" });
});

// Multiple middleware
app.get("/admin/settings", auth, checkRole("admin"), (ctx) => {
  return ctx.json({ settings: {} });
});

Global Middleware#

Use app.use() to register middleware that runs for every request, regardless of route.

typescript
// Runs for every request
app.use(logger);
app.cors({ origin: "*" });  // Use app.cors() for CORS

// Execution order:
// logger → cors → route-level middleware → handler

Execution Order#

Middleware executes in this order:

  1. Global middleware (app.use())
  2. Group middleware (group.use())
  3. Route-level middleware (inline or @Use() decorator)
  4. Final handler
Info
All middleware pipelines are AOT-compiled into a single function at boot time, eliminating runtime overhead from chaining.

Static File Serving#

Serve static files from a directory using app.static().

typescript
// Serve files from ./public at /public/*
app.static("/public", "./public");

// Serve files from ./assets at /files/*
app.static("/files", "./assets");

Security#

Directory traversal attacks (e.g., /files/../../etc/passwd) are automatically blocked. Requests that escape the base directory return 403 Forbidden.

Caching#

Static file responses include Cache-Control: public, max-age=3600 (1 hour). ETag support is also included - if the client sends If-None-Match and the ETag matches, the server returns 304 Not Modified.

WebSocket#

Register WebSocket endpoints using app.ws(). WebSocket paths must be static (no parameters or wildcards).

typescript
app.ws("/chat", {
  open: (ws) => {
    ws.send(JSON.stringify({ type: "connected" }));
  },
  message: (ws, message) => {
    // Broadcast to all connected clients
    ws.publish("chat", message);
  },
  close: (ws, code, reason) => {
    console.log("Client disconnected:", code);
  },
});
Info
WebSocket uses Bun's native WebSocket server. The ws.data.ctx provides access to the request context, cookies, and DI container.

Error Handling#

Override the global error and 404 handlers using app.onError() and app.notFound().

typescript
// Custom error handler
app.onError((err, ctx) => {
  console.error(err);
  return ctx.json(
    { success: false, error: err.message },
    500
  );
});

// Custom 404 handler
app.notFound((ctx) => {
  return ctx.json(
    { success: false, error: "Not Found", path: ctx.request.url },
    404
  );
});

Built-in Error Classes#

Throw HTTP errors to trigger the error handler with the appropriate status code:

Error ClassStatus
BadRequestError400
UnauthorizedError401
ForbiddenError403
NotFoundError404
MethodNotAllowedError405
ConflictError409
UnprocessableEntityError422
TooManyRequestsError429
InternalServerError500
ServiceUnavailableError503
typescript
import { NotFoundError, ForbiddenError } from "@buntok/core";

app.get("/users/:id", async (ctx) => {
  const user = await findUser(ctx.params.id);
  if (!user) throw new NotFoundError("User not found");
  if (!canView(user, ctx.user)) throw new ForbiddenError();
  return ctx.json(user);
});

Performance#

Buntok's router is built for speed:

  • Static routes - Stored in a flat Map for O(1) lookup
  • Dynamic routes - Trie-based with efficient JS implementation (optimized over native FFI)
  • LRU cache - 2048-entry cache avoids re-traversal for repeated paths
  • AOT compilation - Middleware pipelines compiled into single functions at boot

API Reference#

MethodDescription
app.get(path, ...handlers)Register GET route
app.post(path, ...handlers)Register POST route
app.put(path, ...handlers)Register PUT route
app.patch(path, ...handlers)Register PATCH route
app.delete(path, ...handlers)Register DELETE route
app.options(path, ...handlers)Register OPTIONS route
app.head(path, ...handlers)Register HEAD route
app.all(path, ...handlers)Register all HTTP methods
app.query(path, ...handlers)Register QUERY (Bun-specific)
app.group(prefix)Create a route group
app.static(routePath, dir)Serve static files
app.ws(path, handler)Register WebSocket endpoint
app.use(middleware)Register global middleware
app.onError(handler)Override error handler
app.notFound(handler)Override 404 handler

Next Steps#