Skip to content

Decorators API ​

Package: @onebun/core

Module Decorators ​

@Module() ​

Defines a module that groups controllers, services, and imports. See Architecture — Module System for concepts and lifecycle details.

typescript
@Module(options: ModuleOptions)

ModuleOptions:

typescript
interface ModuleOptions {
  /** Other modules to import (their exported services become available) */
  imports?: Function[];

  /** Controller classes to register */
  controllers?: Function[];

  /** Service classes to register as providers */
  providers?: unknown[];

  /** Services to export to parent modules */
  exports?: unknown[];
}

Example:

typescript
import { Module } from '@onebun/core';
import { CacheModule } from '@onebun/cache';
import { UserController } from './user.controller';
import { UserService } from './user.service';

@Module({
  imports: [CacheModule],
  controllers: [UserController],
  providers: [UserService],
  exports: [UserService],
})
export class UserModule {}

exports accepts services only. Listing a MODULE there — the NestJS re-export idiom — throws OneBunInvalidExportError naming both modules. A re-exported module contributes nothing to the importer — a module's services reach only the modules that import it directly (or every module, for a @Global() one) — so the error names the mistake at boot instead of leaving the importer to fail at a controller with no mention of the export. Import the module that provides the service directly wherever the service is needed:

typescript
// Does not work — throws OneBunInvalidExportError at boot
@Module({ imports: [CoreModule], exports: [CoreModule] })
export class FeatureModule {}

// Import the providing module where the service is needed
@Module({ imports: [CoreModule], providers: [UserService] })
export class FeatureModule {}

imports must not form a cycle. A module that imports itself — directly, or through the modules it imports — fails start() with OneBunModuleImportCycleError (a OneBunBootstrapError). Each imported module is built before its importer, so a cycle leaves no module to build first. The message lists the cycle in import order and, when the cycle does not start at the root module, the import path the application took to reach it. A cycle always fails this way, never as a RangeError: Maximum call stack size exceeded.

typescript
// Throws at start(): "Module import cycle: UsersModule -> UsersModule. ...
// Remove UsersModule from its own imports."
@Module({ imports: [UsersModule] })
export class UsersModule {}

A longer cycle — Module import cycle: OrdersModule -> BillingModule -> OrdersModule — is fixed the same way whatever its length: move what the modules on the cycle share into a module that imports none of them, and import that one from each. Split across files, such a cycle usually fails earlier, while the files load, with ReferenceError: Cannot access 'OrdersModule' before initialization.

@Global() ​

Marks a module as global. Global modules export their providers to all modules automatically without explicit import. This is useful for modules that provide cross-cutting concerns like database access or caching.

typescript
@Global()

Example:

typescript
import { Module, Global } from '@onebun/core';
import { DatabaseService } from './database.service';

@Global()
@Module({
  providers: [DatabaseService],
  exports: [DatabaseService],
})
export class DatabaseModule {}

// Now DatabaseService is available in ALL modules without importing DatabaseModule
@Module({
  controllers: [UserController],
  providers: [UserService], // UserService can inject DatabaseService
})
export class UserModule {}

Related Functions:

typescript
// Check if a module is global
function isGlobalModule(target: Function): boolean;

// Remove module from global registry (used internally by forRoot({ isGlobal: false }))
function removeFromGlobalModules(target: Function): void;

The registry is process-wide. It holds one entry per module CLASS with no application dimension, so removeFromGlobalModules() changes globality for every application in the process, not just yours. It is not the multi-database mechanism — forRoot({ as: TOKEN }) with forFeature(TOKEN) is.

Two unnamed forRoot() calls that disagree are refused. When a process configures the same dynamic module twice without a token, and the two calls differ in isGlobal or in what they configure, an application that imports that module fails at start() with OneBunConflictingRegistrationError, naming both call sites. Letting the call evaluated last decide for everyone would boot an application that wrote isGlobal: false with ambient injection anyway, or point two applications naming different databases at one of them. The check is per application — one that never imports the contested module boots normally — and two calls that agree stay silent.

Controller Decorators ​

@Controller() ​

Marks a class as an HTTP controller with a base path.

typescript
@Controller(basePath?: string)

Example:

typescript
import { Controller, BaseController } from '@onebun/core';

@Controller('/api/users')
export class UserController extends BaseController {
  // All routes will be prefixed with /api/users
}

Note: Leading slash is optional. @Controller('api/users') and @Controller('/api/users') are equivalent — the framework normalizes the path automatically.

HTTP Method Decorators ​

@Get(), @Post(), @Put(), @Delete(), @Patch(), @Options(), @Head(), @All() ​

Define HTTP endpoints on controller methods.

typescript
@Get(path?: string, options?: RouteOptions)
@Post(path?: string, options?: RouteOptions)
@Put(path?: string, options?: RouteOptions)
@Delete(path?: string, options?: RouteOptions)
@Patch(path?: string, options?: RouteOptions)
@Options(path?: string, options?: RouteOptions)
@Head(path?: string, options?: RouteOptions)
@All(path?: string, options?: RouteOptions)

interface RouteOptions {
  /** Per-request idle timeout in seconds. Set to 0 to disable. */
  timeout?: number;
}

