Decorators#

Buntok uses Stage 3 TC39 decorators - the standard decorators natively supported by Bun and TypeScript 5+ without the experimentalDecorators flag.

Info
Decorators execute once at class-definition time (bootstrap), not per request. This means zero runtime overhead - the router produces the same registerRoute() calls as manual app.get()/app.post() registrations.
Warning
Legacy decorator mode breaks decorators. If 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.

typescript
@Controller("/users")
class UserController {
  // All routes here will be prefixed with /users
}
ParameterTypeDescription
prefixstringRoute prefix (default: "")
Warning
@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.

typescript
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 */ }
}
DecoratorHTTP MethodNotes
@Get(path?)GETRetrieve resources
@Post(path?)POSTCreate resources
@Put(path?)PUTReplace resources
@Patch(path?)PATCHPartial update
@Delete(path?)DELETERemove resources
@Options(path?)OPTIONSCORS preflight
@Head(path?)HEADHeaders only
@All(path?)All standardMatches all methods
@Query(path?)QUERYBun-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.

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

typescript
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
ParameterTypeDescription
middleware(ctx, next) => ResponseMiddleware function receiving context and next

With zValidator#

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

typescript
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
}
Info
@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).

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

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

typescript
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" });
  }
}
ParameterTypeDescription
...guards(ctx) => boolean | Promise<boolean>One or more guard functions (rest parameters)
Info
@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.
Warning
@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.

typescript
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
Warning
If @Controller is not the topmost decorator, it will not capture the routes correctly. Always place it first.

Full Example#

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