Decorators#
Buntok uses Stage 3 TC39 decorators - the standard decorators natively supported by Bun and TypeScript 5+ without the experimentalDecorators flag.
registerRoute() calls as manual app.get()/app.post() registrations.experimentalDecorators: true is set in your tsconfig.json, every Buntok decorator throws a guidance error at class-definition time: "Buntok decorators require TC39 decorator mode (native in Bun/TS 5+). Legacy decorator mode detected - remove experimentalDecorators: true from your tsconfig.json." Remove the flag; Stage 3 decorators are the native default in TypeScript 5+ and Bun.@Controller#
Marks a class as a controller and sets a route prefix. Must be the outermost (topmost) decorator on the class.
@Controller("/users")
class UserController {
// All routes here will be prefixed with /users
}| Parameter | Type | Description |
|---|---|---|
| prefix | string | Route prefix (default: "") |
@Controller must be the topmost decorator. Stage 3 (TC39) decorators are evaluated top-to-bottom but applied bottom-to-top, so @Controller (topmost, evaluated first) is applied last and captures all method routes already registered by @Get, @Post, etc.Route Decorators#
Decorate class methods to register them as route handlers. Each decorator takes a path string as its first argument.
import type { Context } from "@buntok/core";
@Controller("/users")
class UserController {
@Get("/")
list(ctx: Context) { /* GET /users */ }
@Get("/:id")
detail(ctx: Context) { /* GET /users/:id */ }
@Post("/")
create(ctx: Context) { /* POST /users */ }
@Put("/:id")
update(ctx: Context) { /* PUT /users/:id */ }
@Patch("/:id")
patch(ctx: Context) { /* PATCH /users/:id */ }
@Delete("/:id")
remove(ctx: Context) { /* DELETE /users/:id */ }
@Options("/info")
options(ctx: Context) { /* OPTIONS /users/info */ }
@Head("/check")
check(ctx: Context) { /* HEAD /users/check */ }
@All("/health")
health(ctx: Context) { /* All methods on /users/health */ }
}| Decorator | HTTP Method | Notes |
|---|---|---|
| @Get(path?) | GET | Retrieve resources |
| @Post(path?) | POST | Create resources |
| @Put(path?) | PUT | Replace resources |
| @Patch(path?) | PATCH | Partial update |
| @Delete(path?) | DELETE | Remove resources |
| @Options(path?) | OPTIONS | CORS preflight |
| @Head(path?) | HEAD | Headers only |
| @All(path?) | All standard | Matches all methods |
| @Query(path?) | QUERY | Bun-specific: GET with body |
@Query#
Bun-specific HTTP method - like GET but with a request body (RFC 9110). Useful for complex search queries that don't fit in query params.
import type { Context } from "@buntok/core";
@Controller("/search")
class SearchController {
@Query("/")
async search(ctx: Context) {
const body = await ctx.body(); // { filters: [...], sort: "date" }
const results = await db.search(body);
return ctx.json(results);
}
}@Use#
Attach middleware to a single route. Multiple @Use() calls stack in declaration order.
import type { Context } from "@buntok/core";
const auth = async (ctx: Context, next) => {
const token = ctx.getCookie("token");
if (!token) return ctx.json({ error: "Unauthorized" }, 401);
return next();
};
const log = async (ctx: Context, next) => {
console.log(ctx.request.method, ctx.request.url);
return next();
};
@Controller("/users")
class UserController {
@Get("/public")
publicEndpoint(ctx: Context) {
return ctx.json({ data: "public" });
}
@Get("/private")
@Use(auth)
@Use(log)
privateEndpoint(ctx: Context) {
return ctx.json({ data: "private" });
}
}
// Execution order for GET /users/private:
// auth → log → privateEndpoint| Parameter | Type | Description |
|---|---|---|
| middleware | (ctx, next) => Response | Middleware function receiving context and next |
With zValidator#
import { zValidator, z } from "@buntok/core/middlewares/validator";
import type { ZodCtx } from "@buntok/core";
const userSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
@Controller("/users")
class UserController {
@Post("/")
@Use(zValidator("body", userSchema))
async create(ctx: ZodCtx<{ body: typeof userSchema }>) {
const data = ctx.valid("body"); // Fully typed!
return ctx.json({ created: true, ...data }, 201);
}
@Get("/search")
@Use(zValidator("query", searchSchema))
search(ctx: ZodCtx<{ query: typeof searchSchema }>) {
const { q } = ctx.valid("query");
return ctx.json({ results: [] });
}
}Response Decorators#
Zero-cost boot-time metadata - no per-request overhead. Configure status, headers and redirects without touching handler logic.
import { HttpCode, SetHeader, Redirect, Version } from "@buntok/core";
@Controller("/users")
class UserController {
@Post("/")
@HttpCode(201)
create() { return { id: 1 }; } // 201 instead of 200
@Get("/")
@SetHeader("x-cache", "hit")
list() { return []; } // header x-cache: hit
@Get("/old")
@Redirect("/new", 301)
old() {} // 301 -> /new, handler ignored; return {url: "/other"} to override
}@Header is deprecated alias to @SetHeader (same reference). toResponse() handles flexible return status/header/redirect composition.@SetMetadata & @Public#
Attach arbitrary metadata to handlers (read via getMetadata in guards/interceptors). @Public() is shorthand for SetMetadata("isPublic", true).
import { SetMetadata, Public } from "@buntok/core";
import { getMetadata } from "@buntok/core/helpers";
@SetMetadata("roles", ["admin"])
@Get("/admin")
admin() {}
@Public()
@Get("/health")
health() { return { ok: true }; }
// In guard
const roles = getMetadata(AdminController, "admin", "roles");applyDecorators#
Compose multiple decorators (Nest applyDecorators alias) - boot-time helper.
import { applyDecorators, SetMetadata, UseGuard } from "@buntok/core";
const Auth = (...roles: string[]) => applyDecorators(
SetMetadata("roles", roles),
UseGuard((ctx) => checkRoles(ctx, roles))
);
@Auth("admin")
@Get("/secret")
secret() {}@UseGuard / @UseGuards#
A convenience decorator for boolean authorization checks. A guard is a function that returns boolean or Promise<boolean>. If any guard returns false, the request is rejected with 403 Forbidden. UseGuards is the recommended plural alias - UseGuard remains deprecated but same reference.
import type { Context } from "@buntok/core";
const isAdmin = (ctx: Context) => ctx.user?.role === "admin";
const isActive = (ctx: Context) => ctx.user?.status === "active";
const hasApiKey = async (ctx: Context) => {
const key = ctx.request.headers.get("x-api-key");
const valid = await validateApiKey(key);
return valid;
};
@Controller("/admin")
class AdminController {
@Get("/dashboard")
@UseGuard(isAdmin)
dashboard(ctx: Context) {
return ctx.json({ secret: "admin data" });
}
@Get("/reports")
@UseGuard(isAdmin, isActive)
reports(ctx: Context) {
return ctx.json({ reports: [] });
}
@Get("/api-data")
@UseGuard(hasApiKey)
apiData(ctx: Context) {
return ctx.json({ data: "sensitive" });
}
}| Parameter | Type | Description |
|---|---|---|
| ...guards | (ctx) => boolean | Promise<boolean> | One or more guard functions (rest parameters) |
@UseGuard is syntactic sugar for @Use. It creates an async middleware that runs guards sequentially and returns 403 on failure. Guards are awaited, so async guards (e.g., database lookups) are fully supported.@Injectable & @Inject have been removed in v2.1+. Use Container.register with useClass/useFactory + scope for DI. See the IoC Container page.Decorator Ordering#
Stage 3 decorators: factories are evaluated top-to-bottom but applied bottom-to-top. @Controller is evaluated first (topmost) but applied last, so it captures all routes.
import type { Context } from "@buntok/core";
@Controller("/users") // 4. Evaluated 1st, applied 4th → captures all
class UserController {
@Get("/") // 3. Evaluated 4th, applied 1st → creates entry
@Use(auth) // 2. Evaluated 2nd, applied 3rd → unshift → runs 1st
@Use(log) // 1. Evaluated 3rd, applied 2nd → unshift → runs 2nd
async list(ctx: Context) {
return ctx.json([]);
}
}
// Evaluate: Controller → auth → log → Get (top-to-bottom)
// Apply: Get → log → auth → Controller (bottom-to-top)
// Execution order: auth → log → list@Controller is not the topmost decorator, it will not capture the routes correctly. Always place it first.Full Example#
import {
Buntok, Controller, Get, Post, Use, UseGuard, Dependencies,
Container,
} from "@buntok/core";
import { zValidator, z } from "@buntok/core/middlewares/validator";
import type { Context, ZodCtx } from "@buntok/core";
// Service - plain class, no decorator
class UserService {
async findAll() {
return await db.user.findMany();
}
async create(data: CreateUser) {
return await db.user.create({ data });
}
}
// Middleware
const auth = async (ctx: Context, next) => {
const token = ctx.getCookie("token");
if (!token) return ctx.json({ error: "Unauthorized" }, 401);
return next();
};
// Guards
const isAdmin = (ctx: Context) => ctx.user?.role === "admin";
// Schema
const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
// Controller - @Dependencies declares constructor dependencies
@Dependencies(UserService)
@Controller("/users")
class UserController {
constructor(private service: UserService) {}
@Get("/")
async list(ctx: Context) {
const users = await this.service.findAll();
return ctx.json(users);
}
@Post("/")
@Use(auth)
@UseGuard(isAdmin)
@Use(zValidator("body", createUserSchema))
async create(ctx: ZodCtx<{ body: typeof createUserSchema }>) {
const data = ctx.valid("body");
const user = await this.service.create(data);
return ctx.json({ created: true, ...user }, 201);
}
}
// Setup - container.scan() resolves the entire dependency tree
const app = new Buntok();
const container = new Container();
container.scan([UserController]); // auto-resolves UserService → UserController
app.setContainer(container);
app.registerController(UserController);
app.listen(1212);Next Steps#
- Controllers - Class-based routing
- IoC Container - Dependency injection
- Validation - Zod validation
- Middleware - Middleware patterns