Note: The leading slash in the path argument is optional. @Get('users') and @Get('/users') are equivalent. Similarly, @Get(':id') is the same as @Get('/:id'). This makes migration from NestJS seamless — NestJS-style paths work out of the box.

Per-request timeout:

Override the global idleTimeout for individual routes. Useful for long-running endpoints:

typescript
@Controller('/tasks')
export class TaskController extends BaseController {
  @Post('/process', { timeout: 300 }) // 5 minutes for this endpoint
  async processTask(@Body() body: unknown) {
    // long-running task...
  }

  @Get('/export', { timeout: 0 }) // no timeout
  async exportAll() {
    // very long export...
  }
}

Path Parameters:

Use :paramName syntax for dynamic segments:

typescript
@Controller('/users')
export class UserController extends BaseController {
  @Get('/')              // GET /users
  async findAll() {}

  @Get('/:id')           // GET /users/123
  async findOne(@Param('id') id: string) {}

  @Get('/:userId/posts') // GET /users/123/posts
  async getUserPosts(@Param('userId') userId: string) {}

  @Post('/')             // POST /users
  async create(@Body() body: CreateUserDto) {}

  @Put('/:id')           // PUT /users/123
  async update(@Param('id') id: string, @Body() body: UpdateUserDto) {}

  @Delete('/:id')        // DELETE /users/123
  async remove(@Param('id') id: string) {}
}

@All() — catch-all routes ​

@All() is a true catch-all, like NestJS router.all(): the decorated handler answers every HTTP method on that path, not just the seven with a decorator of their own. That includes OPTIONS and HEAD, and it includes methods the framework has no decorator for at all — PROPFIND, PURGE, LOCK, QUERY, vendor verbs. This is what makes @All() usable for reverse proxies, webhook receivers and legacy-path shims.

typescript
@Controller('/gateway')
export class GatewayController extends BaseController {
  @All('/proxy/:id')
  async proxy(@Param('id') id: string, @Req() req: OneBunRequest) {
    // req.method is whatever the client sent: GET, POST, PROPFIND, QUERY, ...
    return { id, method: req.method };
  }
}

Precedence — an explicitly declared verb always wins. If the same path carries both @All() and a concrete verb decorator, the concrete one handles its verb and @All() handles everything else. The rule is fixed; it does not depend on the order the decorators are written in:

typescript
@Controller('/webhooks')
export class WebhookController extends BaseController {
  @All('/github')
  async fallback() {
    return { handled: 'all' };   // PUT, DELETE, PROPFIND, ... land here
  }

  @Get('/github')
  async health() {
    return { handled: 'get' };   // GET lands here, not in fallback()
  }
}

CORS preflight. Because an @All() path claims OPTIONS, a preflight request reaches that route's middleware chain. When the application is configured with cors, the built-in CorsMiddleware runs first and answers the preflight with 204 — the @All() handler body never runs. Without cors configured, the @All() handler itself answers OPTIONS.

OpenAPI. An @All() route has no single HTTP method, so it cannot be expressed as one OpenAPI operation. Document the individual verbs you care about with concrete decorators, or exclude the route from the generated spec.

Parameter Decorators ​

All parameter decorators support an options object to control whether the parameter is required:

typescript
interface ParamDecoratorOptions {
  required?: boolean;
}

@Param() ​

Extract path parameter from URL. Path parameters are always required per OpenAPI specification.

typescript
@Param(name: string, schema?: Type<unknown>)

Example:

typescript
import { type } from '@onebun/core';

const idSchema = type('string.uuid');

@Get('/:id')
async findOne(
  @Param('id') id: string,                    // Always required (OpenAPI spec)
  @Param('id', idSchema) id: string,          // With validation, always required
) {}

@Query() ​

Extract query parameter from URL. Optional by default.

typescript
@Query(name: string, options?: ParamDecoratorOptions)
@Query(name: string, schema?: Type<unknown>, options?: ParamDecoratorOptions)

Example:

typescript
// GET /users?page=1&limit=10
@Get('/')
async findAll(
  @Query('page') page?: string,                        // Optional (default)
  @Query('limit', { required: true }) limit: string,   // Explicitly required
) {}

// With validation schema
@Get('/search')
async search(
  @Query('q', type('string')) query?: string,                      // Optional with validation
  @Query('sort', type('string'), { required: true }) sort: string, // Required with validation
) {}

@Body() ​

Extract and optionally validate request body. Required is determined from schema - if the schema accepts undefined, the body is optional; otherwise it's required.

typescript
@Body(schema?: Type<unknown>, options?: ParamDecoratorOptions)

Example:

typescript
import { type } from '@onebun/core';

const createUserSchema = type({
  name: 'string',
  email: 'string.email',
  'age?': 'number > 0',
});
type CreateUserBody = typeof createUserSchema.infer;

// Schema doesn't accept undefined → required
@Post('/')
async create(
  @Body(createUserSchema) body: CreateUserBody,
) {}

// Schema accepts undefined → optional
const optionalBodySchema = type({
  name: 'string',
}).or(type.undefined);
type OptionalBody = typeof optionalBodySchema.infer;

@Post('/optional')
async createOptional(
  @Body(optionalBodySchema) body: OptionalBody,
) {}

// Explicit override
@Post('/force-optional')
async forceOptional(
  @Body(createUserSchema, { required: false }) body: CreateUserBody,
) {}

// Without validation - body is unknown
@Post('/simple')
async createSimple(
  @Body() body: unknown,
) {}

Extract header value. Optional by default.

typescript
@Header(name: string, options?: ParamDecoratorOptions)
@Header(name: string, schema?: Type<unknown>, options?: ParamDecoratorOptions)

Example:

typescript
@Get('/protected')
async protected(
  @Header('X-Request-ID') requestId?: string,                      // Optional (default)
  @Header('Authorization', { required: true }) auth: string,       // Explicitly required
) {}

// With validation schema
@Get('/api')
async api(
  @Header('X-API-Key', type('string'), { required: true }) apiKey: string,
) {}

Extract cookie value from request. Uses BunRequest.cookies (CookieMap) under the hood. Optional by default.

typescript
@Cookie(name: string, options?: ParamDecoratorOptions)
@Cookie(name: string, schema?: Type<unknown>, options?: ParamDecoratorOptions)

Example:

typescript
// GET /api/me (with Cookie: session=abc123; theme=dark)
@Get('/me')
async getMe(
  @Cookie('session') session?: string,                       // Optional (default)
  @Cookie('session', { required: true }) session: string,    // Explicitly required
) {}

// With validation schema
@Get('/prefs')
async prefs(
  @Cookie('theme', type('"light" | "dark"')) theme?: string,  // Optional with validation
) {}

@Req() ​

Inject the raw request object. The type is OneBunRequest (alias for BunRequest), which extends the standard Web API Request with:

  • .cookies — a CookieMap for reading and setting cookies
  • .params — route parameters extracted by Bun's routes API

A handler parameter without a param decorator is not injected. What it receives depends on how the rest of the handler is written: on a route whose handler has no decorated parameter and no response schema the framework calls the handler with the request, so the first parameter happens to be it; on any other route the framework fills only the decorated positions and an undecorated one is undefined. Adding a single @Query() therefore flips the meaning of every other parameter on that handler. The framework reports each undecorated parameter at startup, by controller, handler and 0-based index — Function.length stops counting at the first defaulted or rest parameter, so a gap after one of those is not reported. Decorate the parameter with @Req() and the question does not arise.

typescript
@Req()

Example:

typescript
import type { OneBunRequest } from '@onebun/core';

@Get('/raw')
async handleRaw(@Req() req: OneBunRequest) {
  const url = new URL(req.url);
  const headers = Object.fromEntries(req.headers);

  // Access cookies via CookieMap
  const session = req.cookies.get('session');

  // Access route params (populated by Bun routes API)
  // For route '/users/:id', req.params.id is available
}

@Res() (deprecated) ​

Deprecated

@Res() is deprecated and currently injects undefined. Use return new Response(...) from your handler instead. Direct response manipulation is not supported — return a Response object to set custom headers, status codes, and cookies.

typescript
@Res()

createHttpParamDecorator() ​

Build your own parameter decorator from a function of the execution context.

typescript
import { createHttpParamDecorator, getRequestContext } from '@onebun/core';

const CurrentUser = createHttpParamDecorator(() => getRequestContext()?.user);

@Get('/me')
me(@CurrentUser() user: AuthenticatedUser | undefined) {
  return user;
}

The extractor — a ParamExtractor, which is (context: HttpExecutionContext) => unknown — receives the same object a guard gets and returns the argument. It runs once per decorated parameter per request, and one context is built per request however many custom parameters a handler declares.

The case it exists for is per-request state a middleware or guard produced: the authenticated user, the tenant, a decoded token. Those live in the request context, which is the only place @Req() cannot reach. Anything an extractor could pull straight off the request is one property access away from @Req(), so a decorator that only does that buys an import rather than an abstraction.

Per-use arguments need no extra machinery — close over them:

typescript
const Claim = (name: string) => createHttpParamDecorator((ctx) => readClaim(ctx, name))();

@Get('/tenant')
tenant(@Claim('tid') tid: string) { return tid; }

HTTP only

@UseGuards is the same decorator on all three transports; parameter decorators are not. A WebSocket handler reads its own metadata key with its own parameter types, and a queue handler has no parameter machinery at all — so a decorator built here on an @OnMessage or @Subscribe method is not an error, it is simply never read, and the parameter arrives undefined. The name says Http for that reason.

A custom parameter carries no schema and is never required: it is not validated, it does not appear in the OpenAPI document, and the generated service client does not take it as an argument. Validate inside the extractor if you need to. The extractor must be a pure view over data the request already carries — it runs on the request path and nothing unwinds what it allocates.

File Upload Decorators ​

Decorators for handling file uploads via multipart/form-data or JSON with base64-encoded data. The framework auto-detects the content type and provides a unified OneBunFile object.

@UploadedFile() ​

Extracts a single file from the request. Required by default.

typescript
@UploadedFile(fieldName?: string, options?: FileUploadOptions)

FileUploadOptions:

typescript
interface FileUploadOptions {
  /** Maximum file size in bytes */
  maxSize?: number;
  /** Allowed MIME types, supports wildcards like 'image/*'. Use MimeType enum. */
  mimeTypes?: string[];
  /** Whether the file is required (default: true) */
  required?: boolean;
}

Example:

typescript
import { Controller, Post, UploadedFile, MimeType, OneBunFile, BaseController } from '@onebun/core';

@Controller('/api/files')
export class FileController extends BaseController {
  @Post('/avatar')
  async uploadAvatar(
    @UploadedFile('avatar', {
      maxSize: 5 * 1024 * 1024,
      mimeTypes: [MimeType.ANY_IMAGE],
    }) file: OneBunFile,
  ) {
    await file.writeTo(`./uploads/${file.name}`);
    return { filename: file.name, size: file.size };
  }
}

@UploadedFiles() ​

Extracts multiple files from the request. Required by default (at least one file expected).

typescript
@UploadedFiles(fieldName?: string, options?: FilesUploadOptions)

FilesUploadOptions:

typescript
interface FilesUploadOptions extends FileUploadOptions {
  /** Maximum number of files allowed */
  maxCount?: number;
}

Example:

typescript
@Post('/documents')
async uploadDocs(
  @UploadedFiles('docs', { maxCount: 10 }) files: OneBunFile[],
) {
  for (const file of files) {
    await file.writeTo(`./uploads/${file.name}`);
  }
  return { count: files.length };
}

// All files from request (no field name filter)
@Post('/batch')
async uploadBatch(
  @UploadedFiles(undefined, { maxCount: 20 }) files: OneBunFile[],
) {
  return { count: files.length };
}

@FormField() ​

Extracts a non-file form field from the request. Optional by default.

typescript
@FormField(fieldName: string, options?: ParamDecoratorOptions)

Example:

typescript
@Post('/profile')
async createProfile(
  @UploadedFile('avatar', { mimeTypes: [MimeType.ANY_IMAGE] }) avatar: OneBunFile,
  @FormField('name', { required: true }) name: string,
  @FormField('email') email: string,
) {
  await avatar.writeTo(`./uploads/${avatar.name}`);
  return { name, email, avatar: avatar.name };
}

OneBunFile ​

Unified file wrapper returned by @UploadedFile and @UploadedFiles. Works the same regardless of upload method (multipart or JSON+base64).

typescript
class OneBunFile {
  readonly name: string;          // File name
  readonly size: number;          // File size in bytes
  readonly type: string;          // MIME type
  readonly lastModified: number;  // Last modified timestamp

  async toBase64(): Promise<string>;        // Convert to base64 string
  async toBuffer(): Promise<Buffer>;        // Convert to Buffer
  async toArrayBuffer(): Promise<ArrayBuffer>; // Convert to ArrayBuffer
  toBlob(): Blob;                           // Get underlying Blob
  async writeTo(path: string): Promise<void>; // Write to disk

  static fromBase64(data: string, filename?: string, mimeType?: string): OneBunFile;
}

MimeType Enum ​

Common MIME types for use with file upload options:

typescript
import { MimeType } from '@onebun/core';

// Wildcards
MimeType.ANY           // '*/*'
MimeType.ANY_IMAGE     // 'image/*'
MimeType.ANY_VIDEO     // 'video/*'
MimeType.ANY_AUDIO     // 'audio/*'

// Images
MimeType.PNG, MimeType.JPEG, MimeType.GIF, MimeType.WEBP, MimeType.SVG

// Documents
MimeType.PDF, MimeType.JSON, MimeType.XML, MimeType.ZIP, MimeType.CSV, MimeType.XLSX, MimeType.DOCX

// Video/Audio
MimeType.MP4, MimeType.WEBM, MimeType.MP3, MimeType.WAV

// Text
MimeType.PLAIN, MimeType.HTML, MimeType.CSS, MimeType.JAVASCRIPT

// Binary
MimeType.OCTET_STREAM

JSON Base64 Upload Format ​

When sending files via application/json, the framework accepts two formats:

typescript
// Full format with metadata
{ "avatar": { "data": "iVBORw0KGgo...", "filename": "photo.png", "mimeType": "image/png" } }

// Simplified format (raw base64 string)
{ "avatar": "iVBORw0KGgo..." }

The same @UploadedFile decorator works for both multipart and JSON uploads.

WARNING

@Body() cannot be used together with @UploadedFile, @UploadedFiles, or @FormField on the same method, since both consume the request body.

Service Decorators ​

@Service() ​

Marks a class as an injectable service.

typescript
@Service(tag?: Context.Tag<T, T>)

Example:

typescript
import { Service, BaseService } from '@onebun/core';

@Service()
export class UserService extends BaseService {
  // Service with auto-generated tag

  async findAll(): Promise<User[]> {
    this.logger.info('Finding all users');
    // ...
  }
}

// With custom Effect.js tag
import { Context } from 'effect';

const CustomServiceTag = Context.GenericTag<CustomService>('CustomService');

@Service(CustomServiceTag)
export class CustomService extends BaseService {
  // Service with explicit tag
}

@Inject() ​

Explicit dependency injection for edge cases. In most cases, automatic DI works without this decorator. See Architecture — Dependency Injection for how DI resolution works.

typescript
@Inject(type: new (...args: any[]) => T)
@Inject(token: symbol | string)

When to use @Inject:

  • A parameter typed as an interface — pass the CONCRETE class, never the interface itself. An interface has no runtime value, so @Inject(SomeInterface) does not compile, and an interface-typed parameter with no @Inject receives undefined
  • Token-based injection (custom Context.Tag)
  • Overriding automatic resolution
  • Picking WHICH named registration a parameter gets, in a module that selected two of them

An abstract-class-typed parameter needs no @Inject: DI resolves it to a registered subclass on its own, and @Inject(AbstractClass) is a compile error — see Architecture for the exact diagnostics.

An unresolvable parameter is undefined, in its own slot

A parameter the container cannot name — an interface, any, unknown, a type alias — receives undefined and the framework logs which parameter index it was. It does not shift the parameters after it; those keep their own values. When a parameter that does resolve follows it, the index is also reported as a startup warn; a trailing one is logged at debug only.

This is about the ARGUMENT the constructor receives. The design:paramtypes entry for that same parameter is Object, not undefined — see Auditing constructor parameters before writing a sweep over your own codebase.

Example:

typescript
@Controller('/users')
export class UserController extends BaseController {
  constructor(
    // Automatic injection (works in most cases) - no @Inject needed
    private userService: UserService,
    private cacheService: CacheService,

    // @Inject needed only for edge cases:
    // - When the parameter is typed as an INTERFACE: name the implementation here, because
    //   the interface itself has no runtime value for the container to look up
    // - When using custom Effect.js Context.Tag
    @Inject(EmailNotifier) private notifier: Notifier,
  ) {
    super();
  }
}

Selecting a named registration:

Only needed in a module that imported TWO registrations of one service. A module that selected one resolves it by type, with no annotation at all.

typescript
@Module({
  imports: [
    DrizzleModule.forFeature(MAIN_DB),
    DrizzleModule.forFeature(ANALYTICS_DB),
  ],
  providers: [Reconciler],
})
export class ReconcileModule {}

@Service()
export class Reconciler extends BaseService {
  constructor(
    @Inject(MAIN_DB) private main: DrizzleService,
    @Inject(ANALYTICS_DB) private analytics: DrizzleService,
    private clock: ClockService,          // un-annotated parameters resolve normally
  ) {
    super();
  }
}

Asking for a token the module never selected throws at startup, naming what it did select — the alternative is being handed the other database, silently.

@Optional() ​

Marks a constructor parameter as optional for dependency injection. When the dependency cannot be resolved, undefined is injected instead of throwing DependencyResolutionError.

typescript
@Optional()

When to use @Optional:

  • Feature-gated dependencies (e.g., caching that may not be configured)
  • Graceful degradation when an optional module is not imported

A dependency listed in the module's own providers that was never constructed — its constructor threw, or this copy of @onebun/core sees no @Service() on it — counts as not available too: the parameter receives undefined, a warning names the dependency and why it has no instance, and the boot goes on.

An @Optional() parameter typed as an abstract class does not wait for its implementation the way every other parameter does: it receives the subclass only if that is already built when its consumer is constructed, and undefined with a warning otherwise. List the implementation, and what it injects, before the consumer. Waiting would turn an implementation that injects its consumer back — which boots, with undefined — into a CircularDependencyError.

Example:

typescript
@Service()
export class NotificationService extends BaseService {
  constructor(
    private userService: UserService,                  // required — throws if missing
    @Optional() private emailService?: EmailService,   // optional — undefined if missing
  ) {
    super();
  }

  async notify(userId: string, message: string) {
    if (this.emailService) {
      await this.emailService.send(userId, message);
    } else {
      this.logger.warn('EmailService not available, skipping email notification');
    }
  }
}

Middleware Decorators ​

@Middleware() ​

Class decorator for middleware. Apply it to classes that extend BaseMiddleware so that the framework can resolve constructor dependencies automatically (TypeScript emits design:paramtypes when the class has a decorator). Without @Middleware(), you would need @Inject() on each constructor parameter for DI to work.

typescript
@Middleware()
class AuthMiddleware extends BaseMiddleware {
  constructor(private authService: AuthService) {
    super();
  }
  async use(req, next) { ... }
}

@UseMiddleware() ​

Apply middleware to a route handler or to all routes in a controller. Works as both a method decorator and a class decorator. Pass class constructors extending BaseMiddleware (not instances).

typescript
// Method decorator — applies to a single route
@UseMiddleware(...middleware: MiddlewareClass[])

// Class decorator — applies to every route in the controller
@UseMiddleware(...middleware: MiddlewareClass[])

Method-level example:

typescript
import { BaseMiddleware, type OneBunRequest, type OneBunResponse } from '@onebun/core';

class AuthMiddleware extends BaseMiddleware {
  async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
    const token = req.headers.get('Authorization');
    if (!token) {
      return new Response('Unauthorized', { status: 401 });
    }
    return next();
  }
}

class LogMiddleware extends BaseMiddleware {
  async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
    this.logger.info(`${req.method} ${req.url}`);
    return next();
  }
}

@Controller('/users')
export class UserController extends BaseController {
  @Get('/protected')
  @UseMiddleware(AuthMiddleware)
  async protectedRoute() {
    return { message: 'Secret data' };
  }

  @Post('/action')
  @UseMiddleware(LogMiddleware, AuthMiddleware)  // Multiple middleware
  async action() {
    return { message: 'Action performed' };
  }
}

Class-level example:

typescript
@Controller('/admin')
@UseMiddleware(AuthMiddleware)  // Applied to ALL routes in this controller
export class AdminController extends BaseController {
  @Get('/dashboard')
  getDashboard() {
    return { stats: {} };
  }

  @Put('/settings')
  @UseMiddleware(AuditLogMiddleware)  // Additional middleware for this route
  updateSettings() {
    return { updated: true };
  }
}

When both class-level and method-level middleware are present, execution order is: controller-level -> route-level -> handler.

Middleware classes support full DI through the constructor. Use @Middleware() on the class for automatic dependency resolution. See Controllers API — Middleware for details and examples.

Response Decorators ​

@ApiResponse() ​

Define response schema for documentation and validation.

typescript
@ApiResponse(statusCode: number, options?: {
  schema?: Type<unknown>;
  description?: string;
})

Example:

typescript
import { type } from '@onebun/core';

const userResponseSchema = type({
  id: 'string',
  name: 'string',
  email: 'string.email',
});

@Controller('/users')
export class UserController extends BaseController {
  @Get('/:id')
  @ApiResponse(200, {
    schema: userResponseSchema,
    description: 'User found successfully',
  })
  @ApiResponse(404, {
    description: 'User not found',
  })
  async findOne(@Param('id') id: string) {
    // Response will be validated against userResponseSchema
    return { id, name: 'John', email: 'john@example.com' };
  }
}

The schema is enforced, not merely documented: a body that violates it never reaches the client. The request answers HTTP 500 with the default filter's fixed { success: false, error: 'Internal Server Error', code: 500 } — a response that does not match its own declared schema is a server-side contract bug, and the violation detail quotes the offending value, so it goes to the application log rather than to the caller.

Documentation Decorators ​

Package: @onebun/docs

These decorators add metadata for OpenAPI/Swagger documentation generation.

@ApiTags() ​

Group endpoints under tags for documentation organization.

typescript
import { ApiTags } from '@onebun/docs';

@ApiTags(...tags: string[])

Can be used on controller class or individual methods:

Example:

typescript
import { Controller, BaseController, Get } from '@onebun/core';
import { ApiTags } from '@onebun/docs';

@ApiTags('Users', 'User Management')
@Controller('/users')
export class UserController extends BaseController {
  // All endpoints tagged with 'Users' and 'User Management'

  @ApiTags('Admin')
  @Get('/admins')
  async getAdmins() {
    return [];
  }
}

@ApiOperation() ​

Describe an API operation with summary, description, and additional tags.

typescript
import { ApiOperation } from '@onebun/docs';

@ApiOperation(options: {
  summary?: string;
  description?: string;
  tags?: string[];
})

Example:

typescript
import { Controller, BaseController, Get, Param } from '@onebun/core';
import { ApiOperation } from '@onebun/docs';

@Controller('/users')
export class UserController extends BaseController {
  @ApiOperation({
    summary: 'Get user by ID',
    description: 'Returns a single user by their unique identifier. Returns 404 if user not found.',
    tags: ['Users'],
  })
  @Get('/:id')
  async getUser(@Param('id') id: string) {
    return { id, name: 'John' };
  }
}

Combining Documentation Decorators ​

Use both @onebun/core and @onebun/docs decorators together for complete documentation:

typescript
import { Controller, BaseController, Get, Post, Body, Param, ApiResponse, type } from '@onebun/core';
import { ApiTags, ApiOperation } from '@onebun/docs';

const userSchema = type({
  id: 'string',
  name: 'string',
  email: 'string.email',
});

const createUserSchema = type({
  name: 'string',
  email: 'string.email',
});

type CreateUserBody = typeof createUserSchema.infer;

@ApiTags('Users')
@Controller('/users')
export class UserController extends BaseController {
  @ApiOperation({ summary: 'Get user by ID' })
  @Get('/:id')
  @ApiResponse(200, { schema: userSchema, description: 'User found' })
  @ApiResponse(404, { description: 'User not found' })
  async getUser(@Param('id') id: string) {
    // ...
  }

  @ApiOperation({ summary: 'Create new user', description: 'Creates a new user account' })
  @Post('/')
  @ApiResponse(201, { schema: userSchema, description: 'User created' })
  @ApiResponse(400, { description: 'Invalid input' })
  async createUser(@Body(createUserSchema) body: CreateUserBody) {
    // ...
  }
}

Tracing Decorators ​

@Span() ​

Create a trace span for a method (from @onebun/trace).

typescript
@Span(name?: string)

Example:

typescript
import { Span } from '@onebun/trace';

@Service()
export class UserService extends BaseService {
  @Span('user-find-by-id')
  async findById(id: string): Promise<User | null> {
    // This method is automatically traced
    return this.repository.findById(id);
  }

  @Span()  // Default span name is `ClassName.methodName`
  async processUser(user: User): Promise<void> {
    // Span name: "UserService.processUser" — the class prefix is part of it
  }
}

Utility Functions ​

getControllerMetadata() ​

Get metadata for a controller class.

typescript
function getControllerMetadata(target: Function): ControllerMetadata | undefined;

interface ControllerMetadata {
  path: string;
  routes: RouteMetadata[];
}

interface RouteMetadata {
  path: string;
  method: HttpMethod;
  handler: string;
  params?: ParamMetadata[];
  middleware?: Function[];
  responseSchemas?: ResponseSchemaMetadata[];
}

getModuleMetadata() ​

Get metadata for a module class.

typescript
function getModuleMetadata(target: Function): ModuleMetadata | undefined;

interface ModuleMetadata {
  imports?: Function[];
  controllers?: Function[];
  providers?: unknown[];
  exports?: unknown[];
}

getServiceMetadata() ​

Get metadata for a service class.

typescript
function getServiceMetadata(serviceClass: Function): ServiceMetadata | undefined;

interface ServiceMetadata {
  tag: Context.Tag<unknown, unknown>;
  impl: new () => unknown;
}

getServiceTag() ​

Get Effect.js Context tag for a service class.

typescript
function getServiceTag<T>(serviceClass: new (...args: unknown[]) => T): Context.Tag<T, T>;

registerDependencies() ​

Manually register constructor dependencies (fallback method).

typescript
function registerDependencies(target: Function, dependencies: (Function | undefined)[]): void;

Auditing constructor parameters ​

typescript
function getConstructorParamTypes(target: Function): (Function | undefined)[] | undefined;
function isInjectableParamType(type: Function | undefined): type is Function;

getConstructorParamTypes returns the array positionally intact: entry i describes parameter i. It returns undefined only when no metadata was emitted at all.

An entry is undefined when the slot held something that is not a constructor. It is notundefined for a parameter typed as an interface: Bun's emitDecoratorMetadata emits every type reference as typeof X === "undefined" ? Object : X, so an interface, a type alias, any, unknown and a reference broken by a circular import all arrive as Object. An audit spelled types.filter((type) => type === undefined) therefore reports zero holes on every application ever written.

isInjectableParamType is the predicate the container itself uses, exported so the audit asks the framework instead of guessing:

typescript
import { getConstructorParamTypes, isInjectableParamType } from '@onebun/core';

const types = getConstructorParamTypes(MyService) ?? [];
const holes = types.flatMap((type, index) => (isInjectableParamType(type) ? [] : [index]));

Two things it deliberately does not do. It does not say why a slot is unusable — Object cannot be told apart from any or a circular import — and it does not treat Function, Array, Symbol, Date, Map or Set as holes: each of those reaches the resolver and raises a loud DependencyResolutionError at startup, and calling them holes here would convert those failures into silent undefined injections. The non-injectable set is exactly Object, String, Number and Boolean.

The framework warns at startup about a hole followed by a later parameter that resolves — the shape in which a missing dependency is easiest to overlook, since every other field is filled; a trailing hole is far more often a deliberate optional and is logged at debug only. See capturing framework logs for reading the warning from a test. A parameter named by @Inject(TOKEN) still shows as Object here, because the token lives in a side map keyed by parameter index, not in design:paramtypes.

Use the top-level getConstructorParamTypes, not Reflect.getConstructorParamTypes. The one on the Reflect namespace is the raw metadata reader and does not merge @Inject(ConcreteClass), so an audit written against it reports a hole for every parameter that an explicit @Inject already answered.

Reflect Metadata and Other Libraries ​

Bun records constructor and member types (design:paramtypes, design:type) through the global Reflect.metadata, so importing @onebun/core installs the Reflect Metadata API on the global Reflect: all nine functions — metadata, defineMetadata, hasMetadata, hasOwnMetadata, getMetadata, getOwnMetadata, getMetadataKeys, getOwnMetadataKeys and deleteMetadata — with per-member storage and the proposal's prototype walk. The core does not depend on reflect-metadata and does not need it.

The same global is shared with every other library that uses it: tsyringe, class-transformer, inversify, and anything that imports reflect-metadata on its own (@simplewebauthn/server does, through tsyringe). Any import order works:

  • reflect-metadata imported before the core — the core finds a complete implementation and uses it as it is.
  • reflect-metadata imported after the core — a 0.2.x copy joins the provider registry the core publishes, so everything decorated before it arrived is still read back; a 0.1.x copy finds nothing missing and changes nothing.

If your application imports reflect-metadata itself, use 0.2.2 or later. 0.2.1 loses member metadata of classes decorated before it loaded whenever it has to adopt an older implementation that has no registry (a 0.1.x copy loaded earlier, for example).

Constructor types are read own, never inherited. A decorated subclass that declares no constructor of its own has no design:paramtypes of its own, so it is built with no dependencies, and the module logs a warning at startup that names the class, the parent and the types it did not get. Declare the constructor again to have them injected:

typescript
import { BaseService, getConstructorParamTypes, Service } from '@onebun/core';

@Service()
export class Clock extends BaseService {}

@Service()
export class Scheduler extends BaseService {
  constructor(protected readonly clock: Clock) {
    super();
  }
}

// No constructor of its own: nothing is injected, `this.clock` is undefined,
// and startup warns "NightlyScheduler declares no constructor of its own ..."
@Service()
export class NightlyScheduler extends Scheduler {}

// The constructor declared again: `clock` is injected
@Service()
export class HourlyScheduler extends Scheduler {
  constructor(clock: Clock) {
    super(clock);
  }
}

getConstructorParamTypes(Scheduler);        // [Clock]
getConstructorParamTypes(NightlyScheduler); // undefined
getConstructorParamTypes(HourlyScheduler);  // [Clock]

The parent's types are not borrowed because a subclass may take different parameters: a guard without a decorator that extends a decorated base guard and declares constructor(audit: AuditLog) would otherwise receive the base's first dependency in its audit slot. An undecorated class emits no metadata at all, so it is built with no dependencies — add a decorator (@Service(), or @Middleware() for middleware) to have its own constructor injected. When such a class declares constructor parameters and extends a class that takes dependencies, startup warns about it too: <Class> declares a constructor with parameters, but no types were emitted for them .... One whose constructor takes no parameters — constructor() { super(new TokenStore()); } — records nothing either, so it cannot be told from a class that declares none: it gets the first warning, worded for both (<Class> declares no constructor of its own (or, without a decorator, one whose parameter types were not recorded) ...), which is harmless when that constructor passes the parent's dependencies itself.

The import order of reflect-metadata does not change this. The core reads constructor types with getOwnMetadata, never with the prototype-walking getMetadata, so a subclass with no constructor types of its own gets none of its parent's even when reflect-metadata (or a library that imports it, such as @simplewebauthn/server) is imported BEFORE the core. The app still boots, so the two startup warnings above are how to find each class this affects:

  • <Class> declares no constructor of its own ... — a subclass without a constructor gets none of its parent's dependencies, whether it is decorated or an undecorated guard, interceptor, filter or middleware. Declare the constructor in it and pass them to super(...). An undecorated one needs a decorator as well — @Service(), or @Middleware() for middleware — or the constructor it declares records no types either and the next warning takes over.
  • <Class> declares a constructor with parameters, but no types were emitted ... — an undecorated guard, interceptor, filter or middleware that extends a decorated class and declares its own constructor receives no types at all, not even the parent's by position. Add @Service() to it, or @Middleware() if it is middleware.

Complete Example ​

typescript
import {
  Module,
  Controller,
  BaseController,
  Service,
  BaseService,
  Get,
  Post,
  Param,
  Body,
  Query,
  Header,
  UseMiddleware,
  BaseMiddleware,
  ApiResponse,
  HttpException,
  type OneBunRequest,
  type OneBunResponse,
  type,
} from '@onebun/core';
import { Span } from '@onebun/trace';

// Validation schemas (in a real app, these live in a separate schema file)
const createUserSchema = type({
  name: 'string',
  email: 'string.email',
});
type CreateUserBody = typeof createUserSchema.infer;

const userSchema = type({
  id: 'string',
  name: 'string',
  email: 'string.email',
});
type User = typeof userSchema.infer;

// Service
@Service()
export class UserService extends BaseService {
  private users = new Map<string, User>();

  @Span('find-all-users')
  async findAll(): Promise<User[]> {
    return Array.from(this.users.values());
  }

  @Span('find-user-by-id')
  async findById(id: string): Promise<User | null> {
    return this.users.get(id) || null;
  }

  async create(data: CreateUserBody): Promise<User> {
    const user = { id: crypto.randomUUID(), ...data };
    this.users.set(user.id, user);
    this.logger.info('User created', { userId: user.id });
    return user;
  }
}

// Middleware
class AuthMiddleware extends BaseMiddleware {
  async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
    const token = req.headers.get('Authorization');
    if (!token?.startsWith('Bearer ')) {
      return new Response(JSON.stringify({ success: false, message: 'Unauthorized' }), {
        status: 401,
        headers: { 'Content-Type': 'application/json' },
      });
    }
    return next();
  }
}

// Controller
@Controller('/users')
export class UserController extends BaseController {
  constructor(private userService: UserService) {
    super();
  }

  @Get('/')
  @ApiResponse(200, { schema: userSchema.array() })
  async findAll(
    @Query('limit') limit?: string,
    @Query('offset') offset?: string,
  ) {
    return this.userService.findAll();
  }

  @Get('/:id')
  @ApiResponse(200, { schema: userSchema })
  @ApiResponse(404, { description: 'User not found' })
  async findOne(@Param('id') id: string) {
    const user = await this.userService.findById(id);
    if (!user) throw new HttpException(404, 'User not found');
    return user;
  }

  @Post('/')
  @UseMiddleware(AuthMiddleware)
  @ApiResponse(201, { schema: userSchema })
  async create(
    @Body(createUserSchema) body: CreateUserBody,
    @Header('X-Request-ID') requestId?: string,
  ) {
    this.logger.info('Creating user', { requestId });
    return this.userService.create(body);
  }
}

// Module
@Module({
  controllers: [UserController],
  providers: [UserService],
  exports: [UserService],
})
export class UserModule {}

Released under the MPL-2.0 License.