--- url: /ai-docs.md description: How to use OneBun documentation with AI assistants and coding agents. --- # AI Documentation OneBun provides LLM-optimized documentation for use with AI coding assistants. ## Available Formats | Format | Description | Use Case | |--------|-------------|----------| | [llms.txt](/llms.txt) | Navigation index with links | Quick reference, RAG systems | | [llms-full.txt](/llms-full.txt) | Complete documentation | Full context for AI agents | ## Usage with AI Assistants ### Cursor / Windsurf / Similar IDEs Add to project rules (`.cursor/rules/` or similar): ``` When working with OneBun framework, fetch documentation from: https://onebun.dev/llms-full.txt ``` Or reference Context7 with library ID `onebun`. ### ChatGPT / Claude / Other Chat Interfaces 1. Copy content from [llms-full.txt](/llms-full.txt) 2. Paste into conversation as context 3. Ask questions about OneBun framework ### RAG Systems / Custom Agents ```python import requests # Fetch documentation index docs = requests.get("https://onebun.dev/llms.txt").text # Or full documentation full_docs = requests.get("https://onebun.dev/llms-full.txt").text ``` ### MCP Context7 OneBun documentation is indexed by Context7. Use library ID `onebun` with Context7 MCP server: ```json { "mcpServers": { "context7": { "command": "npx", "args": ["-y", "@upstash/context7-mcp"] } } } ``` ## What's Included * API reference for all packages (@onebun/core, @onebun/cache, @onebun/drizzle, etc.) * Code examples and patterns * Type signatures and interfaces * Common errors and solutions * Technical notes in `` blocks (visible only to AI) ### Key Packages | Package | Description | |---------|-------------| | `@onebun/core` | Framework core: Modules & DI, Controllers with decorator routing, Services, WebSocket Gateway (+ Socket.IO + typed client), Queue & Scheduler (in-memory, Redis, NATS, JetStream backends), HTTP Guards (`@UseGuards`, `AuthGuard`, `RolesGuard`, `createHttpGuard`), Exception Filters (`@UseFilters`, `createExceptionFilter`, `defaultExceptionFilter`), TestingModule for isolated controller/service tests (`TestingModule.create(...).overrideProvider(...).compile()`), Security Middleware (`CorsMiddleware`, `RateLimitMiddleware`, `SecurityHeadersMiddleware`), Middleware, MultiServiceApplication for microservices, Graceful Shutdown, SSE (`@Sse`, `sse()`) | | `@onebun/docs` | Automatic OpenAPI 3.1 generation from decorators and ArkType schemas, Swagger UI, @ApiTags, @ApiOperation decorators | | `@onebun/drizzle` | Drizzle ORM integration: PostgreSQL + SQLite (bun:sqlite), schema-first types, CLI & programmatic migrations, auto-migrate on startup, BaseRepository pattern | | `@onebun/cache` | CacheModule with in-memory (TTL, max size) and Redis backends, shared Redis connection pool, batch operations (getMany/setMany/deleteMany) | | `@onebun/envs` | Type-safe environment configuration: schema with Env.string/number/boolean/array, validation, defaults, transforms, sensitive value masking, .env file support | | `@onebun/logger` | Structured logging: JSON (production) and pretty (development) output, 6 log levels, child loggers with context inheritance, automatic trace context integration | | `@onebun/metrics` | Prometheus-compatible metrics: automatic HTTP/system/GC collection, @Timed/@Counted decorators, custom Counter/Gauge/Histogram, /metrics endpoint | | `@onebun/trace` | OpenTelemetry distributed tracing: automatic HTTP tracing, @Span decorator, configurable sampling rate, export to external collectors | | `@onebun/requests` | HTTP client: Bearer/API Key/Basic/HMAC auth, retry strategies (fixed/linear/exponential), typed ApiResponse, typed service clients via createServiceDefinition for inter-service communication | | `@onebun/nats` | NATS and JetStream integration for distributed queues and messaging with at-least-once delivery | ### Database Migrations (@onebun/drizzle) The Drizzle package provides database schema management: **Schema imports:** * PostgreSQL: `import { pgTable, text, integer, ... } from '@onebun/drizzle/pg'` * SQLite: `import { sqliteTable, text, integer, ... } from '@onebun/drizzle/sqlite'` * Common operators: `import { eq, and, sql, count, defineConfig, ... } from '@onebun/drizzle'` **CLI (use `onebun-drizzle` wrapper for correct version):** * `bunx onebun-drizzle generate` - Generate migration files * `bunx onebun-drizzle push` - Push schema directly (dev only) * `bunx onebun-drizzle studio` - Open Drizzle Studio **Programmatic API:** * `generateMigrations()` - Generate SQL files from schema (build step) * `pushSchema()` - Apply schema directly (development) * `DrizzleService.runMigrations()` - Apply migrations at runtime * `autoMigrate: true` - Auto-run migrations on app startup See [Database API](/api/drizzle) for full documentation. ## Format Details Documentation follows [llmstxt.org](https://llmstxt.org/) standard: * Plain Markdown without HTML * Self-contained sections for RAG chunking * Descriptions for each page * Generated by [vitepress-plugin-llms](https://github.com/okineadev/vitepress-plugin-llms) ## Per-Page Markdown Each documentation page is also available as `.md` file alongside the HTML version: * `/api/core` → `/api/core.md` * `/getting-started` → `/getting-started.md` * etc. Use these for individual page context when full documentation is too large. --- --- url: /api/docs.md description: >- Automatic OpenAPI 3.1 documentation generation from decorators and ArkType schemas. Swagger UI, @ApiTags, @ApiOperation setup. --- ## Quick Reference for AI **Setup**: Install `@onebun/docs` and it auto-enables. No manual configuration required. ```typescript bun add @onebun/docs ``` **How it works**: 1. Framework detects `@onebun/docs` at startup via dynamic import 2. `initializeDocs()` collects all controller metadata (routes, params, schemas) 3. `generateOpenApiSpec()` builds OpenAPI 3.1 spec from metadata 4. Swagger UI HTML is served at `/docs`, JSON spec at `/openapi.json` **Decorator order matters**: * `@ApiTags` → ABOVE `@Controller` * `@ApiOperation` → ABOVE route decorator (`@Get`, `@Post`, etc.) * `@ApiResponse` → BELOW route decorator **ArkType → OpenAPI**: ArkType schemas passed to `@Body(schema)` or `@ApiResponse(code, { schema })` are automatically converted to JSON Schema via `arktypeToJsonSchema()` which uses ArkType's built-in `toJsonSchema()`. **Configuration**: `docs` key in `ApplicationOptions`: ```typescript interface DocsApplicationOptions { enabled?: boolean; // default: true (if @onebun/docs installed) path?: string; // default: '/docs' jsonPath?: string; // default: '/openapi.json' title?: string; // default: app name or 'OneBun API' version?: string; // default: '1.0.0' description?: string; contact?: { name?: string; email?: string; url?: string }; license?: { name: string; url?: string }; servers?: Array<{ url: string; description?: string }>; } ``` **Common mistakes**: * Placing `@ApiTags` below `@Controller` — tags won't be read * Placing `@ApiResponse` above `@Get`/`@Post` — response schemas won't be attached to the route * Forgetting to install `@onebun/docs` — no error, docs silently disabled * Not passing ArkType schema to `@Body()` — request body won't appear in OpenAPI spec # API Documentation (OpenAPI) Package: `@onebun/docs` ## Overview OneBun automatically generates OpenAPI 3.1 documentation from your controllers, decorators, and ArkType validation schemas. Install the package and get a Swagger UI with zero configuration. **Key features:** * Automatic OpenAPI 3.1 spec generation from route metadata * ArkType schemas → JSON Schema conversion (single source of truth) * Swagger UI served at `/docs` * OpenAPI JSON spec served at `/openapi.json` * `@ApiTags`, `@ApiOperation`, `@ApiResponse` decorators for additional metadata ## Installation ```bash bun add @onebun/docs ``` That's it. When `@onebun/docs` is installed, documentation is **automatically enabled** on application startup. No imports or configuration required in your application code. ## How It Works 1. On startup, the framework detects `@onebun/docs` via dynamic import 2. All controller metadata (routes, parameters, validation schemas) is collected 3. An OpenAPI 3.1 specification is generated from this metadata 4. Swagger UI is served at the configured path (default: `/docs`) ``` Application starts ↓ Detects @onebun/docs installed ↓ Collects controller metadata (routes, @Body schemas, @Param, @Query, @Header) ↓ Converts ArkType schemas to JSON Schema ↓ Generates OpenAPI 3.1 spec ↓ Serves Swagger UI at /docs Serves OpenAPI JSON at /openapi.json ``` ## Configuration Customize documentation via the `docs` option in `OneBunApplication`: ```typescript import { OneBunApplication } from '@onebun/core'; import { AppModule } from './app.module'; const app = new OneBunApplication(AppModule, { port: 3000, docs: { enabled: true, // default: true (if @onebun/docs installed) path: '/docs', // Swagger UI path (default: '/docs') jsonPath: '/openapi.json', // OpenAPI JSON path (default: '/openapi.json') title: 'My API', // API title version: '2.0.0', // API version description: 'My awesome OneBun API', contact: { name: 'API Support', email: 'support@example.com', url: 'https://example.com', }, license: { name: 'MIT', url: 'https://opensource.org/licenses/MIT', }, servers: [ { url: 'https://api.example.com', description: 'Production' }, { url: 'http://localhost:3000', description: 'Development' }, ], }, }); await app.start(); // Swagger UI: http://localhost:3000/docs // OpenAPI JSON: http://localhost:3000/openapi.json ``` ### DocsApplicationOptions | Option | Type | Default | Description | |--------|------|---------|-------------| | `enabled` | `boolean` | `true` | Enable/disable docs (auto-disabled if `@onebun/docs` not installed) | | `path` | `string` | `'/docs'` | Swagger UI path | | `jsonPath` | `string` | `'/openapi.json'` | OpenAPI JSON spec path | | `title` | `string` | App name or `'OneBun API'` | API title in spec | | `version` | `string` | `'1.0.0'` | API version in spec | | `description` | `string` | - | API description | | `contact` | `object` | - | Contact info (`name`, `email`, `url`) | | `license` | `object` | - | License info (`name`, `url`) | | `servers` | `array` | - | Server URLs with descriptions | | `externalDocs` | `object` | - | External docs link (`description`, `url`) | ### Disabling Documentation ```typescript // Explicitly disable const app = new OneBunApplication(AppModule, { docs: { enabled: false }, }); // Or simply don't install @onebun/docs — docs are silently skipped ``` ## Documentation Decorators ### @ApiTags() Group endpoints under tags in the Swagger UI. Imported from `@onebun/docs`. ::: warning Decorator Order `@ApiTags` must be placed **above** `@Controller` because the controller decorator reads tag metadata when it runs. ::: ```typescript import { Controller, BaseController, Get } from '@onebun/core'; import { ApiTags } from '@onebun/docs'; // @ApiTags ABOVE @Controller @ApiTags('Users', 'User Management') @Controller('/users') export class UserController extends BaseController { // All endpoints in this controller are tagged with 'Users' and 'User Management' @Get('/') async findAll(): Promise { return this.success([]); } } ``` Can also be used on individual methods (place above the route decorator): ```typescript @ApiTags('Admin') @Get('/admins') async getAdmins(): Promise { return this.success([]); } ``` ### @ApiOperation() Describe an API operation with summary, description, and additional tags. Imported from `@onebun/docs`. ::: warning Decorator Order `@ApiOperation` must be placed **above** route decorators (`@Get`, `@Post`, etc.). ::: ```typescript import { Controller, BaseController, Get, Post, Param, Body } from '@onebun/core'; import { ApiOperation } from '@onebun/docs'; import { type } from 'arktype'; @Controller('/users') export class UserController extends BaseController { // @ApiOperation ABOVE @Get @ApiOperation({ summary: 'Get user by ID', description: 'Returns a single user by their unique identifier. Returns 404 if not found.', tags: ['Users'], }) @Get('/:id') async getUser(@Param('id') id: string): Promise { return this.success({ id, name: 'John' }); } } ``` ### @ApiResponse() Define response schemas for documentation and validation. Imported from `@onebun/core`. ::: warning Decorator Order `@ApiResponse` must be placed **below** route decorators (`@Get`, `@Post`, etc.) because the route decorator reads response schemas when it runs. ::: ```typescript import { Controller, BaseController, Get, Param, ApiResponse } from '@onebun/core'; import { type } from 'arktype'; const userSchema = type({ id: 'string', name: 'string', email: 'string.email', }); @Controller('/users') export class UserController extends BaseController { @Get('/:id') // @ApiResponse BELOW @Get @ApiResponse(200, { schema: userSchema, description: 'User found successfully', }) @ApiResponse(404, { description: 'User not found', }) async getUser(@Param('id') id: string): Promise { return this.success({ id, name: 'John', email: 'john@example.com' }); } } ``` ## ArkType to OpenAPI Schema ArkType schemas passed to `@Body(schema)` or `@ApiResponse(code, { schema })` are automatically converted to OpenAPI-compatible JSON Schema. This provides a **single source of truth**: one schema definition serves as TypeScript type, runtime validation, and OpenAPI documentation. ```typescript import { type } from 'arktype'; // Define schema once const createUserSchema = type({ name: 'string', email: 'string.email', 'age?': 'number > 0', }); // Use in @Body — generates both validation AND OpenAPI request body schema @Post('/') async createUser( @Body(createUserSchema) body: typeof createUserSchema.infer, ): Promise { // body is validated and typed return this.success(body); } ``` The resulting OpenAPI spec will include: ```json { "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "name": { "type": "string" }, "email": { "type": "string", "format": "email" }, "age": { "type": "number", "exclusiveMinimum": 0 } }, "required": ["name", "email"] } } } } } ``` ## Programmatic Usage For advanced use cases (CI pipelines, custom documentation tools): ```typescript import { generateOpenApiSpec, generateSwaggerUiHtml, arktypeToJsonSchema } from '@onebun/docs'; import { getControllerMetadata } from '@onebun/core'; // Generate OpenAPI spec from controller classes const spec = generateOpenApiSpec( [UserController, OrderController], { title: 'My API', version: '1.0.0', description: 'Generated API documentation', }, ); // Convert spec to JSON const json = JSON.stringify(spec, null, 2); // Generate Swagger UI HTML pointing to a spec URL const html = generateSwaggerUiHtml('/openapi.json'); // Convert an ArkType schema to JSON Schema import { type } from 'arktype'; const schema = type({ name: 'string', age: 'number' }); const jsonSchema = arktypeToJsonSchema(schema); ``` ## Complete Example ```typescript // src/config.ts import { Env } from '@onebun/core'; export const envSchema = { server: { port: Env.number({ default: 3000, env: 'PORT' }), }, }; // src/user.controller.ts import { Controller, BaseController, Get, Post, Put, Delete, Param, Body, Query, ApiResponse, Service, BaseService, Module, } from '@onebun/core'; import { ApiTags, ApiOperation } from '@onebun/docs'; import { type } from 'arktype'; // ---- Schemas (single source of truth) ---- const userSchema = type({ id: 'string', name: 'string', email: 'string.email', 'age?': 'number > 0', }); const createUserSchema = type({ name: 'string', email: 'string.email', 'age?': 'number > 0', }); const updateUserSchema = type({ 'name?': 'string', 'email?': 'string.email', 'age?': 'number > 0', }); // ---- Service ---- @Service() class UserService extends BaseService { private users = new Map(); async findAll(): Promise> { return Array.from(this.users.values()); } async findById(id: string): Promise { return this.users.get(id) || null; } async create(data: typeof createUserSchema.infer): Promise { const user = { id: crypto.randomUUID(), ...data }; this.users.set(user.id, user); return user; } async update(id: string, data: typeof updateUserSchema.infer): Promise { const user = this.users.get(id); if (!user) return null; const updated = { ...user, ...data }; this.users.set(id, updated); return updated; } async delete(id: string): Promise { return this.users.delete(id); } } // ---- Controller with full documentation ---- @ApiTags('Users') @Controller('/api/users') class UserController extends BaseController { constructor(private userService: UserService) { super(); } @ApiOperation({ summary: 'List all users', description: 'Returns all users. Supports pagination via query params.' }) @Get('/') @ApiResponse(200, { schema: userSchema.array(), description: 'List of users' }) async findAll( @Query('limit') limit?: string, @Query('offset') offset?: string, ): Promise { const users = await this.userService.findAll(); return this.success(users); } @ApiOperation({ summary: 'Get user by ID' }) @Get('/:id') @ApiResponse(200, { schema: userSchema, description: 'User found' }) @ApiResponse(404, { description: 'User not found' }) async findOne(@Param('id') id: string): Promise { const user = await this.userService.findById(id); if (!user) return this.error('User not found', 404, 404); return this.success(user); } @ApiOperation({ summary: 'Create a new user' }) @Post('/') @ApiResponse(201, { schema: userSchema, description: 'User created' }) @ApiResponse(400, { description: 'Invalid input' }) async create( @Body(createUserSchema) body: typeof createUserSchema.infer, ): Promise { const user = await this.userService.create(body); return this.success(user); } @ApiOperation({ summary: 'Update a user' }) @Put('/:id') @ApiResponse(200, { schema: userSchema, description: 'User updated' }) @ApiResponse(404, { description: 'User not found' }) async update( @Param('id') id: string, @Body(updateUserSchema) body: typeof updateUserSchema.infer, ): Promise { const user = await this.userService.update(id, body); if (!user) return this.error('User not found', 404, 404); return this.success(user); } @ApiOperation({ summary: 'Delete a user' }) @Delete('/:id') @ApiResponse(200, { description: 'User deleted' }) @ApiResponse(404, { description: 'User not found' }) async delete(@Param('id') id: string): Promise { const deleted = await this.userService.delete(id); if (!deleted) return this.error('User not found', 404, 404); return this.success({ deleted: true }); } } // ---- Module ---- @Module({ controllers: [UserController], providers: [UserService], }) class UserModule {} // ---- Application ---- import { OneBunApplication } from '@onebun/core'; const app = new OneBunApplication(UserModule, { port: 3000, docs: { title: 'User Management API', version: '1.0.0', description: 'API for managing users', }, }); await app.start(); // Swagger UI: http://localhost:3000/docs // OpenAPI JSON: http://localhost:3000/openapi.json // API endpoint: http://localhost:3000/api/users ``` After starting the application: * Visit `http://localhost:3000/docs` for interactive Swagger UI * Fetch `http://localhost:3000/openapi.json` for the raw OpenAPI specification * All endpoints, request/response schemas, and parameter types are auto-documented --- --- url: /examples/basic-app.md description: >- Minimal OneBun application example. Single controller and service, project structure, running and testing. --- # Basic Application Example A minimal OneBun application with a single controller and service. ## Project Structure ``` basic-app/ ├── src/ │ ├── index.ts │ ├── app.module.ts │ ├── config.ts │ ├── hello.controller.ts │ └── hello.service.ts ├── .env ├── package.json └── tsconfig.json ``` ## package.json ```json { "name": "basic-app", "version": "1.0.0", "scripts": { "dev": "bun run --watch src/index.ts", "start": "bun run src/index.ts", "typecheck": "bunx tsc --noEmit" }, "dependencies": { "@onebun/core": "^0.2.6", "@onebun/logger": "^0.2.0", "@onebun/envs": "^0.2.0", "effect": "^3.0.0" }, "devDependencies": { "bun-types": "latest", "typescript": "^5.0.0" } } ``` ## tsconfig.json ```json { "compilerOptions": { "target": "ESNext", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "noEmit": true, "esModuleInterop": true, "skipLibCheck": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, "types": ["bun-types"] }, "include": ["src/**/*"] } ``` ## .env ```bash PORT=3000 HOST=0.0.0.0 APP_NAME=basic-app DEBUG=true ``` ## src/config.ts ```typescript import { Env } from '@onebun/core'; export const envSchema = { server: { port: Env.number({ default: 3000, env: 'PORT' }), host: Env.string({ default: '0.0.0.0', env: 'HOST' }), }, app: { name: Env.string({ default: 'basic-app', env: 'APP_NAME' }), debug: Env.boolean({ default: false, env: 'DEBUG' }), }, }; export type AppConfig = typeof envSchema; ``` ## src/hello.service.ts ```typescript import { Service, BaseService } from '@onebun/core'; @Service() export class HelloService extends BaseService { private greetCount = 0; /** * Generate a greeting message * @param name - Name to greet * @returns Greeting message */ greet(name: string): string { this.greetCount++; this.logger.info('Generating greeting', { name, greetCount: this.greetCount, }); return `Hello, ${name}! You are visitor #${this.greetCount}`; } /** * Get simple greeting */ sayHello(): string { return 'Hello from OneBun!'; } /** * Get statistics */ getStats(): { greetCount: number; uptime: number } { return { greetCount: this.greetCount, uptime: process.uptime(), }; } } ``` ## src/hello.controller.ts ```typescript import { Controller, BaseController, Get, Param, } from '@onebun/core'; import { HelloService } from './hello.service'; @Controller('/api') export class HelloController extends BaseController { constructor(private helloService: HelloService) { super(); } /** * GET /api/hello * Simple hello endpoint */ @Get('/hello') async hello(): Promise { this.logger.info('Hello endpoint called'); const message = this.helloService.sayHello(); return this.success({ message }); } /** * GET /api/hello/:name * Greet a specific person */ @Get('/hello/:name') async greet(@Param('name') name: string): Promise { this.logger.info('Greet endpoint called', { name }); const greeting = this.helloService.greet(name); return this.success({ greeting }); } /** * GET /api/stats * Get service statistics */ @Get('/stats') async stats(): Promise { const stats = this.helloService.getStats(); return this.success(stats); } /** * GET /api/health * Health check endpoint */ @Get('/health') async health(): Promise { return this.success({ status: 'healthy', timestamp: new Date().toISOString(), }); } } ``` ## src/app.module.ts ```typescript import { Module } from '@onebun/core'; import { HelloController } from './hello.controller'; import { HelloService } from './hello.service'; @Module({ controllers: [HelloController], providers: [HelloService], }) export class AppModule {} ``` ## src/index.ts ```typescript import { OneBunApplication } from '@onebun/core'; import { AppModule } from './app.module'; import { envSchema } from './config'; const app = new OneBunApplication(AppModule, { port: 3000, host: '0.0.0.0', development: true, envSchema, envOptions: { loadDotEnv: true, }, metrics: { enabled: true, path: '/metrics', }, tracing: { enabled: true, serviceName: 'basic-app', }, }); app.start() .then(() => { const logger = app.getLogger({ className: 'Bootstrap' }); logger.info('Basic app started successfully!'); logger.info(`Server: http://localhost:3000`); logger.info(`Metrics: http://localhost:3000/metrics`); }) .catch((error) => { console.error('Failed to start:', error); process.exit(1); }); ``` ## Running the Application ```bash # Install dependencies bun install # Start in development mode bun run dev # Or start once bun run start ``` ## Testing the API ```bash # Simple hello curl http://localhost:3000/api/hello # Response: {"success":true,"result":{"message":"Hello from OneBun!"}} # Greet by name curl http://localhost:3000/api/hello/World # Response: {"success":true,"result":{"greeting":"Hello, World! You are visitor #1"}} curl http://localhost:3000/api/hello/Alice # Response: {"success":true,"result":{"greeting":"Hello, Alice! You are visitor #2"}} # Get stats curl http://localhost:3000/api/stats # Response: {"success":true,"result":{"greetCount":2,"uptime":12.345}} # Health check curl http://localhost:3000/api/health # Response: {"success":true,"result":{"status":"healthy","timestamp":"2024-01-15T10:30:00.000Z"}} # Metrics curl http://localhost:3000/metrics ``` ## Key Takeaways 1. **Decorators**: Use `@Module`, `@Controller`, `@Service`, `@Get` to define structure 2. **Base Classes**: Extend `BaseController` and `BaseService` for built-in features 3. **DI**: Services are automatically injected via constructor 4. **Responses**: Use `this.success()` for standardized JSON responses 5. **Logging**: Use `this.logger` for structured logging 6. **Config**: Define schema in `config.ts`, load via `envSchema` option --- --- url: /api/cache.md description: >- CacheModule for in-memory and Redis caching. CacheService methods, TTL configuration, cache statistics. --- # Cache API Package: `@onebun/cache` ## Overview OneBun provides a caching module with support for: * In-memory cache * Redis cache * Module-based integration with DI ## CacheModule CacheModule is **global by default** — once imported in the root module, `CacheService` is automatically available in all submodules without explicit import. Use `isGlobal: false` to disable this behavior. ### Basic Setup ```typescript import { Module } from '@onebun/core'; import { CacheModule, CacheType } from '@onebun/cache'; import { UserController } from './user.controller'; import { UserService } from './user.service'; // CacheModule imported once in root — CacheService available everywhere @Module({ imports: [ CacheModule.forRoot({ type: CacheType.MEMORY, // CacheType.MEMORY or CacheType.REDIS cacheOptions: { defaultTtl: 300000, // Default TTL in milliseconds }, }), ], controllers: [UserController], providers: [UserService], }) export class AppModule {} // CacheService is automatically available in all submodules @Module({ controllers: [UserController], providers: [UserService], // UserService can inject CacheService }) export class UserModule {} ``` ### Non-Global Mode For multi-cache scenarios, disable global mode so each module can have its own CacheService instance: ```typescript // Root module: non-global cache @Module({ imports: [ CacheModule.forRoot({ type: CacheType.REDIS, isGlobal: false, // Each import creates new instance }), ], }) export class AppModule {} // Feature modules must explicitly import CacheModule @Module({ imports: [CacheModule.forFeature()], providers: [OrderService], }) export class OrderModule {} ``` ### Redis Configuration ```typescript CacheModule.forRoot({ type: CacheType.REDIS, cacheOptions: { defaultTtl: 300000, // TTL in milliseconds }, redisOptions: { host: process.env.REDIS_HOST || 'localhost', port: parseInt(process.env.REDIS_PORT || '6379'), password: process.env.REDIS_PASSWORD, database: 0, connectTimeout: 5000, // Connection timeout in ms keyPrefix: 'myapp:cache:', // Key prefix for all cache keys }, }) ``` ### Environment Variable Configuration CacheService auto-initializes from environment variables. When you import `CacheModule` **without** `.forRoot()`, it reads all configuration from env vars — no explicit options needed. ```bash # Cache type: 'memory' or 'redis' CACHE_TYPE=redis # Common options CACHE_DEFAULT_TTL=300000 # Default TTL in ms (default: 0 = no expiry) CACHE_MAX_SIZE=1000 # Max items for in-memory cache CACHE_CLEANUP_INTERVAL=60000 # Cleanup interval in ms # Redis options (only used when CACHE_TYPE=redis) CACHE_REDIS_HOST=localhost CACHE_REDIS_PORT=6379 CACHE_REDIS_PASSWORD=secret CACHE_REDIS_DATABASE=0 CACHE_REDIS_CONNECT_TIMEOUT=5000 CACHE_REDIS_KEY_PREFIX=myapp:cache: ``` Import `CacheModule` without `.forRoot()` — configuration comes entirely from env vars: ```typescript import { Module, Service, BaseService } from '@onebun/core'; import { CacheModule, CacheService } from '@onebun/cache'; // CacheModule without .forRoot() — auto-configures from env vars @Module({ imports: [CacheModule], providers: [MyService], }) class AppModule {} @Service() class MyService extends BaseService { constructor(private cacheService: CacheService) { super(); } } ``` ::: tip `CacheModule` must be imported at least once (in the root module) — it registers `CacheService` in the DI container. Since it's global by default, submodules get `CacheService` automatically. The difference is only whether you use `.forRoot(options)` (explicit config) or plain `CacheModule` (env-only config). ::: ### Configuration Priority Configuration is resolved in this order (first wins): 1. `CacheModule.forRoot()` options (explicit module configuration) 2. Environment variables (`CACHE_*`) 3. Default values (in-memory, no TTL) ### Custom Environment Prefix Use `envPrefix` to avoid collisions when running multiple cache instances: ```typescript CacheModule.forRoot({ type: CacheType.REDIS, envPrefix: 'ORDERS_CACHE', // Uses ORDERS_CACHE_REDIS_HOST, etc. }) ``` ### Redis Error Handling If Redis connection fails during auto-initialization, CacheService **automatically falls back to in-memory cache** and logs a warning: ```typescript // If CACHE_TYPE=redis but Redis is unreachable: // WARN: Failed to auto-initialize cache from environment // INFO: In-memory cache initialized (fallback) ``` **Technical details for AI agents:** * `CacheModule` is decorated with `@Global()` — by default `CacheService` is available in all modules without explicit import * `isGlobal` option in `CacheModuleOptions` (default: `true`). When `isGlobal: false`, calls `removeFromGlobalModules(CacheModule)` so each module must explicitly import CacheModule * `CacheModule.forFeature()` returns the module class for explicit import in submodules when non-global mode is used * `CacheService` auto-initializes in the constructor via `autoInitialize()` (called as `this.initPromise = this.autoInitialize()`) * `createCacheEnvSchema(prefix)` creates env schema with configurable prefix (default: `CACHE`) * Auto-init flow: check `CacheModule.forRoot()` options → load env vars → merge (module > env > defaults) → create cache instance * If Redis init fails, catches error and falls back to `createInMemoryCache()` — the service always initializes * Redis cache uses `createRedisCache(options)` which creates a Bun-native Redis client * In-memory cache uses `InMemoryCache` with LRU eviction, TTL, and periodic cleanup * `CacheService` implements `getStats()` returning `{ hits, misses, entries, hitRate }` * Shared Redis via `useSharedClient: true` in `createRedisCache()` reuses `SharedRedisProvider` ### Memory Configuration ```typescript CacheModule.forRoot({ type: CacheType.MEMORY, cacheOptions: { defaultTtl: 300000, // TTL in milliseconds maxSize: 1000, // Maximum items cleanupInterval: 60000, // Cleanup every 60 seconds (ms) }, }) ``` ## CacheService ### Injection ```typescript import { Service, BaseService } from '@onebun/core'; import { CacheService } from '@onebun/cache'; @Service() export class UserService extends BaseService { constructor(private cacheService: CacheService) { super(); } } ``` ### Methods #### `get()` Retrieve value from cache. ```typescript async get(key: string): Promise ``` ```typescript const user = await this.cacheService.get('user:123'); if (user) { // Cache hit return user; } // Cache miss ``` #### set() Store value in cache. ```typescript async set(key: string, value: T, options?: CacheSetOptions): Promise ``` ```typescript // With default TTL await this.cacheService.set('user:123', user); // With custom TTL (in seconds) await this.cacheService.set('user:123', user, { ttl: 600 }); // No expiration await this.cacheService.set('user:123', user, { ttl: 0 }); ``` #### delete() Remove value from cache. ```typescript async delete(key: string): Promise ``` ```typescript const deleted = await this.cacheService.delete('user:123'); ``` #### has() Check if key exists. ```typescript async has(key: string): Promise ``` ```typescript if (await this.cacheService.has('user:123')) { // Key exists } ``` #### clear() Clear all cache entries. ```typescript async clear(): Promise ``` ```typescript await this.cacheService.clear(); ``` #### `getMany()` Get multiple values at once. ```typescript async getMany(keys: string[]): Promise> ``` ```typescript const results = await this.cacheService.getMany([ 'user:1', 'user:2', 'user:3', ]); for (const [key, user] of results) { if (user) { console.log(key, user.name); } } ``` #### setMany() Set multiple values at once. ```typescript async setMany(entries: Map, options?: CacheSetOptions): Promise ``` ```typescript const users = new Map([ ['user:1', user1], ['user:2', user2], ]); await this.cacheService.setMany(users, { ttl: 300 }); ``` #### deleteMany() Delete multiple keys. ```typescript async deleteMany(keys: string[]): Promise ``` ```typescript const deletedCount = await this.cacheService.deleteMany([ 'user:1', 'user:2', ]); ``` ## Caching Patterns ### Cache-Aside Pattern ```typescript @Service() export class UserService extends BaseService { constructor( private cacheService: CacheService, private repository: UserRepository, ) { super(); } async findById(id: string): Promise { const cacheKey = `user:${id}`; // Try cache first const cached = await this.cacheService.get(cacheKey); if (cached) { this.logger.debug('Cache hit', { key: cacheKey }); return cached; } // Cache miss - fetch from database this.logger.debug('Cache miss', { key: cacheKey }); const user = await this.repository.findById(id); // Store in cache if (user) { await this.cacheService.set(cacheKey, user, { ttl: 300 }); } return user; } } ``` ### Cache Invalidation ```typescript @Service() export class UserService extends BaseService { async update(id: string, data: UpdateUserDto): Promise { const user = await this.repository.update(id, data); // Invalidate cache await this.cacheService.delete(`user:${id}`); // Also invalidate related caches await this.cacheService.delete('users:list'); return user; } async delete(id: string): Promise { await this.repository.delete(id); // Invalidate all related caches await this.cacheService.deleteMany([ `user:${id}`, `user:${id}:posts`, `user:${id}:settings`, 'users:list', ]); } } ``` ### Cache Warming ```typescript @Service() export class CacheWarmerService extends BaseService { constructor( private cacheService: CacheService, private userRepository: UserRepository, ) { super(); } async warmUserCache(): Promise { this.logger.info('Warming user cache'); const users = await this.userRepository.findAll({ limit: 1000 }); const entries = new Map( users.map(user => [`user:${user.id}`, user]) ); await this.cacheService.setMany(entries, { ttl: 3600 }); this.logger.info('User cache warmed', { count: users.length }); } } ``` ### Memoization ```typescript @Service() export class ConfigService extends BaseService { constructor(private cacheService: CacheService) { super(); } async getFeatureFlags(): Promise { const cacheKey = 'config:feature-flags'; // Very long TTL for rarely changing data let flags = await this.cacheService.get(cacheKey); if (!flags) { flags = await this.fetchFeatureFlags(); await this.cacheService.set(cacheKey, flags, { ttl: 3600 }); // 1 hour } return flags; } } ``` ## Cache Types ### CacheSetOptions ```typescript interface CacheSetOptions { /** Time-to-live in seconds. 0 for no expiration */ ttl?: number; } ``` ### CacheStats ```typescript interface CacheStats { hits: number; misses: number; keys: number; size: number; } ``` ## Shared Redis Connection For applications using both cache and WebSocket (or other Redis-based features), you can share a single Redis connection: ```typescript import { SharedRedisProvider } from '@onebun/core'; import { createRedisCache, RedisCache } from '@onebun/cache'; // Configure shared Redis at app startup SharedRedisProvider.configure({ url: 'redis://localhost:6379', keyPrefix: 'myapp:', }); // Option 1: Use shared client via options const cache = createRedisCache({ useSharedClient: true, defaultTtl: 60000, }); await cache.connect(); // Option 2: Pass RedisClient directly const sharedClient = await SharedRedisProvider.getClient(); const cache = new RedisCache(sharedClient); // Check if using shared connection console.log(cache.isUsingSharedClient()); // true ``` **Benefits:** * Single connection pool for cache and WebSocket * Reduced memory footprint * Consistent key prefixing across features ## Effect.js Integration For Effect.js-based usage: ```typescript import { createCacheModule, cacheServiceTag, CacheType } from '@onebun/cache'; import { Effect, pipe } from 'effect'; // Create service const cacheLayer = createCacheModule({ type: CacheType.MEMORY, cacheOptions: { defaultTtl: 300000, }, }); // Use in Effect const program = pipe( cacheServiceTag, Effect.flatMap((cache) => Effect.promise(() => cache.get('user:123')) ), ); // Run Effect.runPromise( Effect.provide(program, cacheLayer) ); ``` ## Complete Example ```typescript import { Module, Controller, BaseController, Service, BaseService, Get, Post, Delete, Param, Body } from '@onebun/core'; import { CacheModule, CacheService, CacheType } from '@onebun/cache'; import { type } from 'arktype'; // Types interface Product { id: string; name: string; price: number; stock: number; } // Service @Service() export class ProductService extends BaseService { private products = new Map(); constructor(private cacheService: CacheService) { super(); // Seed some data this.products.set('1', { id: '1', name: 'Widget', price: 9.99, stock: 100 }); this.products.set('2', { id: '2', name: 'Gadget', price: 19.99, stock: 50 }); } async findById(id: string): Promise { const cacheKey = `product:${id}`; // Check cache const cached = await this.cacheService.get(cacheKey); if (cached) { this.logger.debug('Product cache hit', { id }); return cached; } // Fetch from "database" const product = this.products.get(id) || null; // Cache result if (product) { await this.cacheService.set(cacheKey, product, { ttl: 60 }); } return product; } async findAll(): Promise { const cacheKey = 'products:all'; const cached = await this.cacheService.get(cacheKey); if (cached) { return cached; } const products = Array.from(this.products.values()); await this.cacheService.set(cacheKey, products, { ttl: 30 }); return products; } async updateStock(id: string, quantity: number): Promise { const product = this.products.get(id); if (!product) return null; product.stock += quantity; this.products.set(id, product); // Invalidate caches await this.cacheService.deleteMany([ `product:${id}`, 'products:all', ]); return product; } } // Controller @Controller('/products') export class ProductController extends BaseController { constructor(private productService: ProductService) { super(); } @Get('/') async findAll(): Promise { const products = await this.productService.findAll(); return this.success(products); } @Get('/:id') async findOne(@Param('id') id: string): Promise { const product = await this.productService.findById(id); if (!product) { return this.error('Product not found', 404, 404); } return this.success(product); } @Post('/:id/stock') async updateStock( @Param('id') id: string, @Body() body: { quantity: number }, ): Promise { const product = await this.productService.updateStock(id, body.quantity); if (!product) { return this.error('Product not found', 404, 404); } return this.success(product); } } // Module @Module({ imports: [ CacheModule.forRoot({ type: CacheType.MEMORY, cacheOptions: { defaultTtl: 300000, maxSize: 1000, }, }), ], controllers: [ProductController], providers: [ProductService], }) export class ProductModule {} ``` ## Advanced Patterns ### Cache Failover (Redis → In-Memory) OneBun does not have built-in automatic failover between cache backends at runtime. However, you can implement a wrapper service that catches Redis errors and falls back to an in-memory cache: ```typescript import { Service, BaseService } from '@onebun/core'; import { CacheService, createInMemoryCache, createRedisCache, type InMemoryCache, type RedisCache, type CacheSetOptions, } from '@onebun/cache'; @Service() export class ResilientCacheService extends BaseService { private primaryCache: RedisCache | null = null; private fallbackCache: InMemoryCache; private usingFallback = false; constructor() { super(); // Always have an in-memory fallback ready this.fallbackCache = createInMemoryCache({ defaultTtl: 300000, maxSize: 10000, }); } async onModuleInit(): Promise { try { this.primaryCache = createRedisCache({ host: process.env.REDIS_HOST || 'localhost', port: parseInt(process.env.REDIS_PORT || '6379'), defaultTtl: 300000, }); await this.primaryCache.connect(); this.logger.info('Redis cache connected (primary)'); } catch (error) { this.logger.warn('Redis unavailable, using in-memory fallback', error); this.usingFallback = true; } } async get(key: string): Promise { if (this.usingFallback || !this.primaryCache) { return this.fallbackCache.get(key); } try { return await this.primaryCache.get(key); } catch (error) { this.logger.warn('Redis get failed, falling back to memory', { key }); this.switchToFallback(); return this.fallbackCache.get(key); } } async set(key: string, value: T, options?: CacheSetOptions): Promise { // Always set in fallback for immediate availability await this.fallbackCache.set(key, value, options); if (!this.usingFallback && this.primaryCache) { try { await this.primaryCache.set(key, value, options); } catch (error) { this.logger.warn('Redis set failed, using memory only', { key }); this.switchToFallback(); } } } async delete(key: string): Promise { this.fallbackCache.delete(key); if (!this.usingFallback && this.primaryCache) { try { return await this.primaryCache.delete(key); } catch { this.switchToFallback(); } } return true; } private switchToFallback(): void { if (!this.usingFallback) { this.usingFallback = true; this.logger.warn('Switched to in-memory cache fallback'); // Try to reconnect periodically setTimeout(() => this.tryReconnect(), 30000); } } private async tryReconnect(): Promise { try { this.primaryCache = createRedisCache({ host: process.env.REDIS_HOST || 'localhost', port: parseInt(process.env.REDIS_PORT || '6379'), defaultTtl: 300000, }); await this.primaryCache.connect(); this.usingFallback = false; this.logger.info('Redis cache reconnected'); } catch { this.logger.warn('Redis reconnection failed, retrying in 30s'); setTimeout(() => this.tryReconnect(), 30000); } } } ``` **Important considerations:** * This pattern provides availability over consistency — the in-memory cache is local to each process instance * When running multiple service instances, in-memory fallback means each instance has its own cache (no sharing) * After Redis recovery, the in-memory cache data is NOT synchronized back to Redis * Consider using a health check to detect Redis availability and alert operations teams --- --- url: /api/controllers.md description: >- BaseController class, response methods (success/error), routing patterns, middleware integration, service injection. --- # Controllers API Package: `@onebun/core` ## BaseController Base class for all HTTP controllers. Provides standardized response methods, logger, and configuration access. ### Class Definition ```typescript export class Controller { protected logger: SyncLogger; protected config: unknown; /** Initialize controller with logger and config (called by framework) */ initializeController(logger: SyncLogger, config: unknown): void; /** Get a service instance by tag or class */ protected getService(tag: Context.Tag): T; protected getService(serviceClass: new (...args: unknown[]) => T): T; /** Set a service instance (used internally) */ setService(tag: Context.Tag, instance: T): void; /** Check if request has JSON content type */ protected isJson(req: OneBunRequest | Request): boolean; /** Parse JSON from request body */ protected async parseJson(req: OneBunRequest | Request): Promise; /** Create standardized success response */ protected success(result: T, status?: number): Response; /** Create standardized error response */ public error(message: string, code?: number, status?: number): Response; /** Create JSON response (alias for success) */ protected json(data: T, status?: number): Response; /** Create text response */ protected text(data: string, status?: number): Response; } ``` ### Usage Always extend `BaseController` (exported as `BaseController` from `@onebun/core`): ```typescript import { Controller, BaseController, Get, Post, Body } from '@onebun/core'; import { UserService } from './user.service'; @Controller('/users') export class UserController extends BaseController { constructor(private userService: UserService) { super(); // Always call super() } @Get('/') async findAll(): Promise { const users = await this.userService.findAll(); return this.success(users); } } ``` ## Response Methods ### success() Create a standardized success response. ```typescript protected success(result: T, status: number = 200): Response ``` **Response Format:** ```json { "success": true, "result": } ``` **Examples:** ```typescript @Get('/') async getUser(): Promise { // Simple data return this.success({ name: 'John', age: 30 }); // Array return this.success([{ id: 1 }, { id: 2 }]); // With custom status return this.success({ id: '123' }, 201); // Created } ``` ### error() Create a standardized error response. ```typescript public error( message: string, code: number = 500, status: number = 500 ): Response ``` **Response Format:** ```json { "success": false, "code": , "message": "" } ``` **Examples:** ```typescript @Get('/:id') async findOne(@Param('id') id: string): Promise { const user = await this.userService.findById(id); if (!user) { return this.error('User not found', 404, 404); } return this.success(user); } @Post('/') async create(@Body() body: unknown): Promise { try { const user = await this.userService.create(body); return this.success(user, 201); } catch (e) { // Validation error return this.error('Invalid data', 400, 400); } } ``` ### json() Alias for `success()`. Creates JSON response. ```typescript protected json(data: T, status: number = 200): Response ``` ### text() Create plain text response. ```typescript protected text(data: string, status: number = 200): Response ``` **Example:** ```typescript @Get('/health') async health(): Promise { return this.text('OK'); } @Get('/version') async version(): Promise { return this.text('1.0.0', 200); } ``` ## Accessing Services ### Via Constructor Injection (Recommended) ```typescript @Controller('/users') export class UserController extends BaseController { constructor( private userService: UserService, private cacheService: CacheService, ) { super(); } @Get('/') async findAll(): Promise { // Use injected services directly const cached = await this.cacheService.get('users'); if (cached) return this.success(cached); const users = await this.userService.findAll(); await this.cacheService.set('users', users, { ttl: 60 }); return this.success(users); } } ``` ### Via getService() (Legacy) ```typescript @Controller('/users') export class UserController extends BaseController { @Get('/') async findAll(): Promise { // By class const userService = this.getService(UserService); // By tag const userService = this.getService(UserServiceTag); const users = await userService.findAll(); return this.success(users); } } ``` ## Lifecycle Hooks Controllers support the same lifecycle hooks as services (`OnModuleInit`, `OnApplicationInit`, `OnModuleDestroy`, `BeforeApplicationDestroy`, `OnApplicationDestroy`). See [Services API — Lifecycle Hooks](/api/services#lifecycle-hooks) for the full reference and execution order. ## Accessing Logger ```typescript @Controller('/users') export class UserController extends BaseController { @Get('/') async findAll(): Promise { // Log levels: trace, debug, info, warn, error, fatal this.logger.info('Finding all users'); this.logger.debug('Request received', { timestamp: Date.now() }); try { const users = await this.userService.findAll(); this.logger.info('Users found', { count: users.length }); return this.success(users); } catch (error) { this.logger.error('Failed to find users', error); return this.error('Internal error', 500); } } } ``` ## Accessing Configuration ```typescript @Controller('/users') export class UserController extends BaseController { @Get('/info') async info(): Promise { // Access typed configuration (with module augmentation, no cast needed) const port = this.config.get('server.port'); // number const appName = this.config.get('app.name'); // string return this.success({ port, appName, configAvailable: this.config.isInitialized, }); } } ``` ## Working with Cookies OneBun uses Bun's native `CookieMap` (available on `BunRequest`) for cookie management. There are two ways to work with cookies: ### Reading Cookies via `@Cookie()` Decorator The simplest way to read a cookie value — extract it directly as a handler parameter: ```typescript import { Controller, BaseController, Get, Cookie } from '@onebun/core'; @Controller('/api') export class PrefsController extends BaseController { @Get('/preferences') async getPrefs( @Cookie('theme') theme?: string, // Optional by default @Cookie('lang') lang?: string, ) { return { theme: theme ?? 'light', lang: lang ?? 'en', }; } } ``` ### Reading Cookies via `req.cookies` For more control, use `@Req()` to access the full `CookieMap`: ```typescript import { Controller, BaseController, Get, Req, type OneBunRequest } from '@onebun/core'; @Controller('/api') export class ApiController extends BaseController { @Get('/session') async session(@Req() req: OneBunRequest) { const session = req.cookies.get('session'); return { session }; } } ``` ### Setting Cookies via `req.cookies` ```typescript import { Controller, BaseController, Post, Req, Body, type OneBunRequest } from '@onebun/core'; @Controller('/api') export class AuthController extends BaseController { @Post('/login') async login(@Req() req: OneBunRequest, @Body() body: unknown) { // Set cookie via CookieMap req.cookies.set('session', 'new-session-id', { httpOnly: true, path: '/', maxAge: 3600, }); return { loggedIn: true }; } } ``` ### Deleting Cookies ```typescript @Post('/logout') async logout(@Req() req: OneBunRequest) { req.cookies.delete('session'); return { loggedOut: true }; } ``` ## Custom Response Headers To return custom headers, return a `Response` object directly from your handler: ```typescript @Controller('/api') export class DownloadController extends BaseController { @Get('/download') async download() { return new Response(JSON.stringify({ data: 'file content' }), { status: 200, headers: { 'Content-Type': 'application/json', 'X-Custom-Header': 'custom-value', 'Cache-Control': 'no-store', }, }); } } ``` ### Setting Cookies via Set-Cookie Header For multiple `Set-Cookie` headers, use the `Headers` API with `append()`: ```typescript @Controller('/api') export class AuthController extends BaseController { @Post('/login') async login(@Body() body: unknown) { const headers = new Headers(); headers.set('Content-Type', 'application/json'); headers.append('Set-Cookie', 'session=abc123; Path=/; HttpOnly'); headers.append('Set-Cookie', 'theme=dark; Path=/'); return new Response(JSON.stringify({ loggedIn: true }), { status: 200, headers, }); } } ``` ::: tip Multiple Set-Cookie Headers OneBun correctly preserves multiple `Set-Cookie` headers. Use `Headers.append()` (not `set()`) to add multiple cookies without overwriting previous ones. ::: ## Middleware OneBun provides a class-based middleware system that operates at four levels: **application-wide**, **module-level**, **controller-level**, and **route-level**. All middleware extends `BaseMiddleware`, giving automatic access to a scoped logger, configuration, and full DI support through the constructor. Use the `@Middleware()` class decorator so that constructor dependencies are resolved automatically. ### BaseMiddleware Every middleware class extends `BaseMiddleware` and implements the `use()` method. Use the `@Middleware()` decorator on the class so that constructor dependencies (if any) are resolved automatically: ```typescript import { BaseMiddleware, Middleware, type OneBunRequest, type OneBunResponse } from '@onebun/core'; @Middleware() class RequestLogMiddleware extends BaseMiddleware { async use(req: OneBunRequest, next: () => Promise) { // Pre-processing: run before the handler // this.logger is scoped to the class name automatically this.logger.info(`${req.method} ${new URL(req.url).pathname}`); // Call next() to continue the chain (other middleware or the handler) const response = await next(); // Post-processing: run after the handler (optional) response.headers.set('X-Request-Duration', String(Date.now())); return response; } } ``` * `this.logger` — `SyncLogger` scoped to the middleware class name (e.g., `RequestLogMiddleware`) * `this.config` — `IConfig` for reading environment variables * `req` — the incoming `OneBunRequest` (extends `Request` with `.cookies` and `.params`) * `next()` — calls the next middleware or the route handler; returns `OneBunResponse` * Return an `OneBunResponse` directly to short-circuit the chain (e.g., for auth failures) ### Middleware with Dependency Injection Middleware supports full constructor-based DI, just like controllers. Decorate the middleware class with `@Middleware()` so that the framework can resolve constructor dependencies automatically (TypeScript emits `design:paramtypes` when a class has a decorator). Inject any service available in the module's DI scope. You can still use `@Inject()` on parameters when needed. ```typescript import { BaseMiddleware, Middleware, type OneBunRequest, type OneBunResponse } from '@onebun/core'; import { AuthService } from './auth.service'; @Middleware() class AuthMiddleware extends BaseMiddleware { constructor(private authService: AuthService) { super(); } async use(req: OneBunRequest, next: () => Promise) { const token = req.headers.get('Authorization'); const secret = this.config.get('auth.jwtSecret'); if (!this.authService.verify(token, secret)) { this.logger.warn('Authentication failed'); return new Response(JSON.stringify({ success: false, code: 401, message: 'Unauthorized', }), { status: 401, headers: { 'Content-Type': 'application/json' } }); } return next(); } } ``` Middleware is instantiated **once** at application startup and reused for every request. ### Route-Level Middleware Apply middleware to a single route handler using `@UseMiddleware()` as a method decorator. Pass **class constructors** (not instances): ```typescript import { Controller, BaseController, Get, Post, UseMiddleware } from '@onebun/core'; @Controller('/api') export class ApiController extends BaseController { @Get('/public') publicEndpoint() { return { message: 'Anyone can see this' }; } @Post('/protected') @UseMiddleware(AuthMiddleware) protectedEndpoint() { return { message: 'Auth required' }; } } ``` You can pass multiple middleware to a single `@UseMiddleware()` — they execute left to right: ```typescript @Post('/action') @UseMiddleware(LogMiddleware, AuthMiddleware, RateLimitMiddleware) action() { return { ok: true }; } ``` ### Controller-Level Middleware Apply middleware to **every route** in a controller by using `@UseMiddleware()` as a class decorator: ```typescript import { Controller, BaseController, Get, Put, UseMiddleware } from '@onebun/core'; @Controller('/admin') @UseMiddleware(AuthMiddleware) export class AdminController extends BaseController { // AuthMiddleware runs before every handler in this controller @Get('/dashboard') getDashboard() { return { stats: { users: 100 } }; } @Put('/settings') updateSettings() { return { updated: true }; } } ``` Controller-level middleware can be combined with route-level middleware. The execution order is always **controller -> route**: ```typescript @Controller('/admin') @UseMiddleware(AuthMiddleware) // Runs first on all routes export class AdminController extends BaseController { @Get('/dashboard') getDashboard() { // Only AuthMiddleware runs return { stats: {} }; } @Put('/settings') @UseMiddleware(AuditLogMiddleware) // Runs second, only on this route updateSettings() { // AuthMiddleware, then AuditLogMiddleware return { updated: true }; } } ``` ### Module-Level Middleware Apply middleware to **all controllers within a module** (including controllers in imported child modules) by implementing the `OnModuleConfigure` interface: ```typescript import { Module, type OnModuleConfigure, type MiddlewareClass, } from '@onebun/core'; import { UserController } from './user.controller'; import { ProfileController } from './profile.controller'; @Module({ controllers: [UserController, ProfileController], }) export class UserModule implements OnModuleConfigure { configureMiddleware(): MiddlewareClass[] { return [TenantMiddleware]; } } ``` Module middleware is **inherited by child modules**. If `RootModule` imports `UserModule`, and both configure middleware, the execution order is: root module middleware -> user module middleware: ```typescript @Module({ imports: [UserModule, OrderModule], controllers: [HealthController], }) export class AppModule implements OnModuleConfigure { configureMiddleware(): MiddlewareClass[] { return [RequestIdMiddleware]; // Applied to ALL controllers in AppModule + UserModule + OrderModule } } ``` In this setup: * `HealthController` gets: `[RequestIdMiddleware]` * Controllers in `UserModule` get: `[RequestIdMiddleware, TenantMiddleware]` * Controllers in `OrderModule` get: `[RequestIdMiddleware]` (if OrderModule has no own middleware) ### Application-Wide Middleware Apply middleware to **every route in every controller** by passing the `middleware` option to `OneBunApplication`: ```typescript import { OneBunApplication } from '@onebun/core'; import { AppModule } from './app.module'; import { RequestIdMiddleware, CorsMiddleware } from './middleware'; const app = new OneBunApplication(AppModule, { port: 3000, middleware: [RequestIdMiddleware, CorsMiddleware], }); await app.start(); ``` Application-wide middleware runs **before** any module-level, controller-level or route-level middleware. ### Application-Wide Middleware with MultiServiceApplication For multi-service setups, middleware can be defined at the application level (shared by all services) or per service: ```typescript import { MultiServiceApplication } from '@onebun/core'; const app = new MultiServiceApplication({ services: { users: { module: UsersModule, port: 3001 }, orders: { module: OrdersModule, port: 3002, middleware: [OrderSpecificMiddleware], // Overrides app-level middleware }, }, middleware: [RequestIdMiddleware, CorsMiddleware], // Shared middleware for all services }); ``` ### Middleware Execution Order When all four levels are used, middleware executes in this order: ``` HTTP Request │ ▼ ┌─────────────────────────────────────────────┐ │ 1. Application-wide middleware │ │ (ApplicationOptions.middleware) │ └─────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────┐ │ 2. Module-level middleware │ │ (OnModuleConfigure.configureMiddleware()) │ │ Root module -> ... -> owner module │ └─────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────┐ │ 3. Controller-level middleware │ │ (@UseMiddleware on the class) │ └─────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────┐ │ 4. Route-level middleware │ │ (@UseMiddleware on the method) │ └─────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────┐ │ 5. Controller Handler │ └─────────────────────────────────────────────┘ │ ▼ Response (flows back through the chain) ``` Each middleware can perform **pre-processing** (before `next()`) and **post-processing** (after `next()`) — similar to the "onion model." ### Real-World Examples #### Custom Authentication Middleware ```typescript import { BaseMiddleware, type OneBunRequest, type OneBunResponse } from '@onebun/core'; export class JwtAuthMiddleware extends BaseMiddleware { async use(req: OneBunRequest, next: () => Promise) { const authHeader = req.headers.get('Authorization'); if (!authHeader?.startsWith('Bearer ')) { this.logger.warn('Missing or invalid Authorization header'); return new Response(JSON.stringify({ success: false, code: 401, message: 'Missing or invalid Authorization header', }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } // Validate the token (your logic here) const token = authHeader.slice(7); this.logger.debug('Validating JWT token'); // ... verify token, attach user info, etc. return next(); } } ``` #### Request Validation Middleware ```typescript import { BaseMiddleware, type OneBunRequest, type OneBunResponse } from '@onebun/core'; export class JsonOnlyMiddleware extends BaseMiddleware { async use(req: OneBunRequest, next: () => Promise) { if (req.method !== 'GET' && req.method !== 'DELETE') { const contentType = req.headers.get('Content-Type'); if (!contentType?.includes('application/json')) { this.logger.warn(`Invalid Content-Type: ${contentType}`); return new Response(JSON.stringify({ success: false, code: 415, message: 'Content-Type must be application/json', }), { status: 415, headers: { 'Content-Type': 'application/json' }, }); } } return next(); } } ``` #### Timing / Logging Middleware ```typescript import { BaseMiddleware, type OneBunRequest, type OneBunResponse } from '@onebun/core'; export class TimingMiddleware extends BaseMiddleware { async use(req: OneBunRequest, next: () => Promise) { const start = performance.now(); const response = await next(); const duration = (performance.now() - start).toFixed(2); response.headers.set('X-Response-Time', `${duration}ms`); this.logger.info(`${req.method} ${new URL(req.url).pathname} — ${duration}ms`); return response; } } ``` #### Combining All Levels ```typescript // Global: runs on every request in the application const app = new OneBunApplication(AppModule, { middleware: [RequestIdMiddleware, TimingMiddleware], }); // Controller: runs on every route in AdminController @Controller('/admin') @UseMiddleware(JwtAuthMiddleware) class AdminController extends BaseController { @Get('/stats') getStats() { /* ... */ } // Route: runs only on POST /admin/users, after JwtAuth @Post('/users') @UseMiddleware(JsonOnlyMiddleware) createUser() { /* ... */ } } ``` For `GET /admin/stats`, the execution order is: 1. `RequestIdMiddleware` (global) 2. `TimingMiddleware` (global) 3. `JwtAuthMiddleware` (controller) 4. `getStats()` handler For `POST /admin/users`, the execution order is: 1. `RequestIdMiddleware` (global) 2. `TimingMiddleware` (global) 3. `JwtAuthMiddleware` (controller) 4. `JsonOnlyMiddleware` (route) 5. `createUser()` handler ## Request Helpers ### isJson() Check if request has JSON content type. ```typescript @Post('/') async create(@Req() req: OneBunRequest): Promise { if (!this.isJson(req)) { return this.error('Content-Type must be application/json', 400, 400); } // ... } ``` ### parseJson() Parse JSON from request body (when not using @Body decorator). ```typescript @Post('/') async create(@Req() req: OneBunRequest): Promise { const body = await this.parseJson(req); // body is typed as CreateUserDto } ``` ## HTTP Status Codes Import common status codes: ```typescript import { HttpStatusCode } from '@onebun/core'; @Get('/:id') async findOne(@Param('id') id: string): Promise { const user = await this.userService.findById(id); if (!user) { return this.error('Not found', HttpStatusCode.NOT_FOUND, HttpStatusCode.NOT_FOUND); } return this.success(user, HttpStatusCode.OK); } @Post('/') async create(@Body() body: CreateUserDto): Promise { const user = await this.userService.create(body); return this.success(user, HttpStatusCode.CREATED); } ``` **Available Status Codes:** ```typescript enum HttpStatusCode { OK = 200, CREATED = 201, ACCEPTED = 202, NO_CONTENT = 204, BAD_REQUEST = 400, UNAUTHORIZED = 401, FORBIDDEN = 403, NOT_FOUND = 404, METHOD_NOT_ALLOWED = 405, CONFLICT = 409, UNPROCESSABLE_ENTITY = 422, INTERNAL_SERVER_ERROR = 500, NOT_IMPLEMENTED = 501, BAD_GATEWAY = 502, SERVICE_UNAVAILABLE = 503, } ``` ## Complete Controller Example ```typescript import { Controller, BaseController, Get, Post, Put, Delete, Param, Body, Query, Header, Cookie, Req, UseMiddleware, HttpStatusCode, type OneBunRequest, } from '@onebun/core'; import { type } from 'arktype'; import { UserService } from './user.service'; import { authMiddleware } from './middleware/auth'; // Validation schemas const createUserSchema = type({ name: 'string', email: 'string.email', 'role?': '"admin" | "user"', }); const updateUserSchema = type({ 'name?': 'string', 'email?': 'string.email', 'role?': '"admin" | "user"', }); const paginationSchema = type({ page: 'string.numeric.parse', limit: 'string.numeric.parse', }); @Controller('/api/users') export class UserController extends BaseController { constructor(private userService: UserService) { super(); } /** * GET /api/users * List all users with pagination */ @Get('/') async findAll( @Query('page') page: string = '1', @Query('limit') limit: string = '10', ): Promise { this.logger.info('Listing users', { page, limit }); const users = await this.userService.findAll({ page: parseInt(page, 10), limit: parseInt(limit, 10), }); return this.success({ users: users.items, total: users.total, page: users.page, limit: users.limit, }); } /** * GET /api/users/:id * Get user by ID */ @Get('/:id') async findOne(@Param('id') id: string): Promise { this.logger.debug('Finding user', { id }); const user = await this.userService.findById(id); if (!user) { this.logger.warn('User not found', { id }); return this.error('User not found', HttpStatusCode.NOT_FOUND, HttpStatusCode.NOT_FOUND); } return this.success(user); } /** * POST /api/users * Create new user (requires authentication) */ @Post('/') @UseMiddleware(authMiddleware) async create( @Body(createUserSchema) body: typeof createUserSchema.infer, @Header('X-Request-ID') requestId?: string, ): Promise { this.logger.info('Creating user', { email: body.email, requestId }); try { const user = await this.userService.create(body); this.logger.info('User created', { userId: user.id }); return this.success(user, HttpStatusCode.CREATED); } catch (error) { if (error instanceof Error && error.message.includes('duplicate')) { return this.error('Email already exists', HttpStatusCode.CONFLICT, HttpStatusCode.CONFLICT); } throw error; } } /** * PUT /api/users/:id * Update user */ @Put('/:id') @UseMiddleware(authMiddleware) async update( @Param('id') id: string, @Body(updateUserSchema) body: typeof updateUserSchema.infer, ): Promise { this.logger.info('Updating user', { id, fields: Object.keys(body) }); const user = await this.userService.update(id, body); if (!user) { return this.error('User not found', HttpStatusCode.NOT_FOUND, HttpStatusCode.NOT_FOUND); } return this.success(user); } /** * DELETE /api/users/:id * Delete user */ @Delete('/:id') @UseMiddleware(authMiddleware) async remove(@Param('id') id: string): Promise { this.logger.info('Deleting user', { id }); const deleted = await this.userService.delete(id); if (!deleted) { return this.error('User not found', HttpStatusCode.NOT_FOUND, HttpStatusCode.NOT_FOUND); } return this.success({ deleted: true }); } /** * GET /api/users/search * Search users */ @Get('/search') async search( @Query('q') query: string, @Query('field') field: string = 'name', ): Promise { if (!query) { return this.error('Query parameter "q" is required', HttpStatusCode.BAD_REQUEST, HttpStatusCode.BAD_REQUEST); } const users = await this.userService.search(query, field); return this.success(users); } } ``` ## Server-Sent Events (SSE) Server-Sent Events provide a way to push data from the server to the client over HTTP. OneBun provides the `@Sse()` decorator and `sse()` method for creating SSE endpoints. > **Connection keep-alive:** SSE endpoints automatically get: > > * **Per-request timeout**: 600 seconds (10 minutes) by default, configurable via `@Sse({ timeout: seconds })` or `@Get('/path', { timeout: seconds })`. Set to `0` to disable. > * **Heartbeat**: a comment (`: heartbeat\n\n`) sent every 30 seconds by default via the `@Sse()` decorator. Override with `@Sse({ heartbeat: ms })` or disable with `@Sse({ heartbeat: 0 })`. > * The global `idleTimeout` (default: 120 seconds) applies to all other connections. > > For regular (non-SSE) endpoints that run long, use `@Get('/path', { timeout: 300 })` or `{ timeout: 0 }` to disable. ### SseEvent Type ```typescript interface SseEvent { /** Event name (optional, defaults to 'message') */ event?: string; /** Event data (will be JSON serialized) */ data: unknown; /** Event ID for reconnection (Last-Event-ID header) */ id?: string; /** Reconnection interval in milliseconds */ retry?: number; } ``` ### SseOptions ```typescript interface SseOptions { /** * Heartbeat interval in milliseconds. * When set, the server will send a comment (": heartbeat\n\n") * at this interval to keep the connection alive. * Default for @Sse() decorator: 30000 (30 seconds). * For sse() method: no default — set explicitly if needed. */ heartbeat?: number; } ``` ### @Sse() Decorator The `@Sse()` decorator marks a method as an SSE endpoint. The method should be an async generator that yields `SseEvent` objects. By default, a heartbeat is sent every 30 seconds and the per-request timeout is 600 seconds (10 minutes). ```typescript @Sse() // defaults: heartbeat=30s, timeout=600s @Sse({ heartbeat: 15000 }) // custom heartbeat, default timeout @Sse({ timeout: 0 }) // no timeout, default heartbeat @Sse({ heartbeat: 5000, timeout: 3600 }) // custom both ``` ```typescript import { Controller, BaseController, Get, Sse, type SseGenerator, } from '@onebun/core'; @Controller('/events') export class EventsController extends BaseController { /** * Simple SSE endpoint * Client: new EventSource('/events/stream') */ @Get('/stream') @Sse() async *stream(): SseGenerator { for (let i = 0; i < 10; i++) { await Bun.sleep(1000); yield { event: 'tick', data: { count: i, timestamp: Date.now() } }; } // Stream closes automatically when generator completes } /** * SSE with heartbeat for long-lived connections * Sends ": heartbeat\n\n" every 15 seconds to keep connection alive */ @Get('/live') @Sse({ heartbeat: 15000 }) async *live(): SseGenerator { // Initial connection event yield { event: 'connected', data: { clientId: crypto.randomUUID() } }; // Infinite stream - client can disconnect anytime while (true) { const update = await this.getService(DataService).waitForUpdate(); yield { event: 'update', data: update }; } } /** * SSE with event IDs for reconnection support */ @Get('/notifications') @Sse({ heartbeat: 30000 }) async *notifications(): SseGenerator { let eventId = 0; while (true) { const notification = await this.getService(NotificationService).poll(); eventId++; yield { event: 'notification', data: notification, id: String(eventId), retry: 5000, // Client should retry after 5 seconds on disconnect }; } } } ``` ### sse() Method The `sse()` method provides an alternative way to create SSE responses programmatically: ```typescript @Controller('/events') export class EventsController extends BaseController { /** * Using sse() method instead of @Sse() decorator */ @Get('/manual') events(): Response { return this.sse(async function* () { yield { event: 'start', data: { timestamp: Date.now() } }; for (let i = 0; i < 5; i++) { await Bun.sleep(1000); yield { event: 'progress', data: { percent: (i + 1) * 20 } }; } yield { event: 'complete', data: { success: true } }; }()); } /** * Using sse() with heartbeat option */ @Get('/with-heartbeat') eventsWithHeartbeat(): Response { const generator = async function* () { while (true) { await Bun.sleep(5000); yield { data: { ping: true } }; } }; return this.sse(generator(), { heartbeat: 10000 }); } } ``` ### SSE Wire Format OneBun automatically formats events according to the SSE specification: ``` event: tick id: 123 retry: 5000 data: {"count":1,"timestamp":1699999999999} ``` For multi-line data: ``` data: {"line1":"value1", data: "line2":"value2"} ``` ### Client-Side Usage ```typescript // Browser JavaScript const eventSource = new EventSource('/events/stream'); eventSource.addEventListener('tick', (event) => { const data = JSON.parse(event.data); console.log('Tick:', data.count); }); eventSource.addEventListener('error', (event) => { console.error('SSE error:', event); }); // Close connection when done eventSource.close(); ``` ### Handling Client Disconnect (Abort) When a client disconnects (via `AbortController`, `EventSource.close()`, or browser navigation), OneBun properly terminates the async generator by calling `iterator.return()`. This triggers the generator's `finally` block, providing a natural cleanup hook. #### `try/finally` Cleanup (Idiomatic for `@Sse()`) Use `try/finally` inside the generator to run cleanup logic on client disconnect. This is the recommended approach for `@Sse()` decorator endpoints: ```typescript @Controller('/events') export class EventsController extends BaseController { @Get('/stream') @Sse({ heartbeat: 15000 }) async *stream(): SseGenerator { const subscription = this.eventService.subscribe(); try { for await (const event of subscription) { yield { event: 'update', data: event }; } } finally { // Runs when client disconnects -- cleanup resources subscription.unsubscribe(); } } } ``` #### SSE Proxy Pattern with `try/finally` When proxying a 3rd party SSE stream, use `try/finally` to abort the upstream connection on client disconnect: ```typescript @Controller('/proxy') export class ProxyController extends BaseController { @Get('/events') @Sse() async *proxyEvents(): SseGenerator { const ac = new AbortController(); try { const response = await fetch('https://api.example.com/events', { signal: ac.signal, }); const reader = response.body!.getReader(); const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) break; const text = decoder.decode(value); yield { event: 'proxied', data: text }; } } finally { // Client disconnected -- abort the upstream SSE connection ac.abort(); } } } ``` #### `onAbort` Callback (for `sse()` helper) The `sse()` method accepts an `onAbort` callback that fires when the client disconnects. This is useful when you have cleanup logic that doesn't fit in a `try/finally`: ```typescript @Controller('/events') export class EventsController extends BaseController { @Get('/live') live(): Response { const subscription = this.eventService.subscribe(); return this.sse(subscription, { heartbeat: 15000, onAbort: () => subscription.unsubscribe(), }); } } ``` #### Factory Function with `AbortSignal` (SSE proxy via `sse()`) The `sse()` method also accepts a factory function `(signal: AbortSignal) => AsyncIterable`. The framework creates an `AbortController` internally and aborts it on client disconnect. This is the cleanest approach for SSE proxying with the `sse()` helper: ```typescript @Controller('/proxy') export class ProxyController extends BaseController { @Get('/events') proxy(): Response { return this.sse((signal) => this.proxyUpstream(signal)); } private async *proxyUpstream(signal: AbortSignal): SseGenerator { const response = await fetch('https://api.example.com/events', { signal }); const reader = response.body!.getReader(); const decoder = new TextDecoder(); while (!signal.aborted) { const { done, value } = await reader.read(); if (done) break; yield { event: 'proxied', data: decoder.decode(value) }; } // When client disconnects -> signal aborted -> fetch aborted automatically } } ``` ### Comparison: @Sse() vs sse() | Feature | @Sse() Decorator | sse() Method | |---------|------------------|--------------| | Use case | Dedicated SSE endpoints | Programmatic/conditional SSE | | Syntax | `async *method()` generator | Return `this.sse(generator)` | | Heartbeat | `@Sse({ heartbeat: ms })` (default: 30s) | `this.sse(gen, { heartbeat: ms })` (no default) | | Timeout | `@Sse({ timeout: s })` (default: 600s) | Use `@Get('/path', { timeout: s })` | | Response type | Auto-wrapped | Explicit Response return | | Disconnect cleanup | `try/finally` in generator | `onAbort` callback or `try/finally` | | SSE proxy | `try/finally` + `AbortController` | Factory function with `AbortSignal` | **Use `@Sse()` when:** * The endpoint is always an SSE stream * You want cleaner async generator syntax * You need built-in heartbeat and timeout defaults **Use `sse()` when:** * You need conditional SSE (sometimes SSE, sometimes JSON) * You're composing generators from multiple sources * You want more control over the Response object * You need the factory function pattern with `AbortSignal` ## File Uploads OneBun supports file uploads via `multipart/form-data` and JSON with base64-encoded data. The framework auto-detects the content type and provides a unified `OneBunFile` object regardless of the upload method. ### Single File Upload ```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, // 5 MB mimeTypes: [MimeType.ANY_IMAGE], // Any image type }) file: OneBunFile, ): Promise { // Write to disk await file.writeTo(`./uploads/${file.name}`); // Or convert to base64 const base64 = await file.toBase64(); // Or get as Buffer const buffer = await file.toBuffer(); return this.success({ filename: file.name, size: file.size, type: file.type, }); } } ``` ### Multiple File Upload ```typescript @Post('/documents') async uploadDocuments( @UploadedFiles('docs', { maxCount: 10, maxSize: 10 * 1024 * 1024, mimeTypes: [MimeType.PDF, MimeType.DOCX], }) files: OneBunFile[], ): Promise { for (const file of files) { await file.writeTo(`./uploads/${file.name}`); } return this.success({ uploaded: files.length }); } ``` ### File with Form Fields ```typescript @Post('/profile') async createProfile( @UploadedFile('avatar', { mimeTypes: [MimeType.ANY_IMAGE] }) avatar: OneBunFile, @FormField('name', { required: true }) name: string, @FormField('email') email: string, ): Promise { await avatar.writeTo(`./uploads/${avatar.name}`); return this.success({ name, email, avatar: avatar.name }); } ``` ### JSON Base64 Upload The same decorators work for JSON bodies with base64-encoded files. The client can send: ```json { "avatar": { "data": "iVBORw0KGgo...", "filename": "photo.png", "mimeType": "image/png" } } ``` Or a simplified format: ```json { "avatar": "iVBORw0KGgo..." } ``` The controller code is identical — `@UploadedFile('avatar')` will work for both `multipart/form-data` and `application/json` content types. ::: warning `@Body()` cannot be used on the same method as `@UploadedFile`, `@UploadedFiles`, or `@FormField`. Both consume the request body. ::: --- --- url: /api/core.md description: >- OneBunApplication, MultiServiceApplication classes. Bootstrap options, graceful shutdown, metrics and tracing configuration. --- ## Quick Reference for AI **Minimal App Bootstrap**: ```typescript import { OneBunApplication } from '@onebun/core'; import { AppModule } from './app.module'; // Uses PORT/HOST env vars if set, otherwise defaults (3000 / '0.0.0.0') const app = new OneBunApplication(AppModule); await app.start(); // Or with explicit port (overrides PORT env var) const app2 = new OneBunApplication(AppModule, { port: 3000 }); ``` **Port/Host Resolution Priority**: 1. Explicit option passed to constructor 2. Environment variable (PORT / HOST) 3. Default value (3000 / '0.0.0.0') **With Full Options**: ```typescript const app = new OneBunApplication(AppModule, { port: 3000, // overrides PORT env var host: '0.0.0.0', // overrides HOST env var basePath: '/api/v1', envSchema, // from @onebun/envs metrics: { enabled: true, path: '/metrics', prefix: 'myapp_' }, tracing: { enabled: true, serviceName: 'my-service' }, loggerOptions: { minLevel: 'info', format: 'json' }, gracefulShutdown: true, // default // Security shortcuts (auto-add built-in middleware): cors: { origin: 'https://my-frontend.com', credentials: true }, rateLimit: { windowMs: 60_000, max: 100 }, security: true, // Exception filters (applied globally to all routes): filters: [myGlobalExceptionFilter], }); ``` **Guards and Filters**: * Use `@UseGuards(AuthGuard)` on a controller or route method to add authorization * Use `@UseFilters(myFilter)` on a controller or route method to add error handling * Both decorators merge with parent-level (controller + route, global + controller + route) * See [Guards](./guards.md) and [Exception Filters](./exception-filters.md) for full docs **Security Middleware shorthand**: ```typescript // CORS + rate limiting + security headers in one line each: cors: { origin: '*' } // or: cors: true rateLimit: { max: 100 } // or: rateLimit: true security: { xFrameOptions: 'DENY' } // or: security: true ``` Auto-ordering: CorsMiddleware → RateLimitMiddleware → \[user middleware] → SecurityHeadersMiddleware **Static files (SPA on same host)**: ```typescript const app = new OneBunApplication(AppModule, { static: { root: './dist', fallbackFile: 'index.html' }, }); await app.start(); // API at /api, docs at /docs; all other GET requests serve from ./dist or index.html for SPA routing ``` **Important Methods**: * `app.start()` - starts HTTP server * `app.stop()` - graceful shutdown (calls lifecycle hooks) * `app.getService(ServiceClass)` - get service instance by class * `app.getLogger({ className: 'X' })` - get logger instance * `app.getConfig()` - get typed config service * `app.getConfigValue('path.to.config')` - read config value (fully typed with module augmentation) * `app.getHttpUrl()` - get listening URL **Lifecycle Hooks** (implement via `implements OnModuleInit`, etc.): * `onModuleInit()` - after service/controller created (sequential, in dependency order; called for all providers including standalone services; works across the entire module import tree) * `onApplicationInit()` - after all modules, before HTTP starts * `onModuleDestroy()` - during shutdown * `beforeApplicationDestroy(signal?)` - start of shutdown * `onApplicationDestroy(signal?)` - end of shutdown **MultiServiceApplication** - for running multiple services in one process, useful for local development or monolith deployment. # Core Package API Package: `@onebun/core` ## OneBunApplication Main application class that bootstraps and runs the HTTP server. ### Constructor ```typescript new OneBunApplication( moduleClass: new (...args: unknown[]) => object, options?: Partial ) ``` ### ApplicationOptions ```typescript interface ApplicationOptions { /** Application name for metrics/tracing labels */ name?: string; /** Port to listen on * Priority: explicit option > PORT env variable > default (3000) */ port?: number; /** Host to listen on * Priority: explicit option > HOST env variable > default ('0.0.0.0') */ host?: string; /** Maximum idle time (seconds) before the server closes a connection. * A connection is idle when no data is sent or received. * Set to 0 to disable. Default: 120. */ idleTimeout?: number; /** Base path prefix for all routes (e.g., '/api/v1') */ basePath?: string; /** Route prefix to prepend to all routes (typically service name) */ routePrefix?: string; /** Enable development mode (default: NODE_ENV !== 'production') */ development?: boolean; /** Logger configuration options. * Provides a declarative way to configure logging. * Priority: loggerLayer > loggerOptions > LOG_LEVEL/LOG_FORMAT env > NODE_ENV defaults */ loggerOptions?: LoggerOptions; /** Custom logger layer (advanced, takes precedence over loggerOptions) */ loggerLayer?: Layer.Layer; /** Environment configuration schema */ envSchema?: TypedEnvSchema; /** Environment loading options */ envOptions?: { envFilePath?: string; loadDotEnv?: boolean; envOverridesDotEnv?: boolean; strict?: boolean; defaultArraySeparator?: string; valueOverrides?: Record; }; /** Metrics configuration */ metrics?: MetricsOptions; /** Tracing configuration */ tracing?: TracingOptions; /** WebSocket configuration */ websocket?: WebSocketApplicationOptions; /** Static file serving: serve files from a directory for requests not matched by API routes */ static?: StaticApplicationOptions; /** * Application-wide middleware class constructors applied to every route * before module-level, controller-level and route-level middleware. * Classes must extend BaseMiddleware. DI is fully supported. * Execution order: global → module → controller → route → handler. * See Controllers API — Middleware for details. */ middleware?: MiddlewareClass[]; /** Enable graceful shutdown on SIGTERM/SIGINT (default: true) */ gracefulShutdown?: boolean; /** Global exception filters. Route/controller filters take priority. */ filters?: ExceptionFilter[]; /** * CORS shorthand — auto-prepends CorsMiddleware. * Pass `true` for permissive defaults, or a CorsOptions object for custom config. * See Security Middleware for details. */ cors?: CorsOptions | true; /** * Rate limiting shorthand — auto-prepends RateLimitMiddleware. * Pass `true` for defaults (100 req / 60s, in-memory), or a RateLimitOptions object. * See Security Middleware for details. */ rateLimit?: RateLimitOptions | true; /** * Security headers shorthand — auto-appends SecurityHeadersMiddleware. * Pass `true` for all defaults, or a SecurityHeadersOptions object. * See Security Middleware for details. */ security?: SecurityHeadersOptions | true; } ``` #### StaticApplicationOptions When `static` is set, the same HTTP server serves API routes (and `/docs`, `/metrics`, WebSocket) as usual; any request that does not match those routes is served from a filesystem directory. ```typescript interface StaticApplicationOptions { /** Filesystem path to the directory to serve (static root). Absolute or relative to cwd. */ root: string; /** * URL path prefix under which static files are served. * Omit or '/' = serve static for all paths not matched by API. * Example: '/app' = only paths starting with /app are served (prefix stripped when resolving file). */ pathPrefix?: string; /** * Fallback file name (e.g. 'index.html') for SPA-style client-side routing. * When the requested file is not found, this file under static root is returned. */ fallbackFile?: string; /** * TTL in ms for caching file existence checks. Use 0 to disable. Default: 60000. * Uses @onebun/cache CacheService when available, otherwise in-memory cache. */ fileExistenceCacheTtlMs?: number; } ``` **Example: SPA on same host** ```typescript const app = new OneBunApplication(AppModule, { static: { root: './dist', fallbackFile: 'index.html', }, }); await app.start(); // GET /api/*, /docs, /metrics, /ws handled by framework; GET /, /dashboard, etc. serve dist/ or index.html ``` **Example: static under a path prefix** ```typescript const app = new OneBunApplication(AppModule, { static: { root: './public', pathPrefix: '/assets', }, }); // Only GET /assets/* are served from ./public; e.g. /assets/logo.png -> public/logo.png ``` ### Methods ```typescript class OneBunApplication { /** Start the HTTP server */ async start(): Promise; /** Stop the HTTP server with optional cleanup options */ async stop(options?: { closeSharedRedis?: boolean; signal?: string; // e.g., 'SIGTERM', 'SIGINT' }): Promise; /** Enable graceful shutdown signal handlers (SIGTERM, SIGINT) */ enableGracefulShutdown(): void; /** Get configuration service with full type inference via module augmentation */ getConfig(): IConfig; /** Get configuration value by path with full type inference via module augmentation */ getConfigValue

>(path: P): DeepValue; getConfigValue(path: string): T; /** Get logger instance */ getLogger(context?: Record): SyncLogger; /** Get HTTP URL where application is listening */ getHttpUrl(): string; /** Get root module layer */ getLayer(): Layer.Layer; /** Get a service instance by class from the module container */ getService(serviceClass: new (...args: unknown[]) => T): T; } ``` ### Usage Example ```typescript import { OneBunApplication } from '@onebun/core'; import { AppModule } from './app.module'; import { envSchema } from './config'; const app = new OneBunApplication(AppModule, { port: 3000, basePath: '/api/v1', envSchema, metrics: { enabled: true, path: '/metrics', prefix: 'myapp_', }, tracing: { enabled: true, serviceName: 'my-service', samplingRate: 1.0, }, }); await app.start(); // Access configuration - fully typed with module augmentation const port = app.getConfigValue('server.port'); // number (auto-inferred) const config = app.getConfig(); const host = config.get('server.host'); // string (auto-inferred) // Get logger const logger = app.getLogger({ component: 'main' }); logger.info('Application started', { port, host }); // Application will automatically handle shutdown signals (SIGTERM, SIGINT) // Or stop manually: await app.stop(); ``` ### Accessing Services Outside of Requests Use `getService()` to access services outside of the request context, for example in background tasks or scripts: ```typescript const app = new OneBunApplication(AppModule, options); await app.start(); // Get a service instance const userService = app.getService(UserService); // Use the service await userService.performBackgroundTask(); await userService.sendScheduledEmails(); ``` ### Graceful Shutdown OneBun enables graceful shutdown **by default**. When the application receives SIGTERM or SIGINT signals, it automatically: 1. Calls `beforeApplicationDestroy(signal)` hooks on all services and controllers 2. Stops the HTTP server 3. Closes all WebSocket connections 4. Calls `onModuleDestroy()` hooks on all services and controllers 5. Disconnects shared Redis connection 6. Calls `onApplicationDestroy(signal)` hooks on all services and controllers ```typescript // Default: graceful shutdown is enabled const app = new OneBunApplication(AppModule); await app.start(); // SIGTERM/SIGINT handlers are automatically registered // To disable automatic shutdown handling: const app = new OneBunApplication(AppModule, { gracefulShutdown: false, }); await app.start(); app.enableGracefulShutdown(); // Enable manually later if needed // Programmatic shutdown await app.stop(); // Closes server, WebSocket, and shared Redis // Keep shared Redis open for other consumers await app.stop({ closeSharedRedis: false }); // Pass signal for lifecycle hooks await app.stop({ signal: 'SIGTERM' }); ``` ### Lifecycle Hooks Services and controllers can implement lifecycle hooks to execute code at specific points: | Interface | Method | When Called | |-----------|--------|-------------| | `OnModuleInit` | `onModuleInit()` | After instantiation and DI | | `OnApplicationInit` | `onApplicationInit()` | After all modules, before HTTP server | | `OnModuleDestroy` | `onModuleDestroy()` | During shutdown, after HTTP server stops | | `BeforeApplicationDestroy` | `beforeApplicationDestroy(signal?)` | Start of shutdown | | `OnApplicationDestroy` | `onApplicationDestroy(signal?)` | End of shutdown | See [Services API](./services.md#lifecycle-hooks) for detailed usage examples. ## MultiServiceApplication Run multiple services in a single process. ### Constructor ```typescript new MultiServiceApplication(options: MultiServiceApplicationOptions) ``` ### MultiServiceApplicationOptions ```typescript interface MultiServiceApplicationOptions { services: ServicesMap; envSchema?: TypedEnvSchema; envOptions?: EnvLoadOptions; metrics?: MetricsOptions; tracing?: TracingOptions; queue?: QueueApplicationOptions; // applied to all services enabledServices?: string[]; excludedServices?: string[]; externalServiceUrls?: Record; } interface ServiceConfig { module: Function; port: number; host?: string; basePath?: string; routePrefix?: string; envOverrides?: EnvOverrides; } type ServicesMap = Record; ``` ### Usage Example ```typescript import { MultiServiceApplication } from '@onebun/core'; import { UsersModule } from './users/users.module'; import { OrdersModule } from './orders/orders.module'; import { envSchema } from './config'; const multiApp = new MultiServiceApplication({ services: { users: { module: UsersModule, port: 3001, routePrefix: true, // Uses 'users' as route prefix }, orders: { module: OrdersModule, port: 3002, routePrefix: true, // Uses 'orders' as route prefix envOverrides: { DB_NAME: { value: 'orders_db' }, }, }, }, envSchema, enabledServices: ['users', 'orders'], }); await multiApp.start(); console.log('Running services:', multiApp.getRunningServices()); // ['users', 'orders'] // Stop all services multiApp.stop(); ``` ## OneBunModule Internal module class (usually not used directly). ```typescript class OneBunModule implements Module { static create( moduleClass: Function, loggerLayer?: Layer.Layer, config?: unknown, ): Module; setup(): Effect.Effect; getControllers(): Function[]; getControllerInstance(controllerClass: Function): Controller | undefined; getServiceInstance(tag: Context.Tag): T | undefined; getLayer(): Layer.Layer; getExportedServices(): Map, unknown>; } ``` ### Global Modules Modules decorated with `@Global()` automatically make their exported services available in all other modules without explicit import. This is useful for cross-cutting concerns like database connections. ```typescript import { Module, Global, Service, BaseService } from '@onebun/core'; @Service() export class DatabaseService extends BaseService { async query(sql: string) { /* ... */ } } // Mark module as global @Global() @Module({ providers: [DatabaseService], exports: [DatabaseService], }) export class DatabaseModule {} // Root module imports DatabaseModule once @Module({ imports: [DatabaseModule], }) export class AppModule {} // All other modules can inject DatabaseService without importing DatabaseModule @Module({ providers: [UserService], // UserService can inject DatabaseService automatically }) export class UserModule {} ``` **Global Module Utilities:** ```typescript // Check if module is global import { isGlobalModule } from '@onebun/core'; isGlobalModule(DatabaseModule); // true // Clear global registries (for testing) import { clearGlobalServicesRegistry } from '@onebun/core'; clearGlobalServicesRegistry(); ``` ## Metrics Options ```typescript interface MetricsOptions { /** Enable/disable metrics (default: true) */ enabled?: boolean; /** HTTP path for metrics endpoint (default: '/metrics') */ path?: string; /** Default labels for all metrics */ defaultLabels?: Record; /** Enable automatic HTTP metrics (default: true) */ collectHttpMetrics?: boolean; /** Enable automatic system metrics (default: true) */ collectSystemMetrics?: boolean; /** Enable GC metrics (default: true) */ collectGcMetrics?: boolean; /** System metrics collection interval in ms (default: 5000) */ systemMetricsInterval?: number; /** Custom prefix for all metrics (default: 'onebun_') */ prefix?: string; /** Buckets for HTTP duration histogram */ httpDurationBuckets?: number[]; } ``` ## Tracing Options ```typescript interface TracingOptions { /** Enable/disable tracing (default: true) */ enabled?: boolean; /** Service name (default: 'onebun-service') */ serviceName?: string; /** Service version (default: '1.0.0') */ serviceVersion?: string; /** Sampling rate 0.0-1.0 (default: 1.0) */ samplingRate?: number; /** Trace HTTP requests (default: true) */ traceHttpRequests?: boolean; /** Trace database queries (default: true) */ traceDatabaseQueries?: boolean; /** Default span attributes */ defaultAttributes?: Record; /** Export options for external tracing systems */ exportOptions?: { endpoint?: string; headers?: Record; timeout?: number; batchSize?: number; batchTimeout?: number; }; } ``` ## Re-exports The core package re-exports commonly used items: ```typescript // From @onebun/envs export { Env, type EnvSchema, EnvValidationError } from '@onebun/envs'; // From @onebun/logger export type { SyncLogger } from '@onebun/logger'; // From @onebun/requests export { createHttpClient, type ErrorResponse, HttpStatusCode, InternalServerError, isErrorResponse, NotFoundError, OneBunBaseError, type SuccessResponse, } from '@onebun/requests'; // From @onebun/trace export { Span } from '@onebun/trace'; // From effect export { Effect, Layer } from 'effect'; // Internal export { OneBunApplication } from './application'; export { Controller as BaseController } from './controller'; export { BaseService, Service, getServiceTag } from './service'; export { OneBunModule } from './module'; export * from './decorators'; export * from './validation'; ``` --- --- url: /examples/crud-api.md description: >- Complete CRUD API example with database, validation, error handling. REST endpoints, repository pattern. --- # CRUD API Example A complete CRUD API with validation, error handling, and best practices. ## Project Structure ``` crud-api/ ├── src/ │ ├── index.ts │ ├── app.module.ts │ ├── config.ts │ └── users/ │ ├── users.module.ts │ ├── users.controller.ts │ ├── users.service.ts │ ├── users.repository.ts │ └── schemas/ │ └── user.schema.ts ├── .env ├── package.json └── tsconfig.json ``` ## src/config.ts ```typescript import { Env } from '@onebun/core'; export const envSchema = { server: { port: Env.number({ default: 3000 }), host: Env.string({ default: '0.0.0.0' }), }, app: { name: Env.string({ default: 'crud-api' }), }, }; ``` ## src/users/schemas/user.schema.ts ```typescript import { type } from 'arktype'; /** * User entity schema */ export const userSchema = type({ id: 'string', name: 'string', email: 'string.email', 'age?': 'number > 0', role: '"admin" | "user" | "guest"', createdAt: 'string', updatedAt: 'string', }); export type User = typeof userSchema.infer; /** * Create user DTO schema */ export const createUserSchema = type({ name: 'string >= 2', email: 'string.email', 'age?': 'number > 0', 'role?': '"admin" | "user" | "guest"', }); export type CreateUserDto = typeof createUserSchema.infer; /** * Update user DTO schema (partial of CreateUserDto) */ export const updateUserSchema = createUserSchema.partial(); export type UpdateUserDto = typeof updateUserSchema.infer; /** * User list response schema */ export const userListSchema = type({ users: userSchema.array(), total: 'number', page: 'number', limit: 'number', }); export type UserListResponse = typeof userListSchema.infer; ``` ## src/users/users.repository.ts ```typescript import { Service, BaseService } from '@onebun/core'; import type { User, CreateUserDto, UpdateUserDto } from './schemas/user.schema'; /** * User repository - handles data storage * In production, this would use a database */ @Service() export class UserRepository extends BaseService { private users = new Map(); /** * Find all users with pagination */ async findAll(options?: { page?: number; limit?: number; }): Promise<{ users: User[]; total: number }> { const page = options?.page || 1; const limit = options?.limit || 10; const offset = (page - 1) * limit; const allUsers = Array.from(this.users.values()); const users = allUsers.slice(offset, offset + limit); return { users, total: allUsers.length, }; } /** * Find user by ID */ async findById(id: string): Promise { return this.users.get(id) || null; } /** * Find user by email */ async findByEmail(email: string): Promise { for (const user of this.users.values()) { if (user.email === email) { return user; } } return null; } /** * Create new user */ async create(data: CreateUserDto): Promise { const now = new Date().toISOString(); const user: User = { id: crypto.randomUUID(), name: data.name, email: data.email, age: data.age, role: data.role || 'user', createdAt: now, updatedAt: now, }; this.users.set(user.id, user); this.logger.debug('User created in repository', { userId: user.id }); return user; } /** * Update user */ async update(id: string, data: UpdateUserDto): Promise { const user = this.users.get(id); if (!user) { return null; } const updatedUser: User = { ...user, ...data, updatedAt: new Date().toISOString(), }; this.users.set(id, updatedUser); this.logger.debug('User updated in repository', { userId: id }); return updatedUser; } /** * Delete user */ async delete(id: string): Promise { const deleted = this.users.delete(id); if (deleted) { this.logger.debug('User deleted from repository', { userId: id }); } return deleted; } } ``` ## src/users/users.service.ts ```typescript import { Service, BaseService, Span, NotFoundError } from '@onebun/core'; import { UserRepository } from './users.repository'; import type { User, CreateUserDto, UpdateUserDto, UserListResponse, } from './schemas/user.schema'; /** * User service - business logic layer */ @Service() export class UserService extends BaseService { constructor(private userRepository: UserRepository) { super(); } /** * Get all users with pagination */ @Span('user-find-all') async findAll(page = 1, limit = 10): Promise { this.logger.info('Finding all users', { page, limit }); const { users, total } = await this.userRepository.findAll({ page, limit }); return { users, total, page, limit, }; } /** * Get user by ID */ @Span('user-find-by-id') async findById(id: string): Promise { this.logger.info('Finding user by ID', { id }); const user = await this.userRepository.findById(id); if (!user) { this.logger.warn('User not found', { id }); throw new NotFoundError('User', id); } return user; } /** * Create new user */ @Span('user-create') async create(data: CreateUserDto): Promise { this.logger.info('Creating user', { email: data.email }); // Check for duplicate email const existing = await this.userRepository.findByEmail(data.email); if (existing) { this.logger.warn('Duplicate email', { email: data.email }); throw new Error('Email already exists'); } const user = await this.userRepository.create(data); this.logger.info('User created', { userId: user.id, email: user.email }); return user; } /** * Update user */ @Span('user-update') async update(id: string, data: UpdateUserDto): Promise { this.logger.info('Updating user', { id, fields: Object.keys(data) }); // Check if user exists const existing = await this.userRepository.findById(id); if (!existing) { throw new NotFoundError('User', id); } // Check email uniqueness if email is being changed if (data.email && data.email !== existing.email) { const emailUser = await this.userRepository.findByEmail(data.email); if (emailUser) { throw new Error('Email already exists'); } } const user = await this.userRepository.update(id, data); if (!user) { throw new NotFoundError('User', id); } this.logger.info('User updated', { userId: id }); return user; } /** * Delete user */ @Span('user-delete') async delete(id: string): Promise { this.logger.info('Deleting user', { id }); const deleted = await this.userRepository.delete(id); if (!deleted) { throw new NotFoundError('User', id); } this.logger.info('User deleted', { userId: id }); } } ``` ## src/users/users.controller.ts ```typescript import { Controller, BaseController, Get, Post, Put, Delete, Param, Query, Body, HttpStatusCode, ApiResponse, } from '@onebun/core'; import { UserService } from './users.service'; import { createUserSchema, updateUserSchema, userSchema, userListSchema, type CreateUserDto, type UpdateUserDto, } from './schemas/user.schema'; @Controller('/api/users') export class UserController extends BaseController { constructor(private userService: UserService) { super(); } /** * GET /api/users * List all users with pagination */ @Get('/') @ApiResponse(200, { schema: userListSchema, description: 'List of users' }) async findAll( @Query('page') page?: string, @Query('limit') limit?: string, ): Promise { const pageNum = page ? parseInt(page, 10) : 1; const limitNum = limit ? parseInt(limit, 10) : 10; // Validate pagination params if (pageNum < 1) { return this.error('Page must be >= 1', HttpStatusCode.BAD_REQUEST, HttpStatusCode.BAD_REQUEST); } if (limitNum < 1 || limitNum > 100) { return this.error('Limit must be between 1 and 100', HttpStatusCode.BAD_REQUEST, HttpStatusCode.BAD_REQUEST); } const result = await this.userService.findAll(pageNum, limitNum); return this.success(result); } /** * GET /api/users/:id * Get user by ID */ @Get('/:id') @ApiResponse(200, { schema: userSchema, description: 'User found' }) @ApiResponse(404, { description: 'User not found' }) async findOne(@Param('id') id: string): Promise { try { const user = await this.userService.findById(id); return this.success(user); } catch (error) { if (error instanceof Error && error.message.includes('not found')) { return this.error('User not found', HttpStatusCode.NOT_FOUND, HttpStatusCode.NOT_FOUND); } throw error; } } /** * POST /api/users * Create new user */ @Post('/') @ApiResponse(201, { schema: userSchema, description: 'User created' }) @ApiResponse(400, { description: 'Validation error' }) @ApiResponse(409, { description: 'Email already exists' }) async create( @Body(createUserSchema) body: CreateUserDto, ): Promise { try { const user = await this.userService.create(body); return this.success(user, HttpStatusCode.CREATED); } catch (error) { if (error instanceof Error && error.message.includes('already exists')) { return this.error('Email already exists', HttpStatusCode.CONFLICT, HttpStatusCode.CONFLICT); } throw error; } } /** * PUT /api/users/:id * Update user */ @Put('/:id') @ApiResponse(200, { schema: userSchema, description: 'User updated' }) @ApiResponse(400, { description: 'Validation error' }) @ApiResponse(404, { description: 'User not found' }) @ApiResponse(409, { description: 'Email already exists' }) async update( @Param('id') id: string, @Body(updateUserSchema) body: UpdateUserDto, ): Promise { try { const user = await this.userService.update(id, body); return this.success(user); } catch (error) { if (error instanceof Error) { if (error.message.includes('not found')) { return this.error('User not found', HttpStatusCode.NOT_FOUND, HttpStatusCode.NOT_FOUND); } if (error.message.includes('already exists')) { return this.error('Email already exists', HttpStatusCode.CONFLICT, HttpStatusCode.CONFLICT); } } throw error; } } /** * DELETE /api/users/:id * Delete user */ @Delete('/:id') @ApiResponse(200, { description: 'User deleted' }) @ApiResponse(404, { description: 'User not found' }) async delete(@Param('id') id: string): Promise { try { await this.userService.delete(id); return this.success({ deleted: true, id }); } catch (error) { if (error instanceof Error && error.message.includes('not found')) { return this.error('User not found', HttpStatusCode.NOT_FOUND, HttpStatusCode.NOT_FOUND); } throw error; } } } ``` ## src/users/users.module.ts ```typescript import { Module } from '@onebun/core'; import { UserController } from './users.controller'; import { UserService } from './users.service'; import { UserRepository } from './users.repository'; @Module({ controllers: [UserController], providers: [UserService, UserRepository], exports: [UserService], }) export class UserModule {} ``` ## src/app.module.ts ```typescript import { Module } from '@onebun/core'; import { UserModule } from './users/users.module'; @Module({ imports: [UserModule], }) export class AppModule {} ``` ## src/index.ts ```typescript import { OneBunApplication } from '@onebun/core'; import { AppModule } from './app.module'; import { envSchema } from './config'; const app = new OneBunApplication(AppModule, { port: 3000, envSchema, metrics: { enabled: true }, tracing: { enabled: true, serviceName: 'crud-api' }, }); app.start().then(() => { const logger = app.getLogger(); logger.info('CRUD API started on http://localhost:3000'); }); ``` ## API Testing ```bash # Create user curl -X POST http://localhost:3000/api/users \ -H "Content-Type: application/json" \ -d '{"name": "John Doe", "email": "john@example.com", "age": 30}' # List users curl http://localhost:3000/api/users curl http://localhost:3000/api/users?page=1&limit=5 # Get user by ID curl http://localhost:3000/api/users/{id} # Update user curl -X PUT http://localhost:3000/api/users/{id} \ -H "Content-Type: application/json" \ -d '{"name": "John Smith"}' # Delete user curl -X DELETE http://localhost:3000/api/users/{id} ``` ## Key Patterns 1. **Layered Architecture**: Controller → Service → Repository 2. **Validation Schemas**: Separate schemas for create/update DTOs 3. **Error Handling**: Typed errors with appropriate HTTP status codes 4. **Tracing**: `@Span()` decorator for automatic tracing 5. **Logging**: Structured logs at each layer 6. **Module Export**: Export `UserService` for use in other modules --- --- url: /api/drizzle.md description: >- DrizzleModule for SQLite and PostgreSQL. DrizzleService, BaseRepository, @Entity decorator. Migrations, type-safe queries. --- # Database (Drizzle) API Package: `@onebun/drizzle` ## Overview OneBun provides database integration via Drizzle ORM with support for: * SQLite (via bun:sqlite) * PostgreSQL * Type-safe queries * Migrations * Repository pattern ## DrizzleModule ### SQLite Setup ```typescript import { Module } from '@onebun/core'; import { DrizzleModule, DatabaseType } from '@onebun/drizzle'; import { UserController } from './user.controller'; import { UserService } from './user.service'; import * as schema from './schema'; @Module({ imports: [ DrizzleModule.forRoot({ connection: { type: DatabaseType.SQLITE, options: { url: './data/app.db', }, }, autoMigrate: true, migrationsFolder: './drizzle', }), ], controllers: [UserController], providers: [UserService], }) export class UserModule {} ``` ### PostgreSQL Setup ```typescript DrizzleModule.forRoot({ connection: { type: DatabaseType.POSTGRESQL, options: { connectionString: process.env.DATABASE_URL, // Or individual options: host: 'localhost', port: 5432, database: 'myapp', user: 'postgres', password: 'password', ssl: process.env.NODE_ENV === 'production', }, }, autoMigrate: true, migrationsFolder: './drizzle', }) ``` ### Global Module (Default Behavior) By default, `DrizzleModule` is a **global module**. This means that once you import it in your root module, `DrizzleService` is automatically available in all submodules without explicit imports. ```typescript // app.module.ts - Import once in root module import { Module } from '@onebun/core'; import { DrizzleModule, DatabaseType } from '@onebun/drizzle'; import { UserModule } from './user/user.module'; import { PostModule } from './post/post.module'; @Module({ imports: [ DrizzleModule.forRoot({ connection: { type: DatabaseType.POSTGRESQL, options: { host: 'localhost', port: 5432, user: 'app', password: 'secret', database: 'myapp' }, }, }), UserModule, PostModule, ], }) export class AppModule {} // user/user.module.ts - DrizzleService available without importing DrizzleModule import { Module } from '@onebun/core'; import { UserController } from './user.controller'; import { UserService } from './user.service'; @Module({ controllers: [UserController], providers: [UserService], // UserService can inject DrizzleService }) export class UserModule {} // user/user.service.ts - DrizzleService is automatically available import { Service, BaseService } from '@onebun/core'; import { DrizzleService } from '@onebun/drizzle'; import { users } from './schema'; @Service() export class UserService extends BaseService { constructor(private db: DrizzleService) { super(); } async findAll() { return this.db.select().from(users); } } ``` ### Non-Global Mode (Multiple Databases) For scenarios where you need multiple database connections (e.g., main database + analytics database), you can disable global behavior: ```typescript // Main database - global (available everywhere) @Module({ imports: [ DrizzleModule.forRoot({ connection: { type: DatabaseType.POSTGRESQL, options: { host: 'main-db', port: 5432, user: 'app', password: 'secret', database: 'main' }, }, isGlobal: true, // Default, can be omitted }), ], }) export class AppModule {} // Analytics module with separate database - non-global @Module({ imports: [ DrizzleModule.forRoot({ connection: { type: DatabaseType.POSTGRESQL, options: { host: 'analytics-db', port: 5432, user: 'analytics', password: 'secret', database: 'analytics' }, }, isGlobal: false, // Each import creates new instance }), ], providers: [AnalyticsService], }) export class AnalyticsModule {} ``` ### forFeature() Method When `DrizzleModule` is not global (`isGlobal: false`), submodules must explicitly import it using `forFeature()`: ```typescript // Root module with non-global DrizzleModule @Module({ imports: [ DrizzleModule.forRoot({ connection: { ... }, isGlobal: false, }), UserModule, ], }) export class AppModule {} // Feature module must explicitly import DrizzleService @Module({ imports: [DrizzleModule.forFeature()], // Required when isGlobal: false controllers: [UserController], providers: [UserService], }) export class UserModule {} ``` ## Schema Definition All schema builders are re-exported from `@onebun/drizzle`: * PostgreSQL: `import { ... } from '@onebun/drizzle/pg'` * SQLite: `import { ... } from '@onebun/drizzle/sqlite'` * Common operators: `import { eq, and, ... } from '@onebun/drizzle'` ### SQLite Schema ```typescript // schema/users.ts import { sqliteTable, text, integer } from '@onebun/drizzle/sqlite'; import { sql } from '@onebun/drizzle'; export const users = sqliteTable('users', { id: text('id').primaryKey(), name: text('name').notNull(), email: text('email').notNull().unique(), age: integer('age'), createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`), }); export type User = typeof users.$inferSelect; export type InsertUser = typeof users.$inferInsert; ``` ### PostgreSQL Schema ```typescript // schema/users.ts import { pgTable, text, integer, timestamp } from '@onebun/drizzle/pg'; export const users = pgTable('users', { // Use generatedAlwaysAsIdentity() for auto-increment integer primary key id: integer('id').primaryKey().generatedAlwaysAsIdentity(), name: text('name').notNull(), email: text('email').notNull().unique(), age: integer('age'), createdAt: timestamp('created_at', { mode: 'date' }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { mode: 'date' }).notNull().defaultNow(), }); export type User = typeof users.$inferSelect; export type InsertUser = typeof users.$inferInsert; ``` **Alternative with UUID:** ```typescript import { pgTable, uuid } from '@onebun/drizzle/pg'; export const users = pgTable('users', { id: uuid('id').primaryKey().defaultRandom(), // ... rest of schema }); ``` ### Relations ```typescript // schema/posts.ts import { pgTable, text, integer, timestamp } from '@onebun/drizzle/pg'; import { relations } from '@onebun/drizzle'; import { users } from './users'; export const posts = pgTable('posts', { id: integer('id').primaryKey().generatedAlwaysAsIdentity(), title: text('title').notNull(), content: text('content'), authorId: integer('author_id').notNull().references(() => users.id), createdAt: timestamp('created_at', { mode: 'date' }).notNull().defaultNow(), }); export const postsRelations = relations(posts, ({ one }) => ({ author: one(users, { fields: [posts.authorId], references: [users.id], }), })); export const usersRelations = relations(users, ({ many }) => ({ posts: many(posts), })); ``` ### Index Schema ```typescript // schema/index.ts export * from './users'; export * from './posts'; ``` ## DrizzleService ### Injection ```typescript import { Service, BaseService } from '@onebun/core'; import { DrizzleService } from '@onebun/drizzle'; @Service() export class UserService extends BaseService { constructor(private db: DrizzleService) { super(); } } ``` ### Type Inference DrizzleService automatically infers database types from table schemas. No generic parameter is required: ```typescript import { sqliteTable, integer, text } from '@onebun/drizzle/sqlite'; import { pgTable, text as pgText, integer as pgInteger } from '@onebun/drizzle/pg'; // SQLite table const users = sqliteTable('users', { id: integer('id').primaryKey(), name: text('name').notNull(), }); // PostgreSQL table const orders = pgTable('orders', { id: pgInteger('id').primaryKey().generatedAlwaysAsIdentity(), total: pgInteger('total').notNull(), }); @Service() export class MyService extends BaseService { constructor(private db: DrizzleService) { super(); } async getUsers() { // TypeScript infers SQLite types from `users` table return this.db.select().from(users); } async getOrders() { // TypeScript infers PostgreSQL types from `orders` table return this.db.select().from(orders); } } ``` ### Query Methods DrizzleService provides direct access to Drizzle ORM query builders: #### select() Create a SELECT query. ```typescript // Select all columns const allUsers = await this.db.select().from(users); // Select specific columns const names = await this.db.select({ name: users.name, email: users.email }).from(users); // Select with conditions import { eq } from '@onebun/drizzle'; const user = await this.db.select() .from(users) .where(eq(users.id, id)) .limit(1); ``` #### insert() Create an INSERT query. ```typescript // Insert single row await this.db.insert(users).values({ name: 'John', email: 'john@example.com' }); // Insert with returning const [newUser] = await this.db.insert(users) .values({ name: 'John', email: 'john@example.com' }) .returning(); // Insert multiple rows await this.db.insert(users).values([ { name: 'John', email: 'john@example.com' }, { name: 'Jane', email: 'jane@example.com' }, ]); ``` #### update() Create an UPDATE query. ```typescript import { eq } from '@onebun/drizzle'; // Update rows await this.db.update(users) .set({ name: 'Jane' }) .where(eq(users.id, id)); // Update with returning const [updated] = await this.db.update(users) .set({ name: 'Jane' }) .where(eq(users.id, id)) .returning(); ``` #### delete() Create a DELETE query. ```typescript import { eq } from '@onebun/drizzle'; // Delete rows await this.db.delete(users).where(eq(users.id, id)); // Delete with returning const [deleted] = await this.db.delete(users) .where(eq(users.id, id)) .returning(); ``` #### transaction() Execute queries in a transaction. The transaction callback receives a `UniversalTransactionClient` with the same API as `DrizzleService`. ```typescript async transaction( fn: (tx: UniversalTransactionClient) => Promise ): Promise ``` ```typescript const result = await this.db.transaction(async (tx) => { // All queries in this block are in a transaction // tx has the same methods as DrizzleService: select(), insert(), update(), delete() const user = await tx.insert(users) .values({ name: 'John', email: 'john@example.com' }) .returning(); await tx.insert(profiles) .values({ userId: user[0].id, bio: 'Hello' }); return user[0]; }); ``` ## BaseRepository For common CRUD operations: ```typescript import { BaseRepository } from '@onebun/drizzle'; import { users, type User, type InsertUser } from './schema'; @Service() export class UserRepository extends BaseRepository { constructor(db: DrizzleService) { super(db, users); } // Inherited methods: // findAll(options?: { limit?: number; offset?: number }): Promise // findById(id: string): Promise // create(data: InsertUser): Promise // update(id: string, data: Partial): Promise // delete(id: string): Promise // Custom methods async findByEmail(email: string): Promise { const result = await this.db.select() .from(users) .where(eq(users.email, email)) .limit(1); return result[0] || null; } } ``` ## Query Examples ### Basic Queries ```typescript // Select specific columns const names = await this.db.select({ name: users.name, email: users.email }).from(users); // Count import { sql } from '@onebun/drizzle'; const countResult = await this.db.select({ count: sql`count(*)` }).from(users); // Order and limit import { desc } from '@onebun/drizzle'; const recentUsers = await this.db.select() .from(users) .orderBy(desc(users.createdAt)) .limit(10); // Pagination const page = 1; const pageSize = 10; const offset = (page - 1) * pageSize; const pagedUsers = await this.db.select() .from(users) .limit(pageSize) .offset(offset); ``` ### Filtering ```typescript import { eq, ne, gt, gte, lt, lte, like, ilike, and, or, not, isNull, isNotNull, inArray, notInArray } from '@onebun/drizzle'; // Equal const user = await this.db.select().from(users).where(eq(users.id, '123')); // Multiple conditions (AND) const admins = await this.db.select().from(users).where( and( eq(users.role, 'admin'), eq(users.active, true) ) ); // OR conditions const filtered = await this.db.select().from(users).where( or( eq(users.role, 'admin'), eq(users.role, 'moderator') ) ); // LIKE const searchResults = await this.db.select().from(users).where( like(users.name, '%john%') ); // IN array const specific = await this.db.select().from(users).where( inArray(users.id, ['1', '2', '3']) ); // NULL checks const noAge = await this.db.select().from(users).where( isNull(users.age) ); ``` ### Joins ```typescript // Inner join const postsWithAuthors = await this.db.select() .from(posts) .innerJoin(users, eq(posts.authorId, users.id)); // Left join const usersWithPosts = await this.db.select() .from(users) .leftJoin(posts, eq(users.id, posts.authorId)); ``` ### Aggregations ```typescript import { sql, count, sum, avg, min, max } from '@onebun/drizzle'; // Count const total = await this.db.select({ count: count() }).from(users); // Group by const postsByUser = await this.db.select({ authorId: posts.authorId, postCount: count(), }) .from(posts) .groupBy(posts.authorId); // Sum const totalSales = await this.db.select({ total: sum(orders.amount), }).from(orders); ``` ## Migrations OneBun uses Drizzle ORM migrations. Typical workflow: 1. **Generate migrations** - Create SQL files from schema changes (CLI) 2. **Apply migrations** - Run migrations on app startup (automatic or manual) ### Generate Migrations (CLI) Create a `drizzle.config.ts` in your project root: ```typescript // drizzle.config.ts import { defineConfig } from '@onebun/drizzle'; export default defineConfig({ schema: './src/schema/index.ts', out: './drizzle', dialect: 'postgresql', // or 'sqlite' dbCredentials: { url: process.env.DATABASE_URL!, }, }); ``` Then run from terminal using `onebun-drizzle` CLI wrapper (ensures correct version): ```bash # Generate migration after schema changes bunx onebun-drizzle generate # Push schema directly to DB (development only, no migration files) bunx onebun-drizzle push # Open Drizzle Studio to browse database bunx onebun-drizzle studio ``` Add scripts to `package.json`: ```json { "scripts": { "db:generate": "onebun-drizzle generate", "db:push": "onebun-drizzle push", "db:studio": "onebun-drizzle studio" } } ``` > **Note**: Use `onebun-drizzle` instead of `drizzle-kit` directly. This ensures the correct version of drizzle-kit is used (the one installed with `@onebun/drizzle`). ### Programmatic Generation (Optional) For build scripts or CI pipelines, use programmatic API: ```typescript import { generateMigrations, pushSchema } from '@onebun/drizzle'; // Generate migration files await generateMigrations({ schemaPath: './src/schema', migrationsFolder: './drizzle', dialect: 'postgresql', }); // Push schema directly (development only) await pushSchema({ schemaPath: './src/schema', dialect: 'postgresql', connectionString: process.env.DATABASE_URL, }); ``` ### Apply Migrations at Runtime Use `DrizzleService.runMigrations()` to apply migrations when the application starts: ```typescript // Manual migration const drizzleService = new DrizzleService(); await drizzleService.initialize({ /* connection options */ }); await drizzleService.runMigrations({ migrationsFolder: './drizzle' }); ``` Or enable automatic migrations in module configuration: ```typescript DrizzleModule.forRoot({ connection: { /* ... */ }, autoMigrate: true, // Run migrations on startup migrationsFolder: './drizzle', // Migration files location }) ``` ### Environment Variables | Variable | Description | Default | |----------|-------------|---------| | `DB_AUTO_MIGRATE` | Auto-run migrations on startup | `true` | | `DB_MIGRATIONS_FOLDER` | Path to migrations folder | `'./drizzle'` | | `DB_SCHEMA_PATH` | Path to schema files | - | ### Migration Tracking Drizzle automatically tracks applied migrations in the `__drizzle_migrations` table. This ensures: * Migrations are only applied once (idempotency) * Running `runMigrations()` multiple times is safe * No duplicate table creation errors ### Migration Logging When migrations are applied, the service logs each migration filename: ``` info: Applied migration: 0001_initial_schema info: Applied migration: 0002_add_users_table info: SQLite migrations applied { migrationsFolder: './drizzle', newMigrations: 2, appliedFiles: ['0001_initial_schema', '0002_add_users_table'] } ``` If no new migrations need to be applied: ``` info: SQLite migrations applied { migrationsFolder: './drizzle', newMigrations: 0, appliedFiles: [] } ``` ## Complete Example ```typescript // schema/index.ts import { pgTable, text, timestamp, integer } from '@onebun/drizzle/pg'; import { relations } from '@onebun/drizzle'; export const users = pgTable('users', { id: integer('id').primaryKey().generatedAlwaysAsIdentity(), name: text('name').notNull(), email: text('email').notNull().unique(), createdAt: timestamp('created_at', { mode: 'date' }).notNull().defaultNow(), }); export const posts = pgTable('posts', { id: integer('id').primaryKey().generatedAlwaysAsIdentity(), title: text('title').notNull(), content: text('content'), authorId: integer('author_id').notNull().references(() => users.id), views: integer('views').default(0), createdAt: timestamp('created_at', { mode: 'date' }).notNull().defaultNow(), }); export const usersRelations = relations(users, ({ many }) => ({ posts: many(posts), })); export const postsRelations = relations(posts, ({ one }) => ({ author: one(users, { fields: [posts.authorId], references: [users.id], }), })); export type User = typeof users.$inferSelect; export type InsertUser = typeof users.$inferInsert; export type Post = typeof posts.$inferSelect; export type InsertPost = typeof posts.$inferInsert; // user.repository.ts import { Service, BaseService } from '@onebun/core'; import { DrizzleService, eq } from '@onebun/drizzle'; import { users, type User, type InsertUser } from './schema'; @Service() export class UserRepository extends BaseService { constructor(private db: DrizzleService) { super(); } async findAll(): Promise { return this.db.select().from(users); } async findById(id: number): Promise { const result = await this.db.select() .from(users) .where(eq(users.id, id)) .limit(1); return result[0] || null; } async findByEmail(email: string): Promise { const result = await this.db.select() .from(users) .where(eq(users.email, email)) .limit(1); return result[0] || null; } async create(data: InsertUser): Promise { const result = await this.db.insert(users).values(data).returning(); return result[0]; } async update(id: number, data: Partial): Promise { const result = await this.db.update(users) .set(data) .where(eq(users.id, id)) .returning(); return result[0] || null; } async delete(id: number): Promise { const result = await this.db.delete(users) .where(eq(users.id, id)) .returning(); return result.length > 0; } } // user.service.ts @Service() export class UserService extends BaseService { constructor( private userRepository: UserRepository, private cacheService: CacheService, ) { super(); } async findById(id: string): Promise { const cacheKey = `user:${id}`; const cached = await this.cacheService.get(cacheKey); if (cached) return cached; const user = await this.userRepository.findById(id); if (user) { await this.cacheService.set(cacheKey, user, { ttl: 300 }); } return user; } async create(data: InsertUser): Promise { // Check for duplicate email const existing = await this.userRepository.findByEmail(data.email); if (existing) { throw new Error('Email already exists'); } return this.userRepository.create(data); } } // user.module.ts import { Module } from '@onebun/core'; import { DrizzleModule, DatabaseType } from '@onebun/drizzle'; import { CacheModule, CacheType } from '@onebun/cache'; @Module({ imports: [ DrizzleModule.forRoot({ connection: { type: DatabaseType.POSTGRESQL, options: { connectionString: process.env.DATABASE_URL, }, }, autoMigrate: true, migrationsFolder: './drizzle', }), CacheModule.forRoot({ type: CacheType.MEMORY, cacheOptions: { defaultTtl: 300000 } }), ], controllers: [UserController], providers: [UserService, UserRepository], }) export class UserModule {} ``` ## Testing ### Testing with DrizzleService For testing services that depend on DrizzleService, use in-memory SQLite database: ```typescript import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; import { Effect } from 'effect'; import { makeMockLoggerLayer, createMockConfig } from '@onebun/core/testing'; import { LoggerService } from '@onebun/logger'; import { DrizzleService, DrizzleModule, DatabaseType } from '@onebun/drizzle'; describe('MyService', () => { let drizzleService: DrizzleService; beforeEach(async () => { // Clear any previous configuration DrizzleModule.clearOptions(); // Configure with in-memory database // Note: autoMigrate defaults to true, set to false if you don't have migrations DrizzleModule.forRoot({ connection: { type: DatabaseType.SQLITE, options: { url: ':memory:' }, }, autoMigrate: false, // Disable if no migrations folder exists }); // Create and initialize service const loggerLayer = makeMockLoggerLayer(); const logger = Effect.runSync( Effect.provide( Effect.map(LoggerService, (l) => l), loggerLayer, ), ); // No generic parameter needed - types are inferred from table schemas drizzleService = new DrizzleService(); drizzleService.initializeService(logger, createMockConfig()); // onAsyncInit() is called automatically by the framework // In tests, call it manually to simulate framework behavior await drizzleService.onAsyncInit(); // Create test tables manually (when autoMigrate is false) const sqliteClient = drizzleService.getSQLiteClient(); sqliteClient!.exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL ) `); }); afterEach(async () => { await drizzleService.close(); DrizzleModule.clearOptions(); }); test('should perform database operations', async () => { const db = drizzleService.getDatabase(); expect(db).toBeDefined(); }); }); ``` ### Testing with Auto-migrations Migrations run automatically by default. To test with migrations: ```typescript import { join } from 'path'; beforeEach(async () => { DrizzleModule.forRoot({ connection: { type: DatabaseType.SQLITE, options: { url: ':memory:' }, }, // autoMigrate defaults to true, so migrations run automatically migrationsFolder: join(__dirname, 'test-migrations'), }); // ... create and initialize service await drizzleService.onAsyncInit(); // Tables from migrations should now exist const sqliteClient = drizzleService.getSQLiteClient(); const tables = sqliteClient!.query(` SELECT name FROM sqlite_master WHERE type='table' AND name='users' `).all(); expect(tables.length).toBe(1); }); ``` ### Testing Environment Variables The service auto-initializes from environment variables. For testing: ```typescript beforeEach(async () => { // Clear previous state DrizzleModule.clearOptions(); delete process.env.DB_URL; delete process.env.DB_TYPE; delete process.env.DB_AUTO_MIGRATE; // Set test environment process.env.DB_URL = ':memory:'; process.env.DB_TYPE = 'sqlite'; process.env.DB_AUTO_MIGRATE = 'false'; // Disable to avoid missing migrations folder error // Create service - will auto-initialize from env vars // No generic parameter needed drizzleService = new DrizzleService(); drizzleService.initializeService(logger, createMockConfig()); await drizzleService.onAsyncInit(); }); afterEach(() => { // Cleanup delete process.env.DB_URL; delete process.env.DB_TYPE; delete process.env.DB_AUTO_MIGRATE; }); ``` ### Key Testing Notes 1. **Call `onAsyncInit()` in tests** - this triggers async initialization that the framework does automatically 2. **Use `DrizzleModule.clearOptions()`** in beforeEach/afterEach to ensure test isolation 3. **Clean up environment variables** when testing env-based initialization 4. **Use `:memory:`** SQLite URL for in-memory databases that are faster and don't leave files 5. **autoMigrate defaults to `true`** - set to `false` explicitly if you don't have migrations 6. **Database is ready after `onAsyncInit()`** - no need to call `waitForInit()` in client code 7. **No generic parameter needed** - `DrizzleService` infers types from table schemas automatically --- --- url: /api/decorators.md description: >- @Module, @Controller, @Service decorators. HTTP method decorators (@Get, @Post, etc). Parameter decorators (@Param, @Query, @Body). --- ## Decorator Quick Reference **Module Structure**: ```typescript @Module({ imports: [OtherModule], // import modules controllers: [MyController], // register controllers providers: [MyService], // register services (auto-available in this module) exports: [MyService], // only needed for other modules that import this one }) export class MyModule {} // Global module - exports available everywhere without import @Global() @Module({ providers: [DbService], exports: [DbService], }) export class DbModule {} ``` **Controller with Routes**: ```typescript @Controller('/api/users') export class UserController extends BaseController { constructor(private userService: UserService) { super(); } @Get('/') // GET /api/users @Get('/:id') // GET /api/users/:id @Post('/') // POST /api/users @Put('/:id') // PUT /api/users/:id @Delete('/:id') // DELETE /api/users/:id } ``` **Parameter Extraction**: ```typescript @Get('/:id') async getUser( @Param('id') id: string, // from path (always required) @Query('page') page?: string, // optional by default @Query('limit', { required: true }) limit: string, // explicitly required @Body(schema) body: CreateUserDto, // required based on schema @Header('x-api-key') key?: string, // optional by default @Header('Authorization', { required: true }) auth: string, // explicitly required @Cookie('session') session?: string, // optional by default @Cookie('token', { required: true }) token: string, // explicitly required ): Promise { return this.success({ id, page, limit }); } // Access raw request (OneBunRequest = BunRequest with .cookies and .params) @Get('/raw') async raw(@Req() req: OneBunRequest) { const session = req.cookies.get('session'); const userId = req.params.id; } ``` **@Cookie & @Req**: ```typescript // Read cookie by name (optional by default) @Cookie('session') session?: string @Cookie('session', { required: true }) session: string // Access full request (OneBunRequest = BunRequest with .cookies/.params) @Req() req: OneBunRequest // Read/set/delete cookies via CookieMap req.cookies.get('session') req.cookies.set('session', 'value', { httpOnly: true, path: '/' }) req.cookies.delete('session') // Custom response headers / Set-Cookie return new Response(body, { headers: { 'X-Custom': 'value' } }) // @Res() is deprecated — always return Response from handler ``` **File Upload** (multipart/form-data or JSON+base64, auto-detected): ```typescript import { UploadedFile, UploadedFiles, FormField, OneBunFile, MimeType } from '@onebun/core'; @Post('/upload') async upload( @UploadedFile('avatar', { maxSize: 5_000_000, mimeTypes: [MimeType.ANY_IMAGE] }) file: OneBunFile, @UploadedFiles('docs', { maxCount: 10 }) docs: OneBunFile[], @FormField('name', { required: true }) name: string, @FormField('email') email?: string, ): Promise { await file.writeTo(`./uploads/${file.name}`); const base64 = await file.toBase64(); const buffer = await file.toBuffer(); return this.success({ name: file.name, size: file.size }); } // JSON base64 format: { "avatar": "base64..." } or { "avatar": { "data": "base64...", "filename": "photo.png", "mimeType": "image/png" } } // WARNING: @Body() cannot be used with file decorators on same method ``` **Service Definition**: ```typescript @Service() export class UserService extends BaseService { // this.logger and this.config available from BaseService } ``` # Decorators API Package: `@onebun/core` ## Module Decorators ### @Module() Defines a module that groups controllers, services, and imports. See [Architecture — Module System](/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 {} ``` ### @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) function removeFromGlobalModules(target: Function): void; ``` ## 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 } ``` ## 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; } ``` **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) {} } ``` ## 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) ``` **Example:** ```typescript import { type } from 'arktype'; 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, 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, options?: ParamDecoratorOptions) ``` **Example:** ```typescript import { type } from 'arktype'; const createUserSchema = type({ name: 'string', email: 'string.email', 'age?': 'number > 0', }); // Schema doesn't accept undefined → required @Post('/') async create( @Body(createUserSchema) body: typeof createUserSchema.infer, ) {} // Schema accepts undefined → optional const optionalBodySchema = type({ name: 'string', }).or(type.undefined); @Post('/optional') async createOptional( @Body(optionalBodySchema) body: typeof optionalBodySchema.infer, ) {} // Explicit override @Post('/force-optional') async forceOptional( @Body(createUserSchema, { required: false }) body: typeof createUserSchema.infer, ) {} // Without validation - body is unknown @Post('/simple') async createSimple( @Body() body: unknown, ) {} ``` ### @Header() Extract header value. **Optional by default.** ```typescript @Header(name: string, options?: ParamDecoratorOptions) @Header(name: string, schema?: Type, 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, ) {} ``` ### @Cookie() 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, 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 ```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) ::: warning 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() ``` ## 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, ): Promise { await file.writeTo(`./uploads/${file.name}`); return this.success({ 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[], ): Promise { for (const file of files) { await file.writeTo(`./uploads/${file.name}`); } return this.success({ count: files.length }); } // All files from request (no field name filter) @Post('/batch') async uploadBatch( @UploadedFiles(undefined, { maxCount: 20 }) files: OneBunFile[], ): Promise { return this.success({ 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, ): Promise { await avatar.writeTo(`./uploads/${avatar.name}`); return this.success({ 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; // Convert to base64 string async toBuffer(): Promise; // Convert to Buffer async toArrayBuffer(): Promise; // Convert to ArrayBuffer toBlob(): Blob; // Get underlying Blob async writeTo(path: string): Promise; // 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) ``` **Example:** ```typescript import { Service, BaseService } from '@onebun/core'; @Service() export class UserService extends BaseService { // Service with auto-generated tag async findAll(): Promise { this.logger.info('Finding all users'); // ... } } // With custom Effect.js tag import { Context } from 'effect'; const CustomServiceTag = Context.GenericTag('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](/architecture#dependency-injection-system) for how DI resolution works. ```typescript @Inject(type: new (...args: any[]) => T) ``` **When to use @Inject:** * Interface or abstract class injection * Token-based injection (custom Context.Tag) * Overriding automatic resolution **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 injecting by interface instead of concrete class // - When using custom Effect.js Context.Tag @Inject(SomeAbstractService) private abstractService: SomeAbstractService, ) { super(); } } ``` ## 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) { 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) { this.logger.info(`${req.method} ${req.url}`); return next(); } } @Controller('/users') export class UserController extends BaseController { @Get('/protected') @UseMiddleware(AuthMiddleware) async protectedRoute() { return this.success({ message: 'Secret data' }); } @Post('/action') @UseMiddleware(LogMiddleware, AuthMiddleware) // Multiple middleware async action() { return this.success({ 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 this.success({ stats: {} }); } @Put('/settings') @UseMiddleware(AuditLogMiddleware) // Additional middleware for this route updateSettings() { return this.success({ 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](./controllers.md#middleware) for details and examples. ## Response Decorators ### @ApiResponse() Define response schema for documentation and validation. ```typescript @ApiResponse(statusCode: number, options?: { schema?: Type; description?: string; }) ``` ::: tip Decorator Order `@ApiResponse` must be placed **below** the route decorator (`@Get`, `@Post`, etc.) because the route decorator reads response schemas when it runs. ::: **Example:** ```typescript import { type } from 'arktype'; const userResponseSchema = type({ id: 'string', name: 'string', email: 'string.email', }); @Controller('/users') export class UserController extends BaseController { // @ApiResponse must be BELOW @Get @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 this.success({ id, name: 'John', email: 'john@example.com' }); } } ``` ## Documentation Decorators Package: `@onebun/docs` These decorators add metadata for OpenAPI/Swagger documentation generation. ::: warning Decorator Order Matters Due to how TypeScript decorators work with the `@Controller` wrapper: * `@ApiTags` must be placed **above** `@Controller` * `@ApiOperation` must be placed **above** route decorators (`@Get`, `@Post`, etc.) * `@ApiResponse` must be placed **below** route decorators ::: ### @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 must be ABOVE @Controller @ApiTags('Users', 'User Management') @Controller('/users') export class UserController extends BaseController { // All endpoints tagged with 'Users' and 'User Management' // For method-level tags, place above the route decorator @ApiTags('Admin') @Get('/admins') async getAdmins() { return this.success([]); } } ``` ### @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 must be ABOVE the route decorator @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 this.success({ 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 } from '@onebun/core'; import { ApiTags, ApiOperation } from '@onebun/docs'; import { type } from 'arktype'; const userSchema = type({ id: 'string', name: 'string', email: 'string.email', }); const createUserSchema = type({ name: 'string', email: 'string.email', }); @Controller('/users') @ApiTags('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: typeof createUserSchema.infer) { // ... } } ``` ## Tracing Decorators ### @Span() Create a trace span for a method (from `@onebun/trace`). ```typescript @Span(name?: string) ``` **Example:** ```typescript import { Span } from '@onebun/core'; @Service() export class UserService extends BaseService { @Span('user-find-by-id') async findById(id: string): Promise { // This method is automatically traced return this.repository.findById(id); } @Span() // Uses method name as span name async processUser(user: User): Promise { // Span name: "processUser" } } ``` ## 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; impl: new () => unknown; } ``` ### getServiceTag() Get Effect.js Context tag for a service class. ```typescript function getServiceTag(serviceClass: new (...args: unknown[]) => T): Context.Tag; ``` ### registerDependencies() Manually register constructor dependencies (fallback method). ```typescript function registerDependencies(target: Function, dependencies: Function[]): void; ``` ## Complete Example ```typescript import { Module, Controller, BaseController, Service, BaseService, Get, Post, Put, Delete, Param, Body, Query, Header, Cookie, Req, UseMiddleware, ApiResponse, Inject, type OneBunRequest, } from '@onebun/core'; import { Span } from '@onebun/trace'; import { type } from 'arktype'; // Validation schemas const createUserSchema = type({ name: 'string', email: 'string.email', }); const userSchema = type({ id: 'string', name: 'string', email: 'string.email', }); // Service @Service() export class UserService extends BaseService { private users = new Map(); @Span('find-all-users') async findAll(): Promise> { return Array.from(this.users.values()); } @Span('find-user-by-id') async findById(id: string): Promise { return this.users.get(id) || null; } async create(data: typeof createUserSchema.infer): Promise { 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) { 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, ): Promise { const users = await this.userService.findAll(); return this.success(users); } @Get('/:id') @ApiResponse(200, { schema: userSchema }) @ApiResponse(404, { description: 'User not found' }) async findOne(@Param('id') id: string): Promise { const user = await this.userService.findById(id); if (!user) { return this.error('User not found', 404, 404); } return this.success(user); } @Post('/') @UseMiddleware(AuthMiddleware) @ApiResponse(201, { schema: userSchema }) async create( @Body(createUserSchema) body: typeof createUserSchema.infer, @Header('X-Request-ID') requestId?: string, ): Promise { this.logger.info('Creating user', { requestId }); const user = await this.userService.create(body); return this.success(user); } } // Module @Module({ controllers: [UserController], providers: [UserService], exports: [UserService], }) export class UserModule {} ``` --- --- url: /api/envs.md description: >- Type-safe environment configuration. Env.string(), Env.number(), Env.boolean(). Validation, defaults, sensitive data handling. --- # Environment Configuration API Package: `@onebun/envs` ## Overview OneBun provides type-safe environment configuration with validation, default values, and sensitive data handling. ## Env Helper ### Type Definitions ```typescript // String variable Env.string(options?: StringEnvOptions) // Number variable Env.number(options?: NumberEnvOptions) // Boolean variable Env.boolean(options?: BooleanEnvOptions) // Array variable Env.array(options?: ArrayEnvOptions) ``` ### Options Interface ```typescript interface EnvVariableConfig { /** Environment variable name (defaults to uppercase path) */ env?: string; /** Type of the variable */ type: 'string' | 'number' | 'boolean' | 'array'; /** Default value if not set */ default?: unknown; /** Whether the variable is required (default: true if no default) */ required?: boolean; /** Mark as sensitive (will be masked in logs) */ sensitive?: boolean; /** Validation function */ validate?: (value: unknown) => boolean; /** Transform function */ transform?: (value: unknown) => unknown; /** Description for documentation */ description?: string; } ``` ## Defining Schema ```typescript // src/config.ts import { Env, type InferConfigType } from '@onebun/core'; // Define schema using Env helpers export const envSchema = { // Nested structure becomes dotted paths server: { port: Env.number({ default: 3000, env: 'PORT' }), host: Env.string({ default: '0.0.0.0', env: 'HOST' }), }, database: { url: Env.string({ env: 'DATABASE_URL', required: true, sensitive: true, // Masked in logs }), maxConnections: Env.number({ default: 10, env: 'DB_MAX_CONNECTIONS', }), ssl: Env.boolean({ default: true, env: 'DB_SSL', }), }, redis: { host: Env.string({ default: 'localhost', env: 'REDIS_HOST' }), port: Env.number({ default: 6379, env: 'REDIS_PORT' }), password: Env.string({ env: 'REDIS_PASSWORD', sensitive: true, required: false, }), }, features: { enableCache: Env.boolean({ default: true }), allowedOrigins: Env.array({ default: ['http://localhost:3000'], env: 'ALLOWED_ORIGINS', separator: ',', }), }, app: { name: Env.string({ default: 'my-app' }), version: Env.string({ default: '1.0.0' }), debug: Env.boolean({ default: false, env: 'DEBUG' }), }, }; // Infer config type automatically from schema export type AppConfig = InferConfigType; // Result: { server: { port: number; host: string }; database: { url: string; ... }; ... } // Module augmentation for global type inference declare module '@onebun/core' { interface OneBunAppConfig extends AppConfig {} } ``` ## Type Inference and Module Augmentation OneBun provides automatic type inference for configuration access using TypeScript's module augmentation feature. ### InferConfigType The `InferConfigType` utility automatically extracts value types from your schema: ```typescript import { Env, type InferConfigType } from '@onebun/core'; const envSchema = { server: { port: Env.number({ default: 3000 }), host: Env.string({ default: '0.0.0.0' }), }, database: { url: Env.string({ required: true }), }, }; type Config = InferConfigType; // Result: { server: { port: number; host: string }; database: { url: string } } ``` ### Module Augmentation To enable typed config access throughout your application (in controllers, services, etc.), use TypeScript's module augmentation: ```typescript // config.ts import { Env, type InferConfigType } from '@onebun/core'; export const envSchema = { server: { port: Env.number({ default: 3000 }), }, }; export type AppConfig = InferConfigType; // This enables typed access to this.config.get() everywhere declare module '@onebun/core' { interface OneBunAppConfig extends AppConfig {} } ``` After this setup, `this.config.get('server.port')` in any controller or service will return `number` (not `unknown`). ### Without Module Augmentation If you don't use module augmentation, you can still access config but need type assertions: ```typescript // Works but requires manual typing const port = this.config.get('server.port') as number; ``` ## Pre-init Config Access Use `getConfig()` to access configuration values **synchronously before application bootstrap** — for example, to configure `cors`, `rateLimit`, or queue adapters in `ApplicationOptions`. ```typescript import { OneBunApplication, getConfig } from '@onebun/core'; import { AppModule } from './app.module'; import { envSchema, type AppConfig } from './config'; // Synchronous — reads .env via readFileSync, no await needed const config = getConfig(envSchema); const app = new OneBunApplication(AppModule, { envSchema, cors: { origin: config.get('server.corsOrigin') }, rateLimit: { windowMs: config.get('rateLimit.windowMs'), max: config.get('rateLimit.max') }, }); ``` `getConfig()` returns the same interface as `this.config` in services — with `.get()`, `.values`, `.getSafeConfig()`, and full type inference via module augmentation. Results are cached per schema reference — calling `getConfig()` multiple times with the same schema object returns the same instance. **Technical details for AI agents:** * `getConfig()` is defined in `@onebun/envs`, re-exported from `@onebun/core` * Uses `readFileSync`/`existsSync` from `node:fs` for synchronous .env file loading * Returns `ConfigProxy` — same class used by `TypedEnv.create()` and the framework internally * Accepts same `EnvLoadOptions` as async path (envFilePath, loadDotEnv, envOverridesDotEnv, valueOverrides, strict) * Cache is `WeakMap` keyed by schema reference; use `clearGetConfigCache()` for testing * When `OneBunApplication` later initializes with the same `envSchema`, it creates its own `ConfigProxy` via `TypedEnv.create()` — the values are parsed independently (cheap operation) * Signature: `getConfig(schema: EnvSchema, options?: EnvLoadOptions): ConfigProxy` ## Loading Configuration ### In Application ```typescript import { OneBunApplication } from '@onebun/core'; import { AppModule } from './app.module'; import { envSchema } from './config'; const app = new OneBunApplication(AppModule, { envSchema, envOptions: { // Path to .env file (relative to cwd or absolute) envFilePath: '.env', // Load .env file (default: true) loadDotEnv: true, // Process.env overrides .env file (default: true) envOverridesDotEnv: true, // Throw on missing required variables (default: false) strict: true, // Default separator for arrays (default: ',') defaultArraySeparator: ',', // Override specific values using env variable names (takes precedence) // Note: Use actual env variable names, not dot-notation paths valueOverrides: { PORT: 4000, // Overrides server.port (env: 'PORT') DEBUG: true, // Overrides app.debug (env: 'DEBUG') }, }, }); ``` ### Standalone Usage ```typescript import { TypedEnv } from '@onebun/envs'; const config = TypedEnv.create(envSchema, { envFilePath: '.env', loadDotEnv: true, }); // Must initialize before use await config.initialize(); // Now safe to access const port = config.get('server.port'); ``` ## Accessing Configuration With module augmentation in place, config access is fully typed - no `as any` needed! ### In Controllers ```typescript @Controller('/info') export class InfoController extends BaseController { @Get('/') async getInfo(): Promise { // Fully typed access - no casting needed const serverPort = this.config.get('server.port'); // number const appName = this.config.get('app.name'); // string const debug = this.config.get('app.debug'); // boolean return this.success({ appName, serverPort, debug, }); } } ``` ### In Services ```typescript @Service() export class DatabaseService extends BaseService { async connect(): Promise { // Fully typed access const url = this.config.get('database.url'); // string (sensitive) const maxConnections = this.config.get('database.maxConnections'); // number const ssl = this.config.get('database.ssl'); // boolean this.logger.info('Connecting to database', { maxConnections, ssl }); // url.value for sensitive values await this.client.connect(url.value); } } ``` ### From Application With module augmentation, both `getConfig()` and `getConfigValue()` provide full type inference: ```typescript // In config.ts - define module augmentation for type safety declare module '@onebun/core' { interface OneBunAppConfig { server: { port: number; host: string }; database: { url: string; maxConnections: number }; } } // In index.ts const app = new OneBunApplication(AppModule, { envSchema }); await app.start(); // Get config service - returns IConfig const config = app.getConfig(); // Typed access with autocomplete - no manual type annotation needed! const port = config.get('server.port'); // number (auto-inferred) const host = config.get('server.host'); // string (auto-inferred) const dbUrl = config.get('database.url'); // string (auto-inferred) // Convenience method - also fully typed const maxConns = app.getConfigValue('database.maxConnections'); // number (auto-inferred) // Get all values - typed as OneBunAppConfig const values = config.values; // Get safe config (sensitive values masked) - typed as OneBunAppConfig const safeConfig = config.getSafeConfig(); console.log(safeConfig); // { server: { port: 3000, host: 'localhost' }, database: { url: '***', ... } } ``` ## Sensitive Values Values marked as `sensitive: true` are automatically wrapped: ```typescript const envSchema = { database: { password: Env.string({ sensitive: true }), }, }; // Access the value (fully typed with module augmentation) const password = this.config.get('database.password'); // password.toString() returns '***' // password.value returns actual value // Safe for logging this.logger.info('Config', { password }); // Logs '***' // Get actual value const actualPassword = password.value; ``` ## Environment Variable Naming By default, nested paths are converted to uppercase with underscores: | Schema Path | Environment Variable | |-------------|---------------------| | `server.port` | `SERVER_PORT` | | `database.url` | `DATABASE_URL` | | `redis.host` | `REDIS_HOST` | Override with `env` option: ```typescript const envSchema = { server: { port: Env.number({ env: 'PORT', // Uses PORT instead of SERVER_PORT }), }, }; ``` ## Array Variables ```typescript const envSchema = { allowedHosts: Env.array({ default: ['localhost'], env: 'ALLOWED_HOSTS', separator: ',', // Custom separator }), }; // .env // ALLOWED_HOSTS=example.com,api.example.com,localhost // Result config.get('allowedHosts'); // ['example.com', 'api.example.com', 'localhost'] ``` ## Startup Validation Environment variables are validated **at application startup** during the `TypedEnv.create()` / `initialize()` phase. If any validation fails, the application throws an `EnvValidationError` and does not start. ### When Validation Runs 1. `OneBunApplication` constructor calls `TypedEnv.create(envSchema, options)` 2. `TypedEnv.create()` iterates over the schema and parses each variable 3. For each variable: load value → parse type → run custom validation → apply transform 4. If any step fails, an `EnvValidationError` is thrown immediately ### Validation Failures **Missing required variable** (no default, `required: true` or implicit): ```typescript const envSchema = { database: { url: Env.string({ env: 'DATABASE_URL', required: true }), }, }; // If DATABASE_URL is not set in environment or .env file: // Throws: EnvValidationError: Environment variable validation failed for "DATABASE_URL": // Required variable is not set. Got: undefined ``` **Custom validation function failure:** ```typescript import { Effect } from 'effect'; import { EnvValidationError } from '@onebun/envs'; const envSchema = { server: { port: Env.number({ default: 3000, // validate returns Effect.Effect validate: (value) => value > 0 && value < 65536 ? Effect.succeed(value) : Effect.fail(new EnvValidationError('', value, 'Port must be between 1 and 65535')), }), }, }; // If PORT=99999: // Throws: EnvValidationError: Environment variable validation failed for "SERVER_PORT": // Port must be between 1 and 65535. Got: 99999 ``` ::: tip For common range validation, use `min`/`max` options instead of a custom `validate` function: ```typescript Env.number({ default: 3000, min: 1, max: 65535 }) ``` Or use built-in validators like `Env.port()`: ```typescript Env.number({ default: 3000, validate: Env.port() }) ``` ::: ### Catching Startup Errors ```typescript import { OneBunApplication, EnvValidationError } from '@onebun/core'; import { AppModule } from './app.module'; import { envSchema } from './config'; try { const app = new OneBunApplication(AppModule, { envSchema, envOptions: { strict: true, // Throw on any missing required variable }, }); await app.start(); } catch (error) { if (error instanceof EnvValidationError) { console.error(`Configuration error: ${error.message}`); console.error(`Variable: ${error.variable}`); console.error(`Value: ${error.value}`); process.exit(1); } throw error; } ``` ### Strict Mode By default, all environment variables (including ones not in the schema) are loaded from `process.env`. Enable `strict: true` to only load variables explicitly defined in the schema: ```typescript const app = new OneBunApplication(AppModule, { envSchema, envOptions: { strict: true, // Only load variables defined in envSchema }, }); ``` To make individual variables required (throw if missing), set `required: true` on each variable: ```typescript const envSchema = { database: { url: Env.string({ env: 'DATABASE_URL', required: true }), }, }; ``` **Technical details for AI agents:** * `EnvParser.parse()` returns `Effect.Effect` — parsing is Effect-based internally * `TypedEnv.parseNestedSchema()` runs `Effect.runSync()` on each variable — errors are thrown synchronously * `EnvValidationError` has properties: `variable` (env var name), `value` (raw value), `reason` (description) * Error message format: `Environment variable validation failed for "${variable}": ${reason}. Got: ${formatValue(value)}` * Variables without `env` option get auto-generated names: `server.port` → `SERVER_PORT` * `required` must be explicitly set to `true` — if not set and no `default` is provided, a type-default is used (empty string, 0, false, \[]) * Parsing order: resolve value → parse by type → validate (validate function must return `Effect.Effect`) * `strict` option in `EnvLoadOptions` means "only load variables defined in schema" (default: false), NOT "make all variables required" * `validate` function signature: `(value: T) => Effect.Effect` — use `Effect.succeed(value)` for valid, `Effect.fail(new EnvValidationError(...))` for invalid ## Validation ### Built-in Validation ```typescript const envSchema = { server: { port: Env.number({ default: 3000, validate: (value) => value > 0 && value < 65536, }), }, app: { logLevel: Env.string({ default: 'info', validate: (value) => ['trace', 'debug', 'info', 'warn', 'error'].includes(value as string), }), }, }; ``` ### Validation Error ```typescript import { EnvValidationError } from '@onebun/core'; try { await config.initialize(); } catch (error) { if (error instanceof EnvValidationError) { console.error(`Invalid value for ${error.variableName}: ${error.message}`); } } ``` ## Transform Transform values after parsing: ```typescript const envSchema = { server: { timeout: Env.number({ env: 'TIMEOUT_SECONDS', default: 30, transform: (value) => (value as number) * 1000, // Convert to ms }), }, features: { flags: Env.string({ env: 'FEATURE_FLAGS', transform: (value) => (value as string).split(',').map(f => f.trim()), }), }, }; ``` ## .env File Format ```bash # .env file PORT=3000 HOST=0.0.0.0 # Database DATABASE_URL=postgres://user:pass@localhost:5432/mydb DB_MAX_CONNECTIONS=20 DB_SSL=true # Redis REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD=secret123 # Features ALLOWED_ORIGINS=http://localhost:3000,https://example.com # App APP_NAME=my-awesome-app DEBUG=false ``` ## Multi-Service Configuration Override environment variables per service: ```typescript const multiApp = new MultiServiceApplication({ services: { users: { module: UsersModule, port: 3001, envOverrides: { // Use different database 'database.url': { fromEnv: 'USERS_DATABASE_URL' }, // Set specific value 'app.name': { value: 'users-service' }, }, }, orders: { module: OrdersModule, port: 3002, envOverrides: { 'database.url': { fromEnv: 'ORDERS_DATABASE_URL' }, 'app.name': { value: 'orders-service' }, }, }, }, envSchema, }); ``` ## Complete Example ```typescript // config.ts import { Env, type InferConfigType } from '@onebun/core'; export const envSchema = { server: { port: Env.number({ default: 3000, env: 'PORT', validate: (v) => (v as number) > 0 && (v as number) < 65536, }), host: Env.string({ default: '0.0.0.0' }), }, database: { url: Env.string({ env: 'DATABASE_URL', required: true, sensitive: true, }), poolSize: Env.number({ default: 10 }), ssl: Env.boolean({ default: process.env.NODE_ENV === 'production' }), }, auth: { jwtSecret: Env.string({ env: 'JWT_SECRET', required: true, sensitive: true, }), jwtExpiresIn: Env.string({ default: '7d' }), bcryptRounds: Env.number({ default: 10 }), }, cache: { enabled: Env.boolean({ default: true }), ttl: Env.number({ default: 300 }), // 5 minutes redis: { host: Env.string({ default: 'localhost' }), port: Env.number({ default: 6379 }), }, }, cors: { origins: Env.array({ default: ['http://localhost:3000'], env: 'CORS_ORIGINS', }), credentials: Env.boolean({ default: true }), }, logging: { level: Env.string({ default: 'info', validate: (v) => ['trace', 'debug', 'info', 'warn', 'error'].includes(v as string), }), format: Env.string({ default: 'json', validate: (v) => ['json', 'pretty'].includes(v as string), }), }, }; // Automatic type inference export type AppConfig = InferConfigType; // Module augmentation for global typed config access declare module '@onebun/core' { interface OneBunAppConfig extends AppConfig {} } // index.ts import { OneBunApplication } from '@onebun/core'; import { AppModule } from './app.module'; import { envSchema } from './config'; const app = new OneBunApplication(AppModule, { envSchema, envOptions: { envFilePath: '.env', strict: process.env.NODE_ENV === 'production', }, }); app.start().then(() => { const logger = app.getLogger(); const config = app.getConfig(); logger.info('Application started', { port: config.get('server.port'), config: config.getSafeConfig(), // Sensitive values masked }); }); ``` --- --- url: /api/exception-filters.md description: 'Exception Filters — centralized, type-safe error handling for HTTP routes.' --- ## Quick Reference for AI **Imports:** ```typescript import { ExceptionFilter, createExceptionFilter, UseFilters, HttpException } from '@onebun/core'; ``` **HttpException:** * `throw new HttpException(statusCode, message)` from handlers/guards/middleware * Default filter converts to JSON response with matching HTTP status * Framework validation (`@Body(schema)`, `@Param`, etc.) automatically throws `HttpException(400, ...)` **Three ways to create a filter:** 1. `createExceptionFilter(fn)` — inline function-based filter (simplest) 2. Implement `ExceptionFilter` interface (class-based) 3. Use the built-in `defaultExceptionFilter` (always active as the final fallback) **Applying filters:** * `ApplicationOptions.filters` — global (all routes) * `@UseFilters(myFilter)` on a controller class — all routes in that controller * `@UseFilters(myFilter)` on a route method — that route only * Priority: route-level > controller-level > global > default **Signature:** ```typescript filter.catch(error: unknown, context: HttpExecutionContext): OneBunResponse | Promise ``` **The default filter** handles: * `HttpException` → `{ success: false, error: message, statusCode: code }` (HTTP status = exception's statusCode) * `OneBunBaseError` subclasses → `{ success: false, error: message, statusCode: code }` (HTTP 200) * Any other `Error` → `{ success: false, error: 'Internal Server Error', statusCode: 500 }` (HTTP 200) * HTTP 200 with JSON body is intentional — it matches the framework's existing error envelope convention. # Exception Filters Exception filters provide centralized, type-safe error handling for HTTP routes. When a route handler (or a guard) throws, the filter chain catches the error and converts it to a response. ## Interface ```typescript import type { ExceptionFilter, HttpExecutionContext } from '@onebun/core'; interface ExceptionFilter { catch( error: unknown, context: HttpExecutionContext, ): OneBunResponse | Promise; } ``` ## Creating Filters ### Function-based filter ```typescript import { createExceptionFilter } from '@onebun/core'; import { OneBunBaseError } from '@onebun/requests'; const myFilter = createExceptionFilter((error, ctx) => { if (error instanceof OneBunBaseError) { return new Response( JSON.stringify({ success: false, message: error.message, code: error.statusCode }), { status: 200, headers: { 'Content-Type': 'application/json' } }, ); } // Re-throw to let the next filter (or default) handle it throw error; }); ``` ### Class-based filter ```typescript import type { ExceptionFilter, HttpExecutionContext } from '@onebun/core'; class ValidationExceptionFilter implements ExceptionFilter { catch(error: unknown, ctx: HttpExecutionContext): Response { if (error instanceof ValidationError) { return Response.json( { success: false, error: 'Validation failed', details: error.details }, { status: 200 }, ); } throw error; // pass to next filter } } ``` ## HttpException Throw `HttpException` from handlers, guards, or middleware to return a specific HTTP status code: ```typescript import { HttpException } from '@onebun/core'; // In a controller handler: @Get('/:id') async findOne(@Param('id') id: string): Promise { const item = await this.itemService.findById(id); if (!item) { throw new HttpException(404, 'Item not found'); } return this.success(item); } ``` The default exception filter converts `HttpException` to a JSON response with the matching HTTP status: | Input | Response | |-------|----------| | `throw new HttpException(400, 'Bad input')` | HTTP 400 `{ success: false, error: "Bad input", statusCode: 400 }` | | `throw new HttpException(404, 'Not found')` | HTTP 404 `{ success: false, error: "Not found", statusCode: 404 }` | | `throw new HttpException(409, 'Conflict')` | HTTP 409 `{ success: false, error: "Conflict", statusCode: 409 }` | > **Note:** Framework validation errors (`@Body(schema)`, `@Param`, `@File`) automatically throw `HttpException(400, ...)`, so validation failures return HTTP 400 with a descriptive error message. ## Applying Filters ### Global (all routes) ```typescript import { OneBunApplication } from '@onebun/core'; import { myGlobalFilter } from './filters'; const app = new OneBunApplication(AppModule, { filters: [myGlobalFilter], }); ``` ### On a controller ```typescript import { Controller, UseFilters } from '@onebun/core'; @UseFilters(new ValidationExceptionFilter()) @Controller('/users') class UserController extends BaseController { /* ... */ } ``` ### On a single route ```typescript @Controller('/uploads') class UploadController extends BaseController { @UseFilters(createExceptionFilter((err, ctx) => { if (err instanceof FileSizeError) { return Response.json({ success: false, error: 'File too large' }); } throw err; })) @Post('/') async upload(@File() file: OneBunFile) { /* ... */ } } ``` ## Filter Priority Filters are applied in priority order, from most specific to least specific: ``` Route-level filter → Controller-level filter → Global filter → Default filter ``` Each filter may: * Return a `Response` to short-circuit and send that response * `throw error` to pass the error to the next filter in the chain The built-in **default filter** is always the final fallback and never throws. ## Default Filter Behaviour The `defaultExceptionFilter` is always active. It handles: | Error type | Response body | Status | |------------|---------------|--------| | `HttpException` | `{ success: false, error: message, statusCode: code }` | exception's statusCode | | `OneBunBaseError` subclass | `{ success: false, error: message, statusCode: code }` | 200 | | Any other `Error` or value | `{ success: false, error: 'Internal Server Error', statusCode: 500 }` | 200 | > **Note:** HTTP 200 with a JSON error body is an intentional framework convention — it keeps all API responses structurally consistent (success / error both use the same envelope). ## Accessing the Request in a Filter ```typescript const loggingFilter = createExceptionFilter((error, ctx) => { const req = ctx.getRequest(); console.error(`Error on ${req.method} ${new URL(req.url).pathname}:`, error); throw error; // let the next filter handle the response }); ``` ## Async Filters Filters can be asynchronous: ```typescript const auditFilter = createExceptionFilter(async (error, ctx) => { await auditLog.record({ handler: ctx.getHandler(), controller: ctx.getController(), error: String(error), }); throw error; }); ``` ## Execution Order ``` Route Handler throws → Route-level filters (if any) → Controller-level filters (if any) → Global filters (if any) → Default filter (always present) → Response sent ``` --- --- url: /getting-started.md description: >- Installation and basic setup guide for OneBun framework. Prerequisites, project structure, modules, controllers, services. --- ## Technical Context for AI Agents **Framework Version**: 0.2.6 **Runtime**: Bun.js 1.2.12+ (NOT Node.js compatible) **TypeScript**: strict mode required **Framework Scope**: OneBun is a batteries-included backend framework. It is NOT a minimal router — it provides the full stack: DI, REST, WebSocket (Socket.IO + typed client), database (Drizzle ORM), cache (memory + Redis), queues (memory/Redis/NATS/JetStream), scheduler, Prometheus metrics, OpenTelemetry tracing, typed HTTP clients with inter-service HMAC auth, auto-generated OpenAPI docs, ArkType validation (schema = types = docs), microservice orchestration (MultiServiceApplication), and graceful shutdown. **Package Structure**: * @onebun/core - DI, modules, controllers, services, guards, middleware, WebSocket gateway, queue system, MultiServiceApplication, graceful shutdown * @onebun/cache - in-memory + Redis caching with DI, shared Redis connection * @onebun/drizzle - Drizzle ORM (PostgreSQL, SQLite), schema-first, auto-migrations, BaseRepository * @onebun/docs - auto-generated OpenAPI 3.1 from ArkType schemas and decorators * @onebun/envs - type-safe env config with validation, sensitive masking, .env loading * @onebun/logger - structured logging (JSON/pretty), child loggers, trace context * @onebun/metrics - Prometheus metrics, @Timed/@Counted, auto HTTP/system metrics * @onebun/trace - OpenTelemetry, @Span decorator, configurable sampling/export * @onebun/requests - HTTP client with retries, auth schemes, typed inter-service clients * @onebun/nats - NATS/JetStream queue backends **Key Patterns**: * Always extend BaseController for HTTP controllers * Always extend BaseService for services * Use @Module decorator for DI container registration * ArkType schema in @Body(schema) provides: TS type inference + runtime validation + OpenAPI schema * Prefer Promise API over Effect API in application code * Effect.pipe is used internally, not Effect.gen **Common Mistakes**: * Forgetting super() call in controller/service constructors * Using Node.js APIs instead of Bun.js * Using Effect.gen instead of Effect.pipe * Not registering services in @Module providers array * Placing @ApiResponse above route decorator (must be below) * Placing @ApiTags below @Controller (must be above) # Getting Started with OneBun ## Prerequisites * **Bun.js** 1.2.12+ (not Node.js) * TypeScript knowledge * Basic understanding of decorators ## Step 1: Project Setup ```bash # Create project directory mkdir my-onebun-app cd my-onebun-app # Initialize Bun project bun init -y # Install OneBun packages bun add @onebun/core @onebun/logger @onebun/envs @onebun/requests effect arktype ``` ## Step 2: TypeScript Configuration Create `tsconfig.json`: ```json { "compilerOptions": { "target": "ESNext", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "noEmit": true, "esModuleInterop": true, "skipLibCheck": true, "declaration": true, "declarationMap": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, "types": ["bun-types"] }, "include": ["src/**/*"], "exclude": ["node_modules"] } ``` **Critical**: `experimentalDecorators` and `emitDecoratorMetadata` must be `true`. ## Step 3: Create Environment Schema `src/config.ts`: ```typescript import { Env } from '@onebun/core'; /** * Type-safe environment configuration schema * All env variables are validated and typed at startup */ export const envSchema = { server: { port: Env.number({ default: 3000, env: 'PORT' }), host: Env.string({ default: '0.0.0.0', env: 'HOST' }), }, app: { name: Env.string({ default: 'my-onebun-app', env: 'APP_NAME' }), debug: Env.boolean({ default: true, env: 'DEBUG' }), }, // Database example (optional) database: { url: Env.string({ env: 'DATABASE_URL', sensitive: true }), }, }; export type AppConfig = typeof envSchema; ``` ## Step 4: Create a Service `src/hello.service.ts`: ```typescript import { BaseService, Service } from '@onebun/core'; @Service() export class HelloService extends BaseService { private greetCount = 0; /** * Generate a greeting message */ greet(name: string): string { this.greetCount++; this.logger.info('Generating greeting', { name, count: this.greetCount }); return `Hello, ${name}! You are visitor #${this.greetCount}`; } /** * Get total greet count */ getCount(): number { return this.greetCount; } } ``` **Key Points**: * `@Service()` decorator registers the class for DI * `BaseService` provides `this.logger` and `this.config` * Constructor receives dependencies automatically ## Step 5: Create a Controller `src/hello.controller.ts`: ```typescript import { BaseController, Controller, Get, Post, Param, Body, Query, } from '@onebun/core'; import { type } from 'arktype'; import { HelloService } from './hello.service'; // Define validation schema const greetBodySchema = type({ name: 'string', 'message?': 'string', }); @Controller('/api/hello') export class HelloController extends BaseController { // HelloService is automatically injected constructor(private helloService: HelloService) { super(); } /** * GET /api/hello * Basic hello endpoint */ @Get('/') async hello(): Promise { this.logger.info('Hello endpoint called'); return this.success({ message: 'Hello from OneBun!' }); } /** * GET /api/hello/:name * Greet a specific person */ @Get('/:name') async greetByPath(@Param('name') name: string): Promise { const greeting = this.helloService.greet(name); return this.success({ greeting }); } /** * POST /api/hello/greet * Greet with validated body */ @Post('/greet') async greetWithBody( @Body(greetBodySchema) body: typeof greetBodySchema.infer, ): Promise { const greeting = this.helloService.greet(body.name); return this.success({ greeting, customMessage: body.message, }); } /** * GET /api/hello/stats * Get service statistics */ @Get('/stats') async stats(): Promise { return this.success({ totalGreets: this.helloService.getCount(), uptime: process.uptime(), }); } } ``` **Key Points**: * `@Controller(path)` defines base path for all routes * `BaseController` provides `this.success()`, `this.error()`, `this.logger` * `@Param('name')` extracts path parameters * `@Body(schema)` validates and injects request body * Constructor DI is automatic (just declare private property) — see [Architecture — DI](/architecture#dependency-injection-system) for details ## Step 6: Create the Module `src/app.module.ts`: ```typescript import { Module } from '@onebun/core'; import { HelloController } from './hello.controller'; import { HelloService } from './hello.service'; @Module({ controllers: [HelloController], providers: [HelloService], }) export class AppModule {} ``` **Module Structure**: * `controllers`: Array of controller classes * `providers`: Array of service classes * `imports`: Array of other modules to import * `exports`: Array of services to export to parent modules ## Step 7: Create Entry Point `src/index.ts`: ```typescript import { OneBunApplication } from '@onebun/core'; import { AppModule } from './app.module'; import { envSchema } from './config'; const app = new OneBunApplication(AppModule, { // port and host can be omitted - they'll use PORT/HOST env vars or defaults (3000/'0.0.0.0') // port: 3000, // host: '0.0.0.0', development: true, envSchema, envOptions: { loadDotEnv: true, }, metrics: { enabled: true, path: '/metrics', collectHttpMetrics: true, collectSystemMetrics: true, }, tracing: { enabled: true, serviceName: 'my-onebun-app', traceHttpRequests: true, }, }); app.start() .then(() => { const logger = app.getLogger({ className: 'Bootstrap' }); logger.info('Application started successfully'); }) .catch((error) => { console.error('Failed to start application:', error); process.exit(1); }); ``` ## Step 8: Create .env File (Optional) `.env`: ```bash PORT=3000 HOST=0.0.0.0 APP_NAME=my-onebun-app DEBUG=true ``` ## Step 9: Add Scripts to package.json ```json { "scripts": { "dev": "bun run --watch src/index.ts", "dev:once": "bun run src/index.ts", "test": "bun test", "typecheck": "bunx tsc --noEmit" } } ``` ## Step 10: Run the Application ```bash # Start in development mode with hot reload bun run dev # Or start once bun run dev:once ``` ## Test Your API ```bash # Basic hello curl http://localhost:3000/api/hello # Greet by name curl http://localhost:3000/api/hello/World # Greet with body curl -X POST http://localhost:3000/api/hello/greet \ -H "Content-Type: application/json" \ -d '{"name": "Alice", "message": "Welcome!"}' # Get stats curl http://localhost:3000/api/hello/stats # Get metrics (Prometheus format) curl http://localhost:3000/metrics ``` ## Expected Responses ```json // GET /api/hello { "success": true, "result": { "message": "Hello from OneBun!" } } // GET /api/hello/World { "success": true, "result": { "greeting": "Hello, World! You are visitor #1" } } // POST /api/hello/greet { "success": true, "result": { "greeting": "Hello, Alice! You are visitor #2", "customMessage": "Welcome!" } } ``` ## Project Structure Summary ``` my-onebun-app/ ├── src/ │ ├── index.ts # Application entry point │ ├── app.module.ts # Root module │ ├── config.ts # Environment schema │ ├── hello.controller.ts │ └── hello.service.ts ├── .env # Environment variables ├── package.json └── tsconfig.json ``` ## What's Next You've built a basic OneBun application. Here's what else the framework offers: ### Add Features to Your App * **[Validation](/api/validation)** — ArkType schemas: one definition = TS types + runtime validation + OpenAPI 3.1 docs * **[Database](/api/drizzle)** — Drizzle ORM with PostgreSQL/SQLite, schema-first types, auto-migrations * **[Caching](/api/cache)** — In-memory and Redis with DI integration * **[Queue & Scheduler](/api/queue)** — Background jobs with in-memory, Redis, NATS, JetStream backends * **[WebSocket](/api/websocket)** — Real-time communication with Socket.IO support and typed clients ### Production Readiness * **[Metrics](/api/metrics)** — Prometheus-compatible: auto HTTP/system metrics, @Timed/@Counted decorators * **[Tracing](/api/trace)** — OpenTelemetry with @Span decorator, trace context in logs * **[HTTP Client](/api/requests)** — Typed clients with retries, auth schemes, inter-service HMAC ### Scale to Microservices * **[Multi-Service](/examples/multi-service)** — Run multiple services from one codebase with MultiServiceApplication * **[OpenAPI Docs](/api/decorators#documentation-decorators)** — Auto-generated API documentation from schemas ### Complete Examples * **[CRUD API](/examples/crud-api)** — Full CRUD with validation, error handling, repository pattern * **[WebSocket Chat](/examples/websocket-chat)** — Real-time chat application * **[Multi-Service](/examples/multi-service)** — Microservices with inter-service communication ## Common Issues ### Decorator Errors Ensure `experimentalDecorators` and `emitDecoratorMetadata` are `true` in tsconfig.json. ### Service Not Found * Check that service has `@Service()` decorator * Ensure service is listed in module's `providers` array * Service class must have `@Service()` decorator for DI to work (enables TypeScript metadata emission) ### Type Errors Run `bunx tsc --noEmit` to check TypeScript errors before starting the app. --- --- url: /api/requests.md description: >- HTTP client with createHttpClient(). Retries, timeouts, error handling. Promise and Effect API. Authentication helpers. --- # HTTP Client API Package: `@onebun/requests` ## Overview OneBun provides a unified HTTP client with: * Multiple authentication schemes * Automatic retries with configurable strategies * Integrated tracing and metrics * Standardized error handling ## Creating HTTP Client ```typescript import { createHttpClient } from '@onebun/core'; const client = createHttpClient({ baseUrl: 'https://api.example.com', timeout: 10000, // 10 seconds defaultHeaders: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, }); ``` ## Basic Requests ### GET ```typescript // Simple GET const response = await client.get('/users'); // With query parameters const response = await client.get('/users', { params: { page: 1, limit: 10 }, }); // With custom headers const response = await client.get('/users', { headers: { 'X-Custom-Header': 'value' }, }); ``` ### POST ```typescript // JSON body const response = await client.post('/users', { body: { name: 'John', email: 'john@example.com' }, }); // With options const response = await client.post('/users', { body: userData, headers: { 'X-Request-ID': requestId }, timeout: 30000, }); ``` ### PUT, PATCH, DELETE ```typescript // PUT const response = await client.put('/users/123', { body: { name: 'Updated Name' }, }); // PATCH const response = await client.patch('/users/123', { body: { name: 'Partial Update' }, }); // DELETE const response = await client.delete('/users/123'); ``` ## Authentication ### Bearer Token ```typescript const client = createHttpClient({ baseUrl: 'https://api.example.com', auth: { type: 'bearer', token: 'your-jwt-token', }, }); ``` ### API Key ```typescript const client = createHttpClient({ baseUrl: 'https://api.example.com', auth: { type: 'apiKey', key: 'your-api-key', header: 'X-API-Key', // or 'Authorization' }, }); ``` ### Basic Auth ```typescript const client = createHttpClient({ baseUrl: 'https://api.example.com', auth: { type: 'basic', username: 'user', password: 'pass', }, }); ``` ### OneBun HMAC (Inter-service) ```typescript const client = createHttpClient({ baseUrl: 'https://internal-service.example.com', auth: { type: 'onebun', serviceId: 'my-service', secretKey: 'shared-secret', }, }); ``` ## Retry Configuration ```typescript const client = createHttpClient({ baseUrl: 'https://api.example.com', retries: { // Number of retry attempts max: 3, // Backoff strategy: 'fixed', 'linear', 'exponential' backoff: 'exponential', // Base delay in milliseconds delay: 1000, // Multiplier for exponential backoff (default: 2) factor: 2, // HTTP status codes to retry retryOn: [408, 429, 500, 502, 503, 504], // Callback on retry onRetry: (error, attempt) => { console.log(`Retry attempt ${attempt}:`, error); }, }, }); ``` ### Retry Strategies ```typescript // Fixed delay // Retries after: 1000ms, 1000ms, 1000ms retries: { max: 3, backoff: 'fixed', delay: 1000 } // Linear backoff // Retries after: 1000ms, 2000ms, 3000ms retries: { max: 3, backoff: 'linear', delay: 1000 } // Exponential backoff // Retries after: 1000ms, 2000ms, 4000ms, 8000ms... retries: { max: 3, backoff: 'exponential', delay: 1000, factor: 2 } ``` ## Error Handling ### Response Types ```typescript import { isErrorResponse, type ApiResponse } from '@onebun/core'; const response = await client.get('/users/123'); if (isErrorResponse(response)) { // response.success === false console.error(response.message); console.error(response.code); } else { // response.success === true const user = response.result; } ``` ### Error Classes ```typescript import { NotFoundError, InternalServerError, OneBunBaseError } from '@onebun/core'; @Service() export class UserService extends BaseService { async findById(id: string): Promise { const response = await this.client.get(`/users/${id}`); if (isErrorResponse(response)) { if (response.code === 404) { throw new NotFoundError('User', id); } throw new InternalServerError(response.message); } return response.result; } } ``` ### Custom Error Handling ```typescript try { const response = await client.get('/users'); } catch (error) { if (error instanceof OneBunBaseError) { // Framework error console.error(error.toErrorResponse()); } else if (error instanceof Error) { // Generic error (network, timeout, etc.) console.error('Request failed:', error.message); } } ``` ## Request Configuration ```typescript interface RequestConfig { /** Request timeout in milliseconds */ timeout?: number; /** Custom headers */ headers?: Record; /** Query parameters */ params?: Record; /** Request body (for POST, PUT, PATCH) */ body?: unknown; /** Override retry config for this request */ retries?: RetryConfig; /** Override auth for this request */ auth?: AuthConfig; /** Custom fetch options */ fetchOptions?: RequestInit; } ``` ## HTTP Status Codes ```typescript import { HttpStatusCode } from '@onebun/core'; HttpStatusCode.OK // 200 HttpStatusCode.CREATED // 201 HttpStatusCode.NO_CONTENT // 204 HttpStatusCode.BAD_REQUEST // 400 HttpStatusCode.UNAUTHORIZED // 401 HttpStatusCode.FORBIDDEN // 403 HttpStatusCode.NOT_FOUND // 404 HttpStatusCode.CONFLICT // 409 HttpStatusCode.UNPROCESSABLE_ENTITY // 422 HttpStatusCode.INTERNAL_SERVER_ERROR // 500 HttpStatusCode.BAD_GATEWAY // 502 HttpStatusCode.SERVICE_UNAVAILABLE // 503 ``` ## Using in Services ```typescript import { Service, BaseService, createHttpClient } from '@onebun/core'; @Service() export class ExternalApiService extends BaseService { private client = createHttpClient({ baseUrl: 'https://api.external-service.com', auth: { type: 'bearer', token: process.env.EXTERNAL_API_TOKEN!, }, retries: { max: 3, backoff: 'exponential', delay: 1000, }, }); async fetchData(id: string): Promise { this.logger.debug('Fetching external data', { id }); const response = await this.client.get(`/data/${id}`); if (isErrorResponse(response)) { this.logger.error('External API error', { id, code: response.code, message: response.message, }); throw new Error(`External API error: ${response.message}`); } return response.result; } async createResource(data: CreateResourceDto): Promise { const response = await this.client.post('/resources', { body: data, }); if (isErrorResponse(response)) { throw new Error(response.message); } return response.result; } } ``` ## Service Client (Inter-service Communication) For type-safe inter-service communication: ```typescript import { createServiceDefinition, createServiceClient } from '@onebun/core'; // Define service API export const UsersServiceDefinition = createServiceDefinition({ name: 'users', controllers: { users: { findAll: { method: 'GET', path: '/users' }, findById: { method: 'GET', path: '/users/:id' }, create: { method: 'POST', path: '/users' }, update: { method: 'PUT', path: '/users/:id' }, delete: { method: 'DELETE', path: '/users/:id' }, }, }, }); // Create typed client const usersClient = createServiceClient(UsersServiceDefinition, { baseUrl: 'http://users-service:3001', auth: { type: 'onebun', serviceId: 'orders-service', secretKey: process.env.SERVICE_SECRET!, }, }); // Use with full type safety const users = await usersClient.users.findAll(); const user = await usersClient.users.findById({ id: '123' }); const newUser = await usersClient.users.create({ body: { name: 'John' } }); ``` ## Response Format All responses follow the standard format: ### Success Response ```typescript interface SuccessResponse { success: true; result: T; } ``` ### Error Response ```typescript interface ErrorResponse { success: false; code: number; message: string; details?: unknown; } ``` ## Complete Example ```typescript import { Service, BaseService, createHttpClient, isErrorResponse, NotFoundError, Span, } from '@onebun/core'; interface User { id: string; name: string; email: string; } interface CreateUserDto { name: string; email: string; } @Service() export class UserApiService extends BaseService { private client = createHttpClient({ baseUrl: process.env.USER_SERVICE_URL || 'http://localhost:3001', timeout: 10000, defaultHeaders: { 'Content-Type': 'application/json', }, auth: { type: 'onebun', serviceId: 'my-service', secretKey: process.env.SERVICE_SECRET!, }, retries: { max: 3, backoff: 'exponential', delay: 1000, factor: 2, retryOn: [408, 429, 500, 502, 503, 504], }, }); @Span('fetch-users') async findAll(page = 1, limit = 10): Promise { this.logger.debug('Fetching users', { page, limit }); const response = await this.client.get('/users', { params: { page, limit }, }); if (isErrorResponse(response)) { this.logger.error('Failed to fetch users', { code: response.code, message: response.message, }); throw new Error(response.message); } this.logger.info('Users fetched', { count: response.result.length }); return response.result; } @Span('fetch-user-by-id') async findById(id: string): Promise { const response = await this.client.get(`/users/${id}`); if (isErrorResponse(response)) { if (response.code === 404) { throw new NotFoundError('User', id); } throw new Error(response.message); } return response.result; } @Span('create-user') async create(data: CreateUserDto): Promise { this.logger.info('Creating user', { email: data.email }); const response = await this.client.post('/users', { body: data, }); if (isErrorResponse(response)) { this.logger.error('Failed to create user', { code: response.code, message: response.message, }); throw new Error(response.message); } this.logger.info('User created', { userId: response.result.id }); return response.result; } @Span('update-user') async update(id: string, data: Partial): Promise { const response = await this.client.patch(`/users/${id}`, { body: data, }); if (isErrorResponse(response)) { if (response.code === 404) { throw new NotFoundError('User', id); } throw new Error(response.message); } return response.result; } @Span('delete-user') async delete(id: string): Promise { const response = await this.client.delete(`/users/${id}`); if (isErrorResponse(response)) { if (response.code === 404) { throw new NotFoundError('User', id); } throw new Error(response.message); } this.logger.info('User deleted', { userId: id }); } } ``` --- --- url: /api/guards.md description: HTTP Guards — authorization and access control for routes and controllers. --- ## Quick Reference for AI **Guard interface:** ```typescript import { HttpGuard, HttpExecutionContext, createHttpGuard, UseGuards } from '@onebun/core'; ``` **Three ways to create a guard:** 1. `createHttpGuard(fn)` — inline function-based guard (simplest) 2. Implement `HttpGuard` interface (class-based, for DI) 3. Use built-in `AuthGuard`, `RolesGuard` **Applying guards:** * `@UseGuards(MyGuard)` on a controller class — applies to all routes * `@UseGuards(MyGuard)` on a route method — applies to that route only * Both can be combined; controller guards run first, then route guards **Order of execution:** global middleware → controller middleware → route middleware → guards → handler **If a guard returns `false` or rejects:** responds with `{ success: false, error: 'Forbidden', statusCode: 403 }` (HTTP 200, consistent with framework error response pattern). **HttpExecutionContext:** ```typescript context.getRequest() // OneBunRequest — full request object context.getHandler() // string — method name on the controller context.getController() // string — controller class name ``` **Built-in guards:** * `AuthGuard` — checks `Authorization: Bearer ` header presence * `RolesGuard` — checks comma-separated roles in `x-user-roles` header; configure with `new RolesGuard(['admin', 'user'])` # HTTP Guards Guards provide a way to implement authorization and access control for HTTP routes. They run **after** middleware but **before** the route handler. ## Interface ```typescript import type { HttpGuard, HttpExecutionContext } from '@onebun/core'; interface HttpGuard { canActivate(context: HttpExecutionContext): boolean | Promise; } interface HttpExecutionContext { getRequest(): OneBunRequest; // incoming request getHandler(): string; // name of the controller method being invoked getController(): string; // name of the controller class } ``` ## Creating Guards ### Function-based guard The simplest way — use the `createHttpGuard` factory: ```typescript import { createHttpGuard } from '@onebun/core'; const apiKeyGuard = createHttpGuard((ctx) => { return ctx.getRequest().headers.get('x-api-key') === process.env.API_KEY; }); ``` ### Class-based guard Implement the `HttpGuard` interface directly. Class-based guards benefit from DI — inject services through the constructor: ```typescript import type { HttpGuard, HttpExecutionContext } from '@onebun/core'; import { Service, BaseService } from '@onebun/core'; @Service() class ApiKeyGuard extends BaseService implements HttpGuard { constructor(private readonly configService: ConfigService) { super(); } canActivate(ctx: HttpExecutionContext): boolean { const key = ctx.getRequest().headers.get('x-api-key'); return key === this.configService.get('apiKey'); } } ``` ### Async guard `canActivate` may return a `Promise`: ```typescript const jwtGuard = createHttpGuard(async (ctx) => { const token = ctx.getRequest().headers.get('authorization')?.replace('Bearer ', ''); if (!token) return false; try { await verifyJwt(token); return true; } catch { return false; } }); ``` ## Applying Guards ### On a controller (all routes) ```typescript import { Controller, Get, UseGuards } from '@onebun/core'; import { AuthGuard } from '@onebun/core'; @UseGuards(AuthGuard) @Controller('/protected') class ProtectedController extends BaseController { @Get('/') index() { return { message: 'authenticated' }; } } ``` ### On a single route ```typescript @Controller('/resources') class ResourceController extends BaseController { @UseGuards(AuthGuard, new RolesGuard(['admin'])) @Delete('/:id') async delete(@Param('id') id: string) { // only accessible with Bearer token AND admin role } } ``` ### Combining controller + route guards Guards from both levels are merged and run sequentially — controller guards first, then route guards. ```typescript @UseGuards(AuthGuard) // applied to every route @Controller('/admin') class AdminController extends BaseController { @Get('/stats') getStats() { /* needs Bearer token only */ } @UseGuards(new RolesGuard(['admin'])) // additionally needs 'admin' role @Delete('/user/:id') deleteUser(@Param('id') id: string) { /* needs Bearer + admin role */ } } ``` ## Built-in Guards ### AuthGuard Checks for a `Authorization: Bearer ` header. Returns `false` if the header is missing or does not start with `Bearer `. ```typescript import { AuthGuard } from '@onebun/core'; @UseGuards(AuthGuard) @Controller('/secure') class SecureController extends BaseController { /* ... */ } ``` ### RolesGuard Reads a comma-separated list of roles from the `x-user-roles` request header and verifies that at least one required role is present. ```typescript import { RolesGuard, UseGuards } from '@onebun/core'; @UseGuards(new RolesGuard(['admin', 'moderator'])) @Delete('/post/:id') async deletePost(@Param('id') id: string) { /* ... */ } ``` **Custom role extractor:** ```typescript const guard = new RolesGuard( ['admin'], (ctx) => { // extract roles from JWT payload stored in header const payload = parseJwtPayload(ctx.getRequest().headers.get('authorization') ?? ''); return payload?.roles ?? []; }, ); ``` ## Guard Response When a guard returns `false`, the framework responds with HTTP 200 and a JSON error body (consistent with the framework's error envelope): ```json { "success": false, "error": "Forbidden", "statusCode": 403 } ``` ## Execution Order ``` Request → [Global Middleware] → [Module Middleware] → [Controller Middleware] → [Route Middleware] → [Controller Guards] → [Route Guards] → Route Handler → [Exception Filters on error] → Response ``` --- --- url: /api/logger.md description: >- Structured logging with SyncLogger. Log levels, JSON/pretty output, trace context, child loggers with context inheritance. --- # Logger API Package: `@onebun/logger` ## Overview OneBun provides a structured logging system with: * Multiple log levels * JSON or pretty console output * Trace context integration * Child loggers with context inheritance ## SyncLogger Interface ```typescript interface SyncLogger { trace(message: string, ...args: unknown[]): void; debug(message: string, ...args: unknown[]): void; info(message: string, ...args: unknown[]): void; warn(message: string, ...args: unknown[]): void; error(message: string, ...args: unknown[]): void; fatal(message: string, ...args: unknown[]): void; child(context: Record): SyncLogger; } ``` ## Log Levels | Level | Value | Use Case | |-------|-------|----------| | `trace` | 0 | Very detailed debugging | | `debug` | 1 | Debug information | | `info` | 2 | General information | | `warn` | 3 | Warnings | | `error` | 4 | Errors | | `fatal` | 5 | Fatal errors | ## Usage in Controllers/Services ```typescript @Controller('/users') export class UserController extends BaseController { @Get('/') async findAll(): Promise { // Logger is automatically available this.logger.info('Finding all users'); this.logger.debug('Request received', { timestamp: Date.now() }); try { const users = await this.userService.findAll(); this.logger.info('Users found', { count: users.length }); return this.success(users); } catch (error) { this.logger.error('Failed to find users', error); return this.error('Internal error', 500); } } } ``` ## Logging with Context ### Object Context ```typescript this.logger.info('User action', { userId: user.id, action: 'login', ip: request.ip, userAgent: request.headers['user-agent'], }); // Output (JSON): // {"level":"info","message":"User action","userId":"123","action":"login","ip":"192.168.1.1",...} ``` ### Error Logging ```typescript try { await this.riskyOperation(); } catch (error) { // Error objects are specially handled this.logger.error('Operation failed', error); // With additional context this.logger.error('Operation failed', error, { operationId: '123', userId: user.id, }); } // Output includes error name, message, and stack ``` ### Multiple Arguments ```typescript // Mix of arguments this.logger.debug( 'Processing request', requestData, // Object merged into context { step: 1 }, // Another object merged 'additional info', // String goes to additionalData 42, // Number goes to additionalData ); ``` ## Child Loggers Create child loggers with inherited context: ```typescript @Service() export class OrderService extends BaseService { async processOrder(orderId: string): Promise { // Create child logger with order context const orderLogger = this.logger.child({ orderId, operation: 'processOrder', }); orderLogger.info('Starting order processing'); // Logs: {"orderId":"123","operation":"processOrder","message":"Starting order processing",...} await this.validateOrder(orderId, orderLogger); await this.processPayment(orderId, orderLogger); orderLogger.info('Order processing completed'); } private async validateOrder(orderId: string, logger: SyncLogger): Promise { logger.debug('Validating order'); // Context (orderId, operation) is inherited } } ``` ## Logger Configuration ### Using loggerOptions (Recommended) Configure logging declaratively using `loggerOptions`: ```typescript const app = new OneBunApplication(AppModule, { loggerOptions: { minLevel: 'info', // 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'none' format: 'json', // 'json' | 'pretty' defaultContext: { service: 'user-service', version: '1.0.0', }, }, }); ``` ### Environment Variables Logger configuration can be controlled via environment variables: * `LOG_LEVEL` - Set minimum log level: `trace`, `debug`, `info`, `warn`/`warning`, `error`, `fatal`, `none` * `LOG_FORMAT` - Output format: `json` or `pretty` ```bash # Example: Set log level to info and format to JSON LOG_LEVEL=info LOG_FORMAT=json bun run start ``` ### Configuration Priority Configuration is resolved in this order (first wins): 1. `loggerLayer` (advanced, full control via Effect.js) 2. `loggerOptions` (declarative configuration) 3. Environment variables (`LOG_LEVEL`, `LOG_FORMAT`) 4. `NODE_ENV` defaults | NODE\_ENV | Default LOG\_LEVEL | Default LOG\_FORMAT | |----------|-------------------|-------------------| | production | info | json | | other | debug | pretty | ### Development vs Production Logger format is automatically selected based on `NODE_ENV`: ```typescript // NODE_ENV !== 'production' → Pretty console output // NODE_ENV === 'production' → JSON output // Manual override using loggerOptions const app = new OneBunApplication(AppModule, { loggerOptions: { minLevel: 'debug', format: 'pretty', }, }); // Or using loggerLayer (advanced) import { makeDevLogger, makeProdLogger } from '@onebun/logger'; const app = new OneBunApplication(AppModule, { loggerLayer: makeDevLogger({ minLevel: LogLevel.Debug, }), }); ``` ### Log Levels ```typescript // Using loggerOptions (recommended) const app = new OneBunApplication(AppModule, { loggerOptions: { minLevel: 'info', // Ignore trace and debug }, }); // Or using loggerLayer with LogLevel enum import { LogLevel, makeLogger } from '@onebun/logger'; const app = new OneBunApplication(AppModule, { loggerLayer: makeLogger({ minLevel: LogLevel.Info, }), }); ``` ### Custom Context ```typescript // Using loggerOptions const app = new OneBunApplication(AppModule, { loggerOptions: { defaultContext: { serviceName: 'user-service', version: '1.0.0', environment: process.env.NODE_ENV, }, }, }); // Or using makeLogger import { makeLogger } from '@onebun/logger'; const app = new OneBunApplication(AppModule, { loggerLayer: makeLogger({ defaultContext: { serviceName: 'user-service', version: '1.0.0', environment: process.env.NODE_ENV, }, }), }); // All logs will include serviceName, version, environment ``` ## Getting Logger from Application ```typescript const app = new OneBunApplication(AppModule, { envSchema }); await app.start(); // Get root logger const logger = app.getLogger(); logger.info('Application started'); // Get logger with context const bootstrapLogger = app.getLogger({ className: 'Bootstrap' }); bootstrapLogger.info('Bootstrapping complete'); ``` ## Output Formats ### Pretty Format (Development) ``` [2024-01-15T10:30:45.123Z] INFO [UserController] User created userId: "abc-123" email: "user@example.com" ``` ### JSON Format (Production) ```json { "level": "info", "message": "User created", "timestamp": "2024-01-15T10:30:45.123Z", "context": { "className": "UserController", "userId": "abc-123", "email": "user@example.com" } } ``` ## Enabling JSON Logging with Trace Context To get structured JSON logs with trace context for production observability, you need to: 1. Enable JSON output format 2. Enable tracing in the application ### Option 1: Via Application Options (Recommended) ```typescript import { OneBunApplication } from '@onebun/core'; import { AppModule } from './app.module'; const app = new OneBunApplication(AppModule, { port: 3000, // Enable JSON logging loggerOptions: { minLevel: 'info', format: 'json', }, // Enable tracing — trace context will appear in all logs tracing: { enabled: true, serviceName: 'user-service', traceHttpRequests: true, }, }); await app.start(); ``` ### Option 2: Via Environment Variables ```bash # Set log format and level LOG_LEVEL=info LOG_FORMAT=json # Run the application bun run src/index.ts ``` Tracing is enabled by default when `tracing.enabled` is set in app options or when the application detects `@onebun/trace` is installed. ### Combined JSON + Trace Output With both JSON logging and tracing enabled, every log entry during an HTTP request automatically includes the trace context: ```json { "timestamp": "2024-01-15T10:30:45.123Z", "level": "info", "message": "User created", "trace": { "traceId": "abc123def456789012345678", "spanId": "span123456789", "parentSpanId": "parent456789" }, "context": { "className": "UserController", "userId": "usr_123", "email": "user@example.com" } } ``` The `trace` field is automatically injected by the logger when a span is active. No code changes needed in your controllers or services — just use `this.logger` as usual. **Technical details for AI agents:** * `makeLogger()` selects formatter based on: `config.formatter` > `LOG_FORMAT` env > `NODE_ENV` (production=JSON, other=pretty) * `JsonFormatter.format()` checks `entry.trace` and adds `{ traceId, spanId, parentSpanId }` to output * Trace context is injected into log entries by the trace middleware when a span is active * `makeDevLogger()` forces pretty format + debug level * `makeProdLogger()` forces JSON format + info level * `makeLoggerFromOptions()` accepts `{ minLevel, format, defaultContext }` and creates the appropriate layer * Logger configuration priority: `loggerLayer` > `loggerOptions` > env vars > `NODE_ENV` defaults ## Trace Context Integration When tracing is enabled, logs automatically include trace context: ```json { "level": "info", "message": "Processing request", "timestamp": "2024-01-15T10:30:45.123Z", "trace": { "traceId": "abc123def456", "spanId": "span123", "parentSpanId": "parent456" } } ``` ## Effect.js Logger (Advanced) For Effect.js integration, use async Logger interface: ```typescript import { LoggerService, Logger } from '@onebun/logger'; import { Effect, pipe } from 'effect'; // Get logger in Effect context const program = pipe( LoggerService, Effect.flatMap((logger: Logger) => logger.info('Message from Effect') ), ); // Run with logger layer Effect.runPromise( Effect.provide(program, makeLogger()) ); ``` ## Testing ### Mock Logger ```typescript import { makeMockLoggerLayer } from '@onebun/core/testing'; describe('UserService', () => { it('should log user creation', async () => { const logs: Array<{ level: string; message: string }> = []; const mockLogger = makeMockLoggerLayer((entry) => { logs.push({ level: entry.level, message: entry.message }); }); // Use mock logger in tests const app = new OneBunApplication(AppModule, { loggerLayer: mockLogger, }); // ... test code ... expect(logs).toContainEqual({ level: 'info', message: expect.stringContaining('User created'), }); }); }); ``` ## Best Practices ### 1. Use Appropriate Log Levels ```typescript // trace: Very detailed, usually disabled this.logger.trace('Entering function', { args }); // debug: Useful for debugging this.logger.debug('Cache lookup', { key, hit: !!value }); // info: Normal operations this.logger.info('User logged in', { userId }); // warn: Potential issues this.logger.warn('Rate limit approaching', { current, limit }); // error: Errors that need attention this.logger.error('Database connection failed', error); // fatal: Critical errors this.logger.fatal('Application cannot start', error); ``` ### 2. Include Relevant Context ```typescript // Good: Includes useful context this.logger.info('Order placed', { orderId: order.id, customerId: order.customerId, total: order.total, itemCount: order.items.length, }); // Bad: Missing context this.logger.info('Order placed'); ``` ### 3. Use Child Loggers for Operations ```typescript async processRequest(requestId: string, userId: string) { const logger = this.logger.child({ requestId, userId }); logger.info('Request started'); // All subsequent logs include requestId and userId try { await this.step1(logger); await this.step2(logger); logger.info('Request completed'); } catch (error) { logger.error('Request failed', error); throw error; } } ``` ### 4. Don't Log Sensitive Data ```typescript // Bad: Logs password this.logger.info('User login', { email, password }); // Good: Omit sensitive fields this.logger.info('User login', { email }); // Or mask them this.logger.info('User login', { email, password: '***' }); ``` ### 5. Log at Entry/Exit Points ```typescript async processOrder(orderId: string): Promise { this.logger.info('Processing order started', { orderId }); try { const result = await this.doProcess(orderId); this.logger.info('Processing order completed', { orderId, duration: Date.now() - startTime, }); return result; } catch (error) { this.logger.error('Processing order failed', { orderId, error, }); throw error; } } ``` ## Complete Example ```typescript import { Service, BaseService, Span } from '@onebun/core'; @Service() export class PaymentService extends BaseService { @Span('process-payment') async processPayment(orderId: string, amount: number): Promise { const logger = this.logger.child({ orderId, amount, operation: 'processPayment', }); logger.info('Payment processing started'); try { // Validate logger.debug('Validating payment'); await this.validatePayment(amount); // Process logger.debug('Charging payment gateway'); const result = await this.gateway.charge({ orderId, amount, currency: 'USD', }); if (result.success) { logger.info('Payment successful', { transactionId: result.transactionId, }); } else { logger.warn('Payment declined', { reason: result.declineReason, }); } return result; } catch (error) { logger.error('Payment processing failed', error); throw error; } } private async validatePayment(amount: number): Promise { if (amount <= 0) { this.logger.warn('Invalid payment amount', { amount }); throw new Error('Amount must be positive'); } if (amount > 10000) { this.logger.warn('Large payment amount, additional verification required', { amount }); } } } ``` --- --- url: /api/metrics.md description: >- Prometheus-compatible metrics. @MeasureTime, @CountCalls decorators. HTTP, system, GC metrics. Custom counters, gauges, histograms. --- # Metrics API Package: `@onebun/metrics` ## Overview OneBun provides Prometheus-compatible metrics with: * Automatic HTTP request metrics * System metrics (CPU, memory, event loop) * Custom counters, gauges, and histograms ## Enabling Metrics ### In Application ```typescript import { OneBunApplication } from '@onebun/core'; import { AppModule } from './app.module'; const app = new OneBunApplication(AppModule, { metrics: { enabled: true, path: '/metrics', prefix: 'myapp_', collectHttpMetrics: true, collectSystemMetrics: true, collectGcMetrics: true, systemMetricsInterval: 5000, defaultLabels: { service: 'my-service', environment: process.env.NODE_ENV || 'development', }, }, }); ``` ### Configuration Options ```typescript interface MetricsOptions { /** Enable/disable metrics (default: true) */ enabled?: boolean; /** HTTP path for metrics endpoint (default: '/metrics') */ path?: string; /** Default labels for all metrics */ defaultLabels?: Record; /** Enable HTTP request metrics (default: true) */ collectHttpMetrics?: boolean; /** Enable system metrics (default: true) */ collectSystemMetrics?: boolean; /** Enable GC metrics (default: true) */ collectGcMetrics?: boolean; /** System metrics collection interval in ms (default: 5000) */ systemMetricsInterval?: number; /** Metric name prefix (default: 'onebun_') */ prefix?: string; /** HTTP request duration histogram buckets */ httpDurationBuckets?: number[]; } ``` ## Built-in Metrics ### HTTP Metrics ``` # Request duration histogram http_request_duration_seconds_bucket{method="GET",route="/api/users",status_code="200",le="0.1"} http_request_duration_seconds_sum{method="GET",route="/api/users",status_code="200"} http_request_duration_seconds_count{method="GET",route="/api/users",status_code="200"} # Request counter http_requests_total{method="GET",route="/api/users",status_code="200"} ``` ### System Metrics ``` # Process CPU process_cpu_seconds_total process_cpu_user_seconds_total process_cpu_system_seconds_total # Memory process_memory_bytes{type="rss"} process_memory_bytes{type="heapTotal"} process_memory_bytes{type="heapUsed"} process_memory_bytes{type="external"} # Event loop lag nodejs_eventloop_lag_seconds # Active handles and requests nodejs_active_handles_total nodejs_active_requests_total ``` ## Custom Metrics ### Accessing MetricsService ```typescript import { Service, BaseService } from '@onebun/core'; @Service() export class OrderService extends BaseService { private metricsService: any; constructor() { super(); // Get global metrics service this.metricsService = (globalThis as any).__onebunMetricsService; } } ``` ### Counter Track cumulative values that only increase. ```typescript // Create counter - returns prom-client Counter object const ordersCounter = this.metricsService.createCounter({ name: 'orders_created_total', help: 'Total number of orders created', labelNames: ['status', 'payment_method'], }); // Increment using prom-client API ordersCounter.inc({ status: 'completed', payment_method: 'credit_card' }); // Increment by value ordersCounter.inc({ status: 'completed', payment_method: 'credit_card' }, 5); ``` ### Gauge Track values that can go up and down. ```typescript // Create gauge - returns prom-client Gauge object const pendingGauge = this.metricsService.createGauge({ name: 'orders_pending', help: 'Number of pending orders', labelNames: ['priority'], }); // Set value using prom-client API pendingGauge.set({ priority: 'high' }, 42); // Increment pendingGauge.inc({ priority: 'high' }); // Decrement pendingGauge.dec({ priority: 'high' }); ``` ### Histogram Track distributions of values. ```typescript // Create histogram - returns prom-client Histogram object const processingHistogram = this.metricsService.createHistogram({ name: 'order_processing_duration_seconds', help: 'Order processing duration in seconds', labelNames: ['order_type'], buckets: [0.1, 0.5, 1, 2, 5, 10], }); // Observe value using prom-client API const startTime = Date.now(); // ... process order ... const duration = (Date.now() - startTime) / 1000; processingHistogram.observe({ order_type: 'standard' }, duration); ``` ## Decorator-based Metrics ### @Timed() Automatically time method execution. ```typescript import { Timed } from '@onebun/metrics'; @Service() export class OrderService extends BaseService { @Timed('order_processing_duration_seconds', { order_type: 'standard' }) async processOrder(orderId: string): Promise { // Method execution time is automatically recorded return this.doProcess(orderId); } } ``` ### @Counted() Automatically count method calls. ```typescript import { Counted } from '@onebun/metrics'; @Service() export class EmailService extends BaseService { @Counted('emails_sent_total', { type: 'transactional' }) async sendEmail(to: string, subject: string): Promise { // Counter incremented on each call await this.smtp.send({ to, subject }); } } ``` ## Service Metrics Pattern ```typescript import type { Counter, Gauge, Histogram } from 'prom-client'; @Service() export class PaymentService extends BaseService { private metricsService: any; private paymentsCounter?: Counter; private processingHistogram?: Histogram; private queueGauge?: Gauge; constructor() { super(); this.metricsService = (globalThis as any).__onebunMetricsService; // Register custom metrics on service init this.registerMetrics(); } private registerMetrics(): void { if (!this.metricsService) return; // Store references to metric objects for later use this.paymentsCounter = this.metricsService.createCounter({ name: 'payments_processed_total', help: 'Total number of payments processed', labelNames: ['status', 'method'], }); this.processingHistogram = this.metricsService.createHistogram({ name: 'payment_processing_seconds', help: 'Payment processing duration', buckets: [0.1, 0.5, 1, 2, 5, 10], }); this.queueGauge = this.metricsService.createGauge({ name: 'payment_queue_size', help: 'Current payment queue size', }); } async processPayment(payment: Payment): Promise { const startTime = Date.now(); try { const result = await this.gateway.charge(payment); // Record success using prom-client API this.paymentsCounter?.inc({ status: 'success', method: payment.method }); return result; } catch (error) { // Record failure this.paymentsCounter?.inc({ status: 'failure', method: payment.method }); throw error; } finally { // Record duration const duration = (Date.now() - startTime) / 1000; this.processingHistogram?.observe(duration); } } updateQueueSize(size: number): void { this.queueGauge?.set(size); } } ``` ## Metrics Endpoint Access metrics at the configured path (default `/metrics`): ```bash curl http://localhost:3000/metrics ``` **Response (Prometheus format):** ``` # HELP http_request_duration_seconds HTTP request duration in seconds # TYPE http_request_duration_seconds histogram http_request_duration_seconds_bucket{method="GET",route="/api/users",status_code="200",le="0.1"} 42 http_request_duration_seconds_bucket{method="GET",route="/api/users",status_code="200",le="0.5"} 87 http_request_duration_seconds_bucket{method="GET",route="/api/users",status_code="200",le="1"} 95 http_request_duration_seconds_sum{method="GET",route="/api/users",status_code="200"} 12.45 http_request_duration_seconds_count{method="GET",route="/api/users",status_code="200"} 95 # HELP http_requests_total Total number of HTTP requests # TYPE http_requests_total counter http_requests_total{method="GET",route="/api/users",status_code="200"} 95 # HELP process_memory_bytes Process memory usage in bytes # TYPE process_memory_bytes gauge process_memory_bytes{type="rss"} 52428800 process_memory_bytes{type="heapTotal"} 20971520 process_memory_bytes{type="heapUsed"} 15728640 ``` ## Prometheus Integration ### prometheus.yml ```yaml global: scrape_interval: 15s scrape_configs: - job_name: 'onebun-app' static_configs: - targets: ['localhost:3000'] metrics_path: '/metrics' ``` ### Grafana Dashboard Common queries for Grafana: ```promql # Request rate (requests per second) rate(http_requests_total[5m]) # Request duration 95th percentile histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])) # Error rate sum(rate(http_requests_total{status_code=~"5.."}[5m])) / sum(rate(http_requests_total[5m])) # Memory usage process_memory_bytes{type="heapUsed"} # Custom metric rate(payments_processed_total{status="success"}[5m]) ``` ## Complete Example ```typescript import { Module, Controller, BaseController, Service, BaseService, Get, Post, Body } from '@onebun/core'; import type { Counter, Gauge, Histogram } from 'prom-client'; // Service with custom metrics @Service() export class AnalyticsService extends BaseService { private metricsService: any; private eventsCounter?: Counter; private processingHistogram?: Histogram; private queueGauge?: Gauge; constructor() { super(); this.metricsService = (globalThis as any).__onebunMetricsService; this.initMetrics(); } private initMetrics(): void { if (!this.metricsService) return; // Store references to prom-client metric objects this.eventsCounter = this.metricsService.createCounter({ name: 'analytics_events_total', help: 'Total analytics events', labelNames: ['event_type', 'source'], }); this.processingHistogram = this.metricsService.createHistogram({ name: 'analytics_processing_seconds', help: 'Analytics event processing time', buckets: [0.001, 0.005, 0.01, 0.05, 0.1], }); this.queueGauge = this.metricsService.createGauge({ name: 'analytics_queue_depth', help: 'Current queue depth', }); } async trackEvent(eventType: string, source: string, data: unknown): Promise { const startTime = performance.now(); try { // Process event await this.processEvent(eventType, data); // Record metrics using prom-client API this.eventsCounter?.inc({ event_type: eventType, source }); } finally { const duration = (performance.now() - startTime) / 1000; this.processingHistogram?.observe(duration); } } updateQueueDepth(depth: number): void { this.queueGauge?.set(depth); } private async processEvent(eventType: string, data: unknown): Promise { // Event processing logic this.logger.debug('Processing event', { eventType }); } } // Controller @Controller('/analytics') export class AnalyticsController extends BaseController { constructor(private analyticsService: AnalyticsService) { super(); } @Post('/track') async track(@Body() body: { event: string; source: string; data?: unknown }): Promise { await this.analyticsService.trackEvent(body.event, body.source, body.data); return this.success({ tracked: true }); } } // Module @Module({ controllers: [AnalyticsController], providers: [AnalyticsService], }) export class AnalyticsModule {} ``` --- --- url: /examples/multi-service.md description: >- Running multiple microservices from single process. MultiServiceApplication, shared config, service communication. --- # Multi-Service Application Example Running multiple microservices from a single process with shared configuration. ## Project Structure ``` multi-service/ ├── src/ │ ├── index.ts │ ├── config.ts │ ├── shared/ │ │ └── database.module.ts │ ├── users/ │ │ ├── users.module.ts │ │ ├── users.controller.ts │ │ └── users.service.ts │ └── orders/ │ ├── orders.module.ts │ ├── orders.controller.ts │ └── orders.service.ts ├── .env ├── package.json └── tsconfig.json ``` ## src/config.ts ```typescript import { Env } from '@onebun/core'; export const envSchema = { // Shared configuration app: { name: Env.string({ default: 'multi-service' }), environment: Env.string({ default: 'development' }), }, // Users service users: { port: Env.number({ default: 3001, env: 'USERS_PORT' }), database: { url: Env.string({ env: 'USERS_DATABASE_URL', sensitive: true }), }, }, // Orders service orders: { port: Env.number({ default: 3002, env: 'ORDERS_PORT' }), database: { url: Env.string({ env: 'ORDERS_DATABASE_URL', sensitive: true }), }, }, // Shared Redis redis: { host: Env.string({ default: 'localhost' }), port: Env.number({ default: 6379 }), }, }; ``` ## src/users/users.service.ts ```typescript import { Service, BaseService, Span } from '@onebun/core'; interface User { id: string; name: string; email: string; } @Service() export class UserService extends BaseService { private users = new Map(); constructor() { super(); // Seed some data this.users.set('1', { id: '1', name: 'Alice', email: 'alice@example.com' }); this.users.set('2', { id: '2', name: 'Bob', email: 'bob@example.com' }); } @Span('user-find-all') async findAll(): Promise { this.logger.info('Finding all users'); return Array.from(this.users.values()); } @Span('user-find-by-id') async findById(id: string): Promise { return this.users.get(id) || null; } @Span('user-create') async create(data: Omit): Promise { const user: User = { id: crypto.randomUUID(), ...data, }; this.users.set(user.id, user); this.logger.info('User created', { userId: user.id }); return user; } } ``` ## src/users/users.controller.ts ```typescript import { Controller, BaseController, Get, Post, Param, Body } from '@onebun/core'; import { type } from 'arktype'; import { UserService } from './users.service'; const createUserSchema = type({ name: 'string', email: 'string.email', }); @Controller('/users') export class UserController extends BaseController { constructor(private userService: UserService) { super(); } @Get('/') async findAll(): Promise { const users = await this.userService.findAll(); return this.success(users); } @Get('/:id') async findOne(@Param('id') id: string): Promise { const user = await this.userService.findById(id); if (!user) { return this.error('User not found', 404, 404); } return this.success(user); } @Post('/') async create( @Body(createUserSchema) body: typeof createUserSchema.infer, ): Promise { const user = await this.userService.create(body); return this.success(user, 201); } } ``` ## src/users/users.module.ts ```typescript import { Module } from '@onebun/core'; import { UserController } from './users.controller'; import { UserService } from './users.service'; @Module({ controllers: [UserController], providers: [UserService], exports: [UserService], }) export class UserModule {} ``` ## src/orders/orders.service.ts ```typescript import { Service, BaseService, Span, createHttpClient, isErrorResponse } from '@onebun/core'; interface Order { id: string; userId: string; items: Array<{ productId: string; quantity: number }>; total: number; status: 'pending' | 'completed' | 'cancelled'; createdAt: string; } interface User { id: string; name: string; email: string; } @Service() export class OrderService extends BaseService { private orders = new Map(); // HTTP client for calling Users service private usersClient = createHttpClient({ baseUrl: process.env.USERS_SERVICE_URL || 'http://localhost:3001', }); @Span('order-find-all') async findAll(): Promise { return Array.from(this.orders.values()); } @Span('order-find-by-user') async findByUserId(userId: string): Promise { return Array.from(this.orders.values()).filter( (order) => order.userId === userId ); } @Span('order-create') async create(data: { userId: string; items: Array<{ productId: string; quantity: number; price: number }>; }): Promise { // Verify user exists by calling Users service const userResponse = await this.usersClient.get(`/users/${data.userId}`); if (isErrorResponse(userResponse)) { this.logger.warn('User not found', { userId: data.userId }); throw new Error('User not found'); } const user = userResponse.result; this.logger.info('User verified', { userId: user.id, name: user.name }); // Calculate total const total = data.items.reduce( (sum, item) => sum + item.quantity * item.price, 0 ); const order: Order = { id: crypto.randomUUID(), userId: data.userId, items: data.items.map(({ productId, quantity }) => ({ productId, quantity })), total, status: 'pending', createdAt: new Date().toISOString(), }; this.orders.set(order.id, order); this.logger.info('Order created', { orderId: order.id, userId: data.userId, total, }); return order; } @Span('order-update-status') async updateStatus( orderId: string, status: 'pending' | 'completed' | 'cancelled', ): Promise { const order = this.orders.get(orderId); if (!order) { return null; } order.status = status; this.orders.set(orderId, order); this.logger.info('Order status updated', { orderId, status }); return order; } } ``` ## src/orders/orders.controller.ts ```typescript import { Controller, BaseController, Get, Post, Put, Param, Body, Query } from '@onebun/core'; import { type } from 'arktype'; import { OrderService } from './orders.service'; const createOrderSchema = type({ userId: 'string', items: type({ productId: 'string', quantity: 'number > 0', price: 'number > 0', }).array().configure({ minLength: 1 }), }); const updateStatusSchema = type({ status: '"pending" | "completed" | "cancelled"', }); @Controller('/orders') export class OrderController extends BaseController { constructor(private orderService: OrderService) { super(); } @Get('/') async findAll(@Query('userId') userId?: string): Promise { if (userId) { const orders = await this.orderService.findByUserId(userId); return this.success(orders); } const orders = await this.orderService.findAll(); return this.success(orders); } @Post('/') async create( @Body(createOrderSchema) body: typeof createOrderSchema.infer, ): Promise { try { const order = await this.orderService.create(body); return this.success(order, 201); } catch (error) { if (error instanceof Error && error.message === 'User not found') { return this.error('User not found', 404, 404); } throw error; } } @Put('/:id/status') async updateStatus( @Param('id') id: string, @Body(updateStatusSchema) body: typeof updateStatusSchema.infer, ): Promise { const order = await this.orderService.updateStatus(id, body.status); if (!order) { return this.error('Order not found', 404, 404); } return this.success(order); } } ``` ## src/orders/orders.module.ts ```typescript import { Module } from '@onebun/core'; import { OrderController } from './orders.controller'; import { OrderService } from './orders.service'; @Module({ controllers: [OrderController], providers: [OrderService], }) export class OrderModule {} ``` ## src/index.ts ```typescript import { MultiServiceApplication } from '@onebun/core'; import { UserModule } from './users/users.module'; import { OrderModule } from './orders/orders.module'; import { envSchema } from './config'; const app = new MultiServiceApplication({ services: { users: { module: UserModule, port: 3001, routePrefix: true, // Uses 'users' as route prefix }, orders: { module: OrderModule, port: 3002, routePrefix: true, // Uses 'orders' as route prefix // Orders service can have different env overrides envOverrides: { // Use different database for orders 'database.url': { fromEnv: 'ORDERS_DATABASE_URL' }, }, }, }, envSchema, envOptions: { loadDotEnv: true, }, metrics: { enabled: true, prefix: 'multiservice_', }, tracing: { enabled: true, serviceName: 'multi-service-app', }, // Optional: only start specific services // enabledServices: ['users'], // excludedServices: ['orders'], }); app.start().then(() => { const logger = app.getLogger(); logger.info('Multi-service application started'); logger.info('Running services:', app.getRunningServices()); logger.info('Users service: http://localhost:3001'); logger.info('Orders service: http://localhost:3002'); }).catch((error) => { console.error('Failed to start:', error); process.exit(1); }); ``` ## .env ```bash # App APP_NAME=multi-service NODE_ENV=development # Users Service USERS_PORT=3001 USERS_DATABASE_URL=postgres://localhost:5432/users_db # Orders Service ORDERS_PORT=3002 ORDERS_DATABASE_URL=postgres://localhost:5432/orders_db # Users service URL (for Orders to call) USERS_SERVICE_URL=http://localhost:3001 # Shared Redis REDIS_HOST=localhost REDIS_PORT=6379 ``` ## Testing the Services ```bash # Users Service (port 3001) # List users curl http://localhost:3001/users/users # Get user curl http://localhost:3001/users/users/1 # Create user curl -X POST http://localhost:3001/users/users \ -H "Content-Type: application/json" \ -d '{"name": "Charlie", "email": "charlie@example.com"}' # Orders Service (port 3002) # List orders curl http://localhost:3002/orders/orders # Create order (verifies user exists via Users service) curl -X POST http://localhost:3002/orders/orders \ -H "Content-Type: application/json" \ -d '{ "userId": "1", "items": [ {"productId": "prod-1", "quantity": 2, "price": 29.99}, {"productId": "prod-2", "quantity": 1, "price": 49.99} ] }' # Get user's orders curl http://localhost:3002/orders/orders?userId=1 # Update order status curl -X PUT http://localhost:3002/orders/orders/{orderId}/status \ -H "Content-Type: application/json" \ -d '{"status": "completed"}' ``` ## Graceful Shutdown `MultiServiceApplication` supports graceful shutdown out of the box. When the process receives SIGTERM or SIGINT, it calls `stop()` on each running service, which triggers lifecycle hooks in order. ### Shutdown Sequence 1. `beforeApplicationDestroy(signal)` — called on all services/controllers with the signal name 2. WebSocket connections closed 3. Queue service stopped, queue adapter disconnected 4. HTTP servers stopped 5. `onModuleDestroy()` — called on all services/controllers 6. Shared Redis disconnected (if configured) 7. `onApplicationDestroy(signal)` — final cleanup hook ### Implementing Lifecycle Hooks Services and controllers can implement lifecycle hooks to clean up resources: ```typescript import { Service, BaseService } from '@onebun/core'; import type { OnModuleInit, OnModuleDestroy, BeforeApplicationDestroy } from '@onebun/core'; @Service() export class OrderService extends BaseService implements OnModuleInit, OnModuleDestroy, BeforeApplicationDestroy { private cleanupInterval: ReturnType | null = null; // Called after DI resolution — set up resources async onModuleInit(): Promise { this.logger.info('OrderService initialized'); // Start a periodic cleanup task this.cleanupInterval = setInterval(() => { this.cleanupExpiredOrders(); }, 60000); } // Called at the start of shutdown — stop accepting new work async beforeApplicationDestroy(signal?: string): Promise { this.logger.info('Shutdown signal received, stopping new order processing', { signal }); // Stop accepting new orders, finish in-progress work } // Called during shutdown — clean up resources async onModuleDestroy(): Promise { this.logger.info('Cleaning up OrderService'); if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } // Close database connections, flush buffers, etc. } private cleanupExpiredOrders(): void { // periodic cleanup logic } } ``` ### Programmatic Shutdown ```typescript const app = new MultiServiceApplication({ services: { users: { module: UserModule, port: 3001 }, orders: { module: OrderModule, port: 3002 }, }, envSchema, }); await app.start(); // Programmatic stop — all services shut down gracefully await app.stop(); ``` ::: tip `MultiServiceApplication.stop()` calls `stop()` on each `OneBunApplication` instance. Individual `OneBunApplication.stop()` accepts `{ closeSharedRedis?: boolean; signal?: string }` if you need more control when stopping services directly. ::: ### Lifecycle Hook Reference | Interface | Method | When Called | |-----------|--------|------------| | `OnModuleInit` | `onModuleInit()` | After DI resolution, before HTTP server starts | | `OnApplicationInit` | `onApplicationInit()` | After all modules initialized, before HTTP server starts | | `BeforeApplicationDestroy` | `beforeApplicationDestroy(signal?)` | Start of shutdown (stop accepting work) | | `OnModuleDestroy` | `onModuleDestroy()` | During shutdown, after HTTP server stops | | `OnApplicationDestroy` | `onApplicationDestroy(signal?)` | End of shutdown (final cleanup) | ## Key Patterns 1. **MultiServiceApplication**: Run multiple services in one process 2. **Service Isolation**: Each service has its own module, port, and route prefix 3. **Environment Overrides**: Per-service environment variable customization 4. **Inter-service Communication**: Use `createHttpClient` to call other services 5. **Shared Configuration**: Common settings via `envSchema` 6. **Trace Propagation**: Traces automatically flow between services 7. **Metrics Aggregation**: All services expose metrics on their respective ports 8. **Graceful Shutdown**: Lifecycle hooks for clean resource management ## Production: Service Selection via Environment `MultiServiceApplication` has built-in support for selecting which services to run via environment variables. No need for separate entry files — use the same code everywhere. ### Built-in Options ```typescript interface MultiServiceApplicationOptions { // Queue config applied to every service (same broker/config for all) queue?: QueueApplicationOptions; // List of service names to start (if set, only these services run) enabledServices?: string[]; // List of service names to exclude from starting excludedServices?: string[]; // URLs for services running in other processes (for inter-service calls) externalServiceUrls?: Record; } ``` ### Environment Variables * `ONEBUN_SERVICES` — comma-separated list of services to start (overrides `enabledServices`) * `ONEBUN_EXCLUDE_SERVICES` — comma-separated list of services to exclude (overrides `excludedServices`) ### Single Entry Point for All Deployments ```typescript // src/index.ts - same file for dev and production import { MultiServiceApplication } from '@onebun/core'; import { UserModule } from './users/users.module'; import { OrderModule } from './orders/orders.module'; import { envSchema } from './config'; const app = new MultiServiceApplication({ services: { users: { module: UserModule, port: 3001, routePrefix: true }, orders: { module: OrderModule, port: 3002, routePrefix: true }, }, envSchema, // URLs for services when they run in separate processes // Used by getServiceUrl() for inter-service communication externalServiceUrls: { users: process.env.USERS_SERVICE_URL, orders: process.env.ORDERS_SERVICE_URL, }, }); app.start(); ``` ### Deployment Examples ```bash # Development: run all services in one process bun run src/index.ts # Production: run only users service ONEBUN_SERVICES=users bun run src/index.ts # Production: run only orders service ONEBUN_SERVICES=orders bun run src/index.ts # Run all except orders ONEBUN_EXCLUDE_SERVICES=orders bun run src/index.ts # Multiple services ONEBUN_SERVICES=users,orders bun run src/index.ts ``` ### Kubernetes Deployment ```yaml # users-deployment.yaml apiVersion: apps/v1 kind: Deployment metadata: name: users-service spec: template: spec: containers: - name: app image: myapp:latest env: - name: ONEBUN_SERVICES value: "users" - name: ORDERS_SERVICE_URL value: "http://orders-service:3002" --- # orders-deployment.yaml apiVersion: apps/v1 kind: Deployment metadata: name: orders-service spec: template: spec: containers: - name: app image: myapp:latest env: - name: ONEBUN_SERVICES value: "orders" - name: USERS_SERVICE_URL value: "http://users-service:3001" ``` ### Inter-Service Communication When a service runs in a separate process, use `externalServiceUrls` to configure URLs: ```typescript // In orders service, calling users service const usersClient = createHttpClient({ baseUrl: app.getServiceUrl('users'), // Returns externalServiceUrls.users or local URL }); const user = await usersClient.get(`/users/${userId}`); ``` --- --- url: /architecture.md description: >- System architecture overview. Module hierarchy, DI container, request lifecycle, Effect.js integration patterns. --- ## Internal Architecture Notes **DI Resolution Order**: 1. Module imports resolved first (depth-first) 2. Providers instantiated in declaration order 3. Controllers receive injected services via constructor (from the same module's providers and imported modules' exports) 4. **Exports are only required for cross-module injection.** Within a module, any provider can be injected into controllers and other providers without being listed in `exports`. **Lifecycle Hooks Order**: 1. Services created (ambient init context provides logger/config to `BaseService` constructor — `this.config` and `this.logger` are available after `super()`) → `onModuleInit()` called on each service (sequentially in dependency order; called for all providers, even standalone ones not injected anywhere) 2. Controllers created → `onModuleInit()` called on each controller 3. All modules ready → `onApplicationInit()` called (before HTTP server starts) 4. Shutdown signal → `beforeApplicationDestroy(signal?)` called 5. HTTP server stops → `onModuleDestroy()` called 6. Cleanup complete → `onApplicationDestroy(signal?)` called **Accessing Services Outside Requests**: * `app.getService(ServiceClass)` - returns service instance by class **Effect.js Usage**: * Framework internals use Effect.pipe for composition * Services can use Effect for complex async flows * Application code typically uses Promise wrappers * Layer system manages service lifecycles **Request Flow**: 1. Bun.serve receives HTTP request 2. TraceMiddleware adds trace context 3. Middleware chain executes (global → module → controller → route) 4. Parameter decorators extract @Param, @Query, @Body 5. Controller method executes with injected services 6. Response serialized (this.success/this.error or direct return) 7. MetricsMiddleware records metrics **Module Metadata Storage**: * Reflect.metadata stores decorator info * MODULE\_METADATA\_KEY for @Module options * CONTROLLER\_METADATA\_KEY for route paths * METHOD\_METADATA\_KEY for HTTP methods * PARAM\_METADATA\_KEY for parameter extraction # OneBun Architecture ## Overview OneBun is built on three pillars: 1. **NestJS-inspired DI** - Decorators for modules, controllers, services 2. **Effect.js** - Type-safe side effect management internally 3. **Bun.js** - Fast runtime with native TypeScript support ## Component Hierarchy ``` OneBunApplication │ ├── OneBunModule (Root Module) │ ├── Controllers[] │ │ └── Routes[] (from decorators) │ ├── Services[] (Providers) │ └── Child Modules[] │ └── (recursive structure) │ ├── Logger (SyncLogger) ├── ConfigService ├── MetricsService (optional) └── TraceService (optional) ``` ## Dependency Injection System ### How DI Works 1. **Service Registration**: `@Service()` decorator registers class with Effect.js Context tag 2. **Module Assembly**: `@Module()` collects controllers and providers 3. **Dependency Resolution**: Framework analyzes constructor parameters 4. **Instance Creation**: Services created in dependency order, then controllers ### DI Resolution Flow ```typescript // 1. Service is decorated @Service() export class UserService extends BaseService { // Dependencies are injected via constructor // Logger and config are injected automatically constructor(private cacheService: CacheService) { super(); } } // 2. Module declares dependencies @Module({ imports: [CacheModule], // provides CacheService providers: [UserService], controllers: [UserController], }) export class UserModule {} // 3. At startup, framework: // a. Creates CacheService first (from imported module) // b. Sets ambient init context (logger + config) // c. Creates UserService with CacheService injected // (config and logger available in constructor after super()) // d. Clears init context // e. Creates UserController with UserService injected ``` ### Auto-Detection Algorithm The framework uses multiple strategies to detect dependencies: ```typescript // Priority 1: TypeScript design:paramtypes (when emitDecoratorMetadata is true) const designTypes = Reflect.getMetadata('design:paramtypes', target); // Priority 2: Constructor source code analysis (fallback) // Matches patterns like: "private userService: UserService" const constructorStr = target.toString(); const typeMatch = param.match(/:\s*([A-Za-z][A-Za-z0-9]*)/); // Note: Classes MUST have a decorator (@Service, @Controller, etc.) for // design:paramtypes to be emitted. Without a decorator, DI will not work. ``` ### Automatic DI (Recommended) In most cases, dependencies are injected automatically without any decorator: ```typescript @Controller('/users') export class UserController extends BaseController { constructor( private userService: UserService, // Automatically injected private cache: CacheService, // Automatically injected ) { super(); } } ``` ### Explicit Injection (Edge Cases) Use `@Inject()` only when automatic DI cannot resolve the dependency: ```typescript import { Inject } from '@onebun/core'; @Controller('/users') export class UserController extends BaseController { constructor( // Use @Inject for: // - Interface/abstract class injection // - Token-based injection // - Overriding automatic resolution @Inject(UserService) private userService: UserService, ) { super(); } } ``` ## Effect.js Integration ### Internal vs External API | Layer | API Style | Why | |-------|-----------|-----| | Framework internals | Effect.js | Composable error handling, resource management | | Client code (controllers, services) | Promises | Simpler, familiar API | | Advanced use cases | Both | `Effect.runPromise()` bridge | ### Effect.js in Module System ```typescript // Module initialization returns Effect setup(): Effect.Effect { return this.createControllerInstances(); } // Layer composition for DI const layer = Layer.merge( Layer.succeed(ServiceTag, serviceInstance), loggerLayer, ); ``` ### Using Effect.js in Services ```typescript @Service() export class DataService extends BaseService { // Promise-based (recommended for most cases) async fetchData(): Promise { return await fetch('/api/data').then(r => r.json()); } // Effect-based (for advanced composition) fetchDataEffect(): Effect.Effect { return pipe( Effect.tryPromise({ try: () => fetch('/api/data'), catch: (e) => new FetchError(e), }), Effect.flatMap((response) => Effect.tryPromise(() => response.json()) ), ); } // Bridge: use Effect internally, expose Promise async fetchWithRetry(): Promise { const effect = pipe( this.fetchDataEffect(), Effect.retry({ times: 3 }), ); return Effect.runPromise(effect); } } ``` ## Request Lifecycle ``` HTTP Request │ ▼ ┌─────────────────────────────────────────────┐ │ Bun.serve fetch handler │ │ ├── Extract trace headers │ │ ├── Start trace span (if enabled) │ │ └── Match route │ └─────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────┐ │ Middleware Chain │ │ ├── Global middleware (ApplicationOptions) │ │ ├── Module middleware (OnModuleConfigure) │ │ ├── Controller middleware (@UseMiddleware) │ │ └── Route middleware (@UseMiddleware) │ └─────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────┐ │ Parameter Extraction & Validation │ │ ├── Path params (@Param) │ │ ├── Query params (@Query) │ │ ├── Body (@Body) with ArkType validation │ │ └── Headers (@Header) │ └─────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────┐ │ Controller Handler │ │ ├── Execute handler method │ │ └── Return Response │ └─────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────┐ │ Response Processing │ │ ├── Response validation (if schema) │ │ ├── Record metrics │ │ ├── End trace span │ │ └── Return to client │ └─────────────────────────────────────────────┘ ``` ## Module System ### Module Types 1. **Root Module**: Entry point, contains app-level configuration 2. **Feature Modules**: Domain-specific (UserModule, OrderModule) 3. **Infrastructure Modules**: Cross-cutting (CacheModule, DrizzleModule) ### Module Lifecycle **Initialization Phase:** ```typescript // Phase 1: Import child modules and collect exported services if (metadata.imports) { for (const importModule of metadata.imports) { const childModule = new OneBunModule(importModule, loggerLayer, config); // Merge layers, collect exported services } } // Phase 2: Create this module's services with DI this.createServicesWithDI(metadata); // Phase 3: Call onModuleInit() on services that implement it // (sequentially in dependency order — dependencies complete before dependents) // Recursively traverses ALL modules in the import tree (depth-first) await this.callServicesOnModuleInit(); // root + all descendants // Phase 4: Create controllers with injected services (all modules) this.createControllersWithDI(); // Phase 5: Call onModuleInit() on controllers that implement it (all modules) await this.callControllersOnModuleInit(); // Phase 6: After all modules ready, call onApplicationInit() (before HTTP server) await this.callOnApplicationInit(); ``` **Shutdown Phase:** ```typescript // Phase 1: Call beforeApplicationDestroy(signal) on all services and controllers await this.callBeforeApplicationDestroy(signal); // Phase 2: HTTP server stops (handled by application) // Phase 3: Call onModuleDestroy() on all services and controllers await this.callOnModuleDestroy(); // Phase 4: Call onApplicationDestroy(signal) on all services and controllers await this.callOnApplicationDestroy(signal); ``` ### Lifecycle Hooks Services and controllers can implement lifecycle hooks by importing the interfaces. See [Services API — Lifecycle Hooks](/api/services#lifecycle-hooks) for the full reference, execution order, and standalone service patterns. ```typescript import { OnModuleInit, OnApplicationInit, OnModuleDestroy, BeforeApplicationDestroy, OnApplicationDestroy } from '@onebun/core'; @Service() export class DatabaseService extends BaseService implements OnModuleInit, OnModuleDestroy { private connection: Connection | null = null; async onModuleInit(): Promise { this.connection = await createConnection(this.config.database.url); this.logger.info('Database connected'); } async onModuleDestroy(): Promise { await this.connection?.close(); this.logger.info('Database disconnected'); } } ``` ### Service Export/Import **Exports are only needed when a module is imported by another.** Within a module, all of its providers are automatically available to its controllers and to other providers in the same module; you do not need to list them in `exports`. ```typescript // CacheModule exports CacheService so that importing modules can use it @Module({ providers: [CacheService], exports: [CacheService], }) export class CacheModule {} // UserModule imports and uses CacheService @Module({ imports: [CacheModule], providers: [UserService], // can inject CacheService (imported) }) export class UserModule {} ``` Example without exports (same-module only): a module with `providers: [MyService]` and `controllers: [MyController]` can inject `MyService` into `MyController` without listing `MyService` in `exports`. ## Controller Architecture ### Route Registration ```typescript @Controller('/users') export class UserController extends BaseController { @Get('/') // GET /users/ @Get('/:id') // GET /users/:id @Post('/') // POST /users/ @Put('/:id') // PUT /users/:id @Delete('/:id') // DELETE /users/:id } ``` ### Metadata Storage ```typescript // Stored in META_CONTROLLERS Map const metadata: ControllerMetadata = { path: '/users', routes: [ { path: '/', method: HttpMethod.GET, handler: 'findAll', params: [], middleware: [], }, { path: '/:id', method: HttpMethod.GET, handler: 'findOne', params: [{ type: ParamType.PATH, name: 'id', index: 0 }], }, ], }; ``` ## Service Architecture ### BaseService Features ```typescript export class BaseService { protected logger: SyncLogger; // Available after super() in constructor protected config: unknown; // Available after super() in constructor // Logger and config are injected via ambient init context, // making them available immediately after super() in subclass constructors. // initializeService() is kept as a fallback for manual/test usage. } // Lifecycle hooks are optional - implement the interface to use: // onModuleInit is called for ALL providers, even standalone services // not injected anywhere. Hooks run sequentially in dependency order. @Service() class MyService extends BaseService implements OnModuleInit { async onModuleInit(): Promise { // Called after construction and DI injection // All dependencies' onModuleInit have already completed } } ``` ### Service Layer Pattern ```typescript @Service() export class UserService extends BaseService { // Dependencies injected via constructor // Logger and config available immediately after super() constructor( private repository: UserRepository, // Data access private cacheService: CacheService, // Cross-cutting ) { super(); // this.config and this.logger are available here } async findById(id: string): Promise { // Check cache first const cached = await this.cacheService.get(`user:${id}`); if (cached) return cached; // Fetch from database const user = await this.repository.findById(id); // Cache result if (user) { await this.cacheService.set(`user:${id}`, user, { ttl: 300 }); } return user; } } ``` ## Configuration System ### Schema Definition ```typescript export const envSchema = { server: { port: Env.number({ default: 3000 }), host: Env.string({ default: '0.0.0.0' }), }, database: { url: Env.string({ env: 'DATABASE_URL', sensitive: true }), maxConnections: Env.number({ default: 10 }), }, features: { enableCache: Env.boolean({ default: true }), allowedOrigins: Env.array({ separator: ',' }), }, }; ``` ### Configuration Flow ``` .env file → EnvLoader → EnvParser → ConfigProxy → TypedEnv ↓ validation ↓ type-safe access: config.get('database.url') ``` ## Observability Stack ### Logging ```typescript // Levels: trace, debug, info, warn, error, fatal this.logger.info('User created', { userId: user.id }); // Child loggers inherit context const childLogger = this.logger.child({ requestId: '123' }); ``` ### Metrics (Prometheus) ``` http_request_duration_seconds{method, route, status_code} http_requests_total{method, route, status_code} process_cpu_seconds_total process_memory_bytes{type} ``` ### Tracing (OpenTelemetry-compatible) ```typescript // Automatic HTTP request tracing // Headers: traceparent, tracestate, x-trace-id, x-span-id // Manual span creation @Span('operation-name') async myOperation(): Promise { // Span automatically created and closed } ``` ## Error Handling OneBun provides standardized error responses via `this.error()` on controllers and typed error classes (`NotFoundError`, `InternalServerError`, etc.). See [Controllers API — error()](/api/controllers#error) for the response format and usage examples. ## Multi-Service Architecture ### Single Process, Multiple Services ```typescript const multiApp = new MultiServiceApplication({ services: { users: { module: UsersModule, port: 3001, }, orders: { module: OrdersModule, port: 3002, envOverrides: { DB_NAME: { value: 'orders_db' }, }, }, }, envSchema, }); ``` ### Service Communication ```typescript // Generate typed client from service definition const usersClient = createServiceClient(UsersServiceDefinition, { baseUrl: 'http://localhost:3001', }); // Call with full type safety const user = await usersClient.users.findById('123'); ``` --- --- url: /features.md description: >- Complete overview of OneBun Framework capabilities — DI, WebSocket, microservices, validation, database, queue, cache, observability, and production features. --- # OneBun Framework — Complete Documentation ## Framework Summary OneBun is a batteries-included TypeScript backend framework for Bun.js runtime, inspired by NestJS architecture. It provides a complete ecosystem for building production-grade backend services. ### Core Capabilities * **Modules & DI**: NestJS-style @Module, @Controller, @Service, @Injectable with automatic constructor injection * **HTTP Routing**: Decorator-based (@Get, @Post, @Put, @Delete, @Patch) with path params, query, body, headers * **Guards**: Custom route guards for authentication/authorization * **Middleware**: @UseMiddleware decorator with chaining support * **Graceful Shutdown**: Enabled by default, handles SIGTERM/SIGINT ### WebSocket * WebSocket Gateway with @WebSocketGateway, @SubscribeMessage decorators * Socket.IO adapter support (rooms, namespaces, broadcasting) * Auto-generated typed WebSocket client for frontend integration ### Microservices * MultiServiceApplication: run multiple services from single codebase/image * Dev: all services in one process. Prod: ONEBUN\_SERVICES=name selects services * Typed inter-service HTTP clients with createServiceDefinition/createServiceClient * HMAC authentication for service-to-service communication ### Validation (ArkType) * Single source of truth: one schema = TypeScript type + runtime validation + OpenAPI 3.1 * @Body(schema) decorator for automatic request validation * Zero duplication between types, validation, and API documentation ### API Documentation (@onebun/docs) * Automatic OpenAPI 3.1 generation from decorators and ArkType schemas * @ApiTags, @ApiOperation, @ApiResponse decorators * Swagger UI included ### Database (@onebun/drizzle) * Drizzle ORM with PostgreSQL and SQLite (bun:sqlite) support * Schema-first approach with full type inference from schema * CLI migrations (onebun-drizzle generate/push/studio) * Auto-migrate on startup (autoMigrate: true) * BaseRepository with CRUD operations ### Queue & Scheduler * Background job processing with backends: in-memory, Redis Pub/Sub, NATS, JetStream * NATS/JetStream via @onebun/nats package * Cron-like scheduler with same backend options ### Caching (@onebun/cache) * In-memory cache (TTL, max size, cleanup) and Redis cache * Shared Redis connection pool across modules * Batch operations (getMany, setMany, deleteMany) ### HTTP Client (@onebun/requests) * createHttpClient() with auth (Bearer, API Key, Basic, HMAC), retries (fixed/linear/exponential) * Typed `ApiResponse` with success/error discrimination * Typed service clients for inter-service communication ### Observability * **Prometheus Metrics** (@onebun/metrics): auto HTTP/system/GC metrics, @Timed, @Counted, custom counters/gauges/histograms * **OpenTelemetry Tracing** (@onebun/trace): auto HTTP tracing, @Span decorator, configurable export * **Structured Logging** (@onebun/logger): JSON/pretty output, levels, child loggers, trace context integration ### Configuration (@onebun/envs) * Type-safe env schema (Env.string, Env.number, Env.boolean, Env.array) * Validation, defaults, transforms, sensitive value masking * .env file support, per-service overrides ### Packages | Package | Key Features | |---------|-------------| | @onebun/core | Modules, DI, Controllers, Services, WebSocket Gateway, Queue, Scheduler, Guards, Middleware, Graceful Shutdown | | @onebun/docs | OpenAPI 3.1 auto-generation, Swagger UI, @ApiTags, @ApiOperation | | @onebun/drizzle | Drizzle ORM, PostgreSQL + SQLite, migrations, BaseRepository, schema-first types | | @onebun/cache | In-memory + Redis caching, shared connections, batch operations | | @onebun/envs | Type-safe env variables, validation, sensitive masking, .env support | | @onebun/logger | Structured logging, JSON/pretty, child loggers, trace context | | @onebun/metrics | Prometheus metrics, auto HTTP/system metrics, @Timed, @Counted | | @onebun/trace | OpenTelemetry, @Span decorator, configurable sampling/export | | @onebun/requests | HTTP client, auth schemes, retries, typed service clients | | @onebun/nats | NATS + JetStream integration for queues | *** (Detailed documentation for each package follows below) # Features Overview OneBun is a complete, batteries-included backend framework for Bun.js. It provides everything needed to build production-grade TypeScript services — from HTTP routing to database integration, from message queues to observability. ## Core Framework (@onebun/core) ### Dependency Injection & Modules NestJS-inspired module system with automatic constructor-based DI, module imports/exports, and service scoping. → [API Reference](/api/core) ### Controllers & Routing Decorator-based HTTP controllers with @Get, @Post, @Put, @Delete, @Patch. Path parameters, query parameters, body parsing, header extraction. Standardized ApiResponse format across the application. → [API Reference](/api/controllers) ### Guards Custom guard support for authentication and authorization. Write guard functions and apply them via decorators to protect routes. → [API Reference](/api/decorators) ### Middleware Request/response middleware with @UseMiddleware decorator. Supports middleware chaining on individual routes. → [API Reference](/api/decorators) ### Static file serving Serve a static directory (e.g. SPA build) from the same host and port as the API. Configure `static.root`, optional `pathPrefix` and `fallbackFile` (e.g. `index.html`) for client-side routing. → [API Reference](/api/core#staticapplicationoptions) ## WebSocket (@onebun/core) ### WebSocket Gateway Decorator-based WebSocket handlers with @WebSocketGateway, @SubscribeMessage. Built on Bun's native WebSocket support for maximum performance. ### Socket.IO Support Optional Socket.IO adapter for browser compatibility, rooms, namespaces, and broadcasting. ### Typed WebSocket Client Auto-generated typed client for type-safe frontend ↔ backend WebSocket communication. → [API Reference](/api/websocket) ## Microservices (@onebun/core) ### MultiServiceApplication Run multiple services from a single codebase and Docker image: * **Development**: all services in one process (`bun run src/index.ts`) * **Production**: one service per process (`ONEBUN_SERVICES=users bun run src/index.ts`) * **Flexible**: any combination via environment variables ### Inter-Service Communication Typed HTTP clients with `createServiceDefinition` + `createServiceClient`. HMAC authentication for service-to-service calls. ### Kubernetes-Ready Environment-based service selection, external service URL configuration, single Docker image for all services. → [Multi-Service Example](/examples/multi-service) ## Validation (@onebun/core + ArkType) ### Single Source of Truth One ArkType schema serves as: * **TypeScript type** (compile-time safety) * **Runtime validation** (request body, query params) * **OpenAPI 3.1 schema** (auto-generated documentation) Zero duplication between types, validation rules, and API docs. ### @Body() Validation Pass ArkType schema to @Body decorator for automatic validation with typed error responses. → [API Reference](/api/validation) ## API Documentation (@onebun/docs) ### OpenAPI Auto-Generation Automatic OpenAPI 3.1 spec from decorators and ArkType schemas. Install `@onebun/docs` and get Swagger UI with zero configuration. ### Documentation Decorators @ApiTags, @ApiOperation, @ApiResponse for additional metadata. → [API Reference](/api/decorators) ## Database (@onebun/drizzle) ### Drizzle ORM Integration Schema-first approach with full type inference. Supports PostgreSQL and SQLite (via bun:sqlite). ### Migrations * CLI: `bunx onebun-drizzle generate` / `push` / `studio` * Programmatic: `generateMigrations()`, `pushSchema()` * Auto-migrate on startup: `autoMigrate: true` ### Repository Pattern BaseRepository with built-in CRUD operations, custom queries via Drizzle query builder. → [API Reference](/api/drizzle) ## Queue & Scheduler (@onebun/core + @onebun/nats) ### Queue System Background job processing with multiple backends: * **In-memory** — zero config, for development and simple use cases * **Redis Pub/Sub** — distributed queues via Redis * **NATS** — high-performance messaging (via @onebun/nats) * **JetStream** — persistent, at-least-once delivery (via @onebun/nats) ### Scheduler Cron-like task scheduling with the same backend options. → [API Reference](/api/queue) ## Caching (@onebun/cache) ### CacheModule * **In-memory cache** — with TTL, max size, cleanup intervals * **Redis cache** — with shared connection pool support * Batch operations: getMany, setMany, deleteMany * Cache-aside, invalidation, and warming patterns → [API Reference](/api/cache) ## HTTP Client (@onebun/requests) ### createHttpClient() Full-featured HTTP client with: * **Authentication**: Bearer, API Key, Basic, HMAC (inter-service) * **Retries**: fixed, linear, exponential backoff strategies * **Typed responses**: `ApiResponse` with success/error discrimination ### Typed Service Clients `createServiceDefinition()` + `createServiceClient()` for type-safe inter-service REST communication without code generation. → [API Reference](/api/requests) ## Observability ### Prometheus Metrics (@onebun/metrics) * Automatic HTTP request metrics (duration, count, status codes) * System metrics (CPU, memory, event loop, GC) * Custom metrics: Counter, Gauge, Histogram * Decorator-based: @Timed(), @Counted() * Endpoint: GET /metrics → [API Reference](/api/metrics) ### OpenTelemetry Tracing (@onebun/trace) * Automatic HTTP request tracing * @Span() decorator for custom spans * Trace context propagation in logs * Configurable sampling, export to external collectors → [API Reference](/api/trace) ### Structured Logging (@onebun/logger) * JSON (production) and pretty (development) output * Log levels: trace, debug, info, warn, error, fatal * Child loggers with context inheritance * Automatic trace context in log entries → [API Reference](/api/logger) ## Configuration (@onebun/envs) ### Type-Safe Environment Variables * Schema definition with Env.string(), Env.number(), Env.boolean(), Env.array() * Validation, defaults, transforms * Sensitive value masking in logs * .env file support * Per-service overrides in MultiServiceApplication → [API Reference](/api/envs) ## Production Features ### Graceful Shutdown Enabled by default. Handles SIGTERM/SIGINT, closes HTTP server, WebSocket connections, and Redis connections. ### Shared Redis Connection Single Redis connection pool shared between Cache, WebSocket, and Queue modules. Reduced memory footprint and connection count. ### Effect.js Integration Internal architecture built on Effect.js for type-safe side effect management. Optional Effect API for advanced use cases. ## For NestJS Developers If you're coming from NestJS, here's what to expect: ### Same patterns * @Module, @Controller, @Service decorators * Constructor-based dependency injection * Module imports/exports for service sharing * Guards for route protection * Middleware support ### Improved in OneBun * **Validation**: ArkType schema = TypeScript type = OpenAPI spec = runtime validation (vs class-validator + Swagger decorators + separate TS types in NestJS) * **Microservices**: Single Docker image, env-based service selection (vs separate entry points in NestJS) * **Observability**: Prometheus metrics + OpenTelemetry tracing built-in (vs community packages in NestJS) * **Performance**: Bun.js native, no Express/Fastify adapter layer * **Configuration**: Type-safe env schema with sensitive value masking (vs @nestjs/config) ### Different approach * ArkType instead of class-validator/class-transformer * Drizzle ORM instead of TypeORM (schema-first, not entity-first) * Effect.js internally (optional for application code) * Bun.js runtime only (not Node.js compatible) ### Not yet available * Interceptors and Pipes (planned) * GraphQL integration (planned, separate package) * CQRS module * Extensive third-party ecosystem --- --- url: /api/queue.md description: >- Message queues with @Subscribe, @Cron, @Interval decorators. In-memory, Redis, NATS, JetStream backends. Message guards. --- # Queue API ## Overview OneBun provides a unified queue system for message-based communication. It supports multiple backends (in-memory, Redis, NATS, JetStream) with a consistent API. The queue system includes: * Message publishing and subscribing with pattern matching * Scheduled jobs (cron, interval, timeout) * Message guards for authorization * Auto and manual acknowledgment modes ## Setup The queue system **auto-enables** when any controller in your application uses queue decorators (`@Subscribe`, `@Cron`, `@Interval`, `@Timeout`). No explicit configuration is required for basic usage with the in-memory adapter. ### Application Configuration Configure the queue backend via the `queue` option in `OneBunApplication`: ```typescript import { OneBunApplication } from '@onebun/core'; import { AppModule } from './app.module'; // Default: in-memory adapter, auto-detected const app = new OneBunApplication(AppModule, { port: 3000, }); // Explicit: Redis adapter const app = new OneBunApplication(AppModule, { port: 3000, queue: { adapter: 'redis', redis: { useSharedProvider: true, // Use shared Redis connection (recommended) prefix: 'myapp:queue:', // Key prefix for Redis keys }, }, }); // Explicit: Redis with dedicated connection const app = new OneBunApplication(AppModule, { port: 3000, queue: { adapter: 'redis', redis: { useSharedProvider: false, url: 'redis://localhost:6379', prefix: 'myapp:queue:', }, }, }); ``` For NATS/JetStream or other backends, use a custom adapter constructor (see [Custom adapter: NATS JetStream](#custom-adapter-nats-jetstream) below). ### QueueApplicationOptions | Option | Type | Default | Description | |--------|------|---------|-------------| | `enabled` | `boolean` | auto | Enable queue system (auto-enabled if handlers detected) | | `adapter` | `'memory' \| 'redis'` or adapter class | `'memory'` | Built-in type or custom adapter constructor (e.g. for NATS JetStream) | | `options` | inferred from adapter | - | Options passed to the custom adapter constructor — type-safe when `adapter` is a class | | `redis.useSharedProvider` | `boolean` | `true` | Use shared Redis connection pool | | `redis.url` | `string` | - | Redis URL (required if `useSharedProvider: false`) | | `redis.prefix` | `string` | `'onebun:queue:'` | Key prefix for Redis keys | ### Registering Controllers with Queue Decorators Classes that use `@Subscribe`, `@Cron`, `@Interval`, or `@Timeout` must be registered in a module's `controllers` array. The framework automatically discovers and registers queue handlers from all controllers during startup via `initializeQueue()`. ```typescript import { Module, Controller, BaseController } from '@onebun/core'; import { Subscribe, Cron, CronExpression, Message } from '@onebun/core'; // Controller with queue decorators @Controller('/orders') class OrderProcessor extends BaseController { @Subscribe('orders.created') async handleOrderCreated(message: Message<{ orderId: string }>) { this.logger.info('Processing order', { orderId: message.data.orderId }); // Process the order... } @Cron(CronExpression.EVERY_HOUR, { pattern: 'cleanup.expired' }) getCleanupData() { return { timestamp: Date.now() }; } } // Register in module's controllers array @Module({ controllers: [OrderProcessor], }) class OrderModule {} ``` ::: warning Queue handlers are only discovered in classes registered in the `controllers` array of a `@Module`. Classes in `providers` will **not** be scanned for queue decorators. ::: ::: tip Scheduled-only Controllers If your controllers use **only** scheduling decorators (`@Cron`, `@Interval`, `@Timeout`) without `@Subscribe`, the queue system still auto-initializes with the in-memory adapter. No explicit `queue` configuration is needed. This works regardless of where in the module tree the controller is located — root, child, or deeply nested modules. ::: ::: info Scheduled Job Error Handling Errors thrown inside `@Cron`, `@Interval`, and `@Timeout` handlers are caught and logged as warnings. The scheduler continues running — one failed job does not affect other scheduled jobs. ::: **Technical details for AI agents:** * Queue auto-enables by checking all **controllers** (not providers) for `hasQueueDecorators()` which inspects `@Subscribe`, `@Cron`, `@Interval`, `@Timeout` metadata * Controllers are collected recursively from the entire module tree via `getControllers()` (root + all child modules) * `initializeQueue(controllers)` is called during `app.start()` after `ensureModule().setup()` — it receives `getControllers()` result * Both `controllerClass` and `instance.constructor` are checked for queue decorators (defensive against `@Controller` wrapping edge cases) * The adapter is created, connected, then `QueueService` is initialized and handlers are registered via `registerService(instance, class)` for each controller with queue decorators * `registerService()` processes: subscribe handlers → cron jobs → interval jobs → timeout jobs → lifecycle handlers * `@Interval` handlers fire immediately on scheduler start, then repeat at the configured interval * Scheduler error handler logs warnings for failed jobs via `QueueScheduler.setErrorHandler()` * Message guards (`@UseMessageGuards`) are applied as wrappers around the actual handler * The scheduler (`QueueScheduler`) manages cron/interval/timeout jobs with configurable overlap strategies: `SKIP`, `QUEUE`, `REPLACE` * Queue shutdown sequence: `queueService.stop()` → `queueAdapter.disconnect()` * Debug logging emits per-controller diagnostics during handler registration (controller name, decorator detection result) * Dynamic job management: `addJob()`, `getJob()`, `getJobs()`, `hasJob()`, `pauseJob()`, `resumeJob()`, `removeJob()`, `updateJob()` on `QueueService` — all synchronous, delegate to `QueueScheduler` * Jobs created via decorators are also accessible through the dynamic API by their name (method name by default, overridable via `name` option) **QueueApplicationOptions interface:** ```typescript // Generic: options type is inferred from the adapter constructor interface QueueApplicationOptions { enabled?: boolean; adapter?: 'memory' | 'redis' | A; options?: A extends QueueAdapterConstructor ? O : never; redis?: { useSharedProvider?: boolean; url?: string; prefix?: string; }; } ``` ### Error Handling in Handlers ```typescript @Controller('/orders') class OrderProcessor extends BaseController { @Subscribe('orders.created', { ackMode: 'manual', retry: { attempts: 3, backoff: 'exponential', delay: 1000 }, }) async handleOrder(message: Message<{ orderId: string }>) { try { await this.processOrder(message.data); await message.ack(); // Acknowledge success } catch (error) { this.logger.error('Order processing failed', error); if (message.attempt && message.attempt >= (message.maxAttempts || 3)) { this.logger.error('Max retries reached, moving to DLQ', { orderId: message.data.orderId, }); await message.ack(); // Remove from queue } else { await message.nack(true); // Requeue for retry } } } } ``` ## Quick Start Queue handlers (`@Subscribe`, `@Cron`, `@Interval`, `@Timeout`) are only discovered in classes registered in a module's **controllers** array. Use a controller (not a provider) for queue handlers. ```typescript import { Module, Controller, BaseController, OneBunApplication, Subscribe, Cron, Interval, Message, CronExpression, OnQueueReady, QueueService, } from '@onebun/core'; // Controller with queue handlers (must be in controllers array) @Controller('/events') class EventProcessor extends BaseController { constructor(private queueService: QueueService) { super(); } @OnQueueReady() onReady() { this.logger.info('Queue connected and ready'); } // Subscribe to messages @Subscribe('orders.created') async handleOrderCreated(message: Message<{ orderId: number }>) { this.logger.info('New order:', { orderId: message.data.orderId }); } // Scheduled job: every hour @Cron(CronExpression.EVERY_HOUR, { pattern: 'cleanup.expired' }) getCleanupData() { return { timestamp: Date.now() }; } // Interval job: every 30 seconds @Interval(30000, { pattern: 'metrics.collect' }) getMetricsData() { return { cpu: process.cpuUsage() }; } // Publish messages programmatically async createOrder(data: { userId: string }) { await this.queueService.publish('orders.created', { orderId: Date.now(), userId: data.userId, }); } } // Module: register in controllers so queue handlers are discovered @Module({ controllers: [EventProcessor], }) class AppModule {} // Application const app = new OneBunApplication(AppModule, { port: 3000 }); await app.start(); ``` ## Subscribe Decorator The `@Subscribe` decorator marks a method as a message handler. ```typescript @Subscribe('orders.*') async handleOrder(message: Message) { console.log('Order:', message.pattern, message.data); } ``` ### Pattern Syntax | Pattern | Example Match | Description | |---------|--------------|-------------| | `orders.created` | `orders.created` | Exact match | | `orders.*` | `orders.created`, `orders.updated` | Single-level wildcard | | `events.#` | `events.user.created`, `events.order.paid` | Multi-level wildcard | | `orders.{id}` | `orders.123` → `{ id: '123' }` | Named parameter | ### Subscribe Options ```typescript @Subscribe('orders.*', { ackMode: 'manual', // 'auto' (default) or 'manual' group: 'order-processors', // Consumer group for load balancing prefetch: 10, // Messages to process in parallel retry: { attempts: 3, backoff: 'exponential', delay: 1000, }, }) async handleOrder(message: Message) { try { await this.processOrder(message.data); await message.ack(); } catch (error) { await message.nack(true); // requeue } } ``` ## Message Interface ```typescript interface Message { id: string; // Unique message ID pattern: string; // Message pattern/topic data: T; // Message payload timestamp: number; // Unix timestamp in ms metadata: MessageMetadata; redelivered?: boolean; // True if redelivered attempt?: number; // Current attempt number maxAttempts?: number; // Max attempts allowed ack(): Promise; // Acknowledge (manual mode) nack(requeue?: boolean): Promise; // Negative ack } interface MessageMetadata { headers?: Record; authorization?: string; // Bearer token serviceId?: string; // Calling service ID traceId?: string; // Distributed tracing spanId?: string; parentSpanId?: string; } ``` ## Scheduling Decorators ### @Cron Executes on a cron schedule. The decorated method returns data to publish. ```typescript import { Cron, CronExpression } from '@onebun/core'; // Daily at 9 AM @Cron('0 0 9 * * *', { pattern: 'reports.daily' }) getDailyReportData() { return { type: 'daily', date: new Date() }; } // Using CronExpression enum @Cron(CronExpression.EVERY_HOUR, { pattern: 'health.check' }) getHealthData() { return { status: 'ok' }; } ``` #### CronExpression Constants | Constant | Expression | Description | |----------|------------|-------------| | `EVERY_SECOND` | `* * * * * *` | Every second | | `EVERY_5_SECONDS` | `*/5 * * * * *` | Every 5 seconds | | `EVERY_MINUTE` | `0 * * * * *` | Every minute | | `EVERY_5_MINUTES` | `0 */5 * * * *` | Every 5 minutes | | `EVERY_HOUR` | `0 0 * * * *` | Every hour | | `EVERY_DAY_AT_MIDNIGHT` | `0 0 0 * * *` | Daily at midnight | | `EVERY_DAY_AT_NOON` | `0 0 12 * * *` | Daily at noon | | `EVERY_WEEKDAY` | `0 0 0 * * 1-5` | Mon-Fri at midnight | | `EVERY_WEEK` | `0 0 0 * * 0` | Sunday at midnight | | `EVERY_MONTH` | `0 0 0 1 * *` | 1st of month | ### @Interval Executes at fixed intervals. ```typescript // Every 60 seconds @Interval(60000, { pattern: 'metrics.collect' }) getMetrics() { return { cpu: process.cpuUsage() }; } ``` ### @Timeout Executes once after a delay. ```typescript // After 5 seconds @Timeout(5000, { pattern: 'init.complete' }) getInitData() { return { startedAt: this.startTime }; } ``` ## Message Guards Guards control access to message handlers, similar to WebSocket guards. ### Built-in Guards ```typescript import { UseMessageGuards, MessageAuthGuard, MessageServiceGuard, MessageHeaderGuard, MessageTraceGuard, } from '@onebun/core'; // Require authorization token @UseMessageGuards(MessageAuthGuard) @Subscribe('secure.events') async handleSecure(message: Message) {} // Require specific service @UseMessageGuards(new MessageServiceGuard(['payment-service'])) @Subscribe('internal.events') async handleInternal(message: Message) {} // Require header @UseMessageGuards(new MessageHeaderGuard('x-api-key')) @Subscribe('api.events') async handleApi(message: Message) {} // Require trace context @UseMessageGuards(MessageTraceGuard) @Subscribe('traced.events') async handleTraced(message: Message) {} ``` ### Composite Guards ```typescript import { MessageAllGuards, MessageAnyGuard } from '@onebun/core'; // All guards must pass @UseMessageGuards(new MessageAllGuards([ MessageAuthGuard, new MessageServiceGuard(['allowed-service']), ])) @Subscribe('strict.events') async handleStrict(message: Message) {} // Any guard can pass @UseMessageGuards(new MessageAnyGuard([ new MessageServiceGuard(['internal-service']), MessageAuthGuard, ])) @Subscribe('flexible.events') async handleFlexible(message: Message) {} ``` ### Custom Guards ```typescript import { createMessageGuard } from '@onebun/core'; const customGuard = createMessageGuard((context) => { const metadata = context.getMetadata(); return metadata.headers?.['x-custom'] === 'expected'; }); @UseMessageGuards(customGuard) @Subscribe('custom.events') async handleCustom(message: Message) {} ``` ## Lifecycle Decorators Lifecycle handlers run only when the class is registered as a **controller** (in a module's `controllers` array). ```typescript import { Controller, BaseController, OnQueueReady, OnQueueError, OnMessageReceived, OnMessageProcessed, OnMessageFailed, } from '@onebun/core'; @Controller('/events') class EventProcessor extends BaseController { @OnQueueReady() handleReady() { this.logger.info('Queue connected'); } @OnQueueError() handleError(error: Error) { this.logger.error('Queue error', error); } @OnMessageReceived() handleReceived(message: Message) { this.logger.info('Received', { id: message.id }); } @OnMessageProcessed() handleProcessed(message: Message) { this.logger.info('Processed', { id: message.id }); } @OnMessageFailed() handleFailed(message: Message, error: Error) { this.logger.error('Failed', { id: message.id, error }); } } ``` ## Queue Adapters ### InMemoryQueueAdapter In-process message bus. Good for development and testing. This is the default adapter when `queue.adapter` is not specified: ```typescript const app = new OneBunApplication(AppModule, { queue: { adapter: 'memory' }, }); ``` **Supported Features:** * Pattern subscriptions * Delayed messages * Priority * Scheduled jobs ### RedisQueueAdapter Distributed queue using Redis. Uses SharedRedisProvider by default: ```typescript const app = new OneBunApplication(AppModule, { queue: { adapter: 'redis', redis: { useSharedProvider: true, prefix: 'myapp:queue:' }, }, }); ``` Or with a dedicated Redis connection: ```typescript const app = new OneBunApplication(AppModule, { queue: { adapter: 'redis', redis: { useSharedProvider: false, url: 'redis://localhost:6379' }, }, }); ``` **Supported Features:** * All features (pattern subscriptions, delayed messages, priority, consumer groups, DLQ, retry, scheduled jobs) ### Custom adapter: NATS JetStream To use a custom backend (e.g. NATS JetStream), pass the adapter **constructor** and **options** in `queue`: ```typescript import { OneBunApplication, type QueueAdapter } from '@onebun/core'; import { Module, Controller, BaseController, Subscribe, Message } from '@onebun/core'; import { AppModule } from './app.module'; // If you have an adapter class (e.g. from @onebun/nats or your own): class NatsJetStreamAdapter implements QueueAdapter { readonly name = 'nats-jetstream'; readonly type = 'jetstream'; constructor(private opts: { servers: string; streams?: Array<{ name: string; subjects: string[] }> }) {} async connect() { /* connect to NATS */ } async disconnect() { /* disconnect */ } isConnected() { return true; } async publish() { return ''; } async publishBatch() { return []; } async subscribe() { return { unsubscribe: async () => {}, pause: () => {}, resume: () => {}, pattern: '', isActive: true }; } supports() { return false; } on() {} off() {} } @Controller('/jobs') class JobHandler extends BaseController { @Subscribe('jobs.created') async handle(message: Message<{ id: string }>) { this.logger.info('Job', { id: message.data.id }); } } @Module({ controllers: [JobHandler] }) class AppModule {} const app = new OneBunApplication(AppModule, { port: 3000, queue: { adapter: NatsJetStreamAdapter, options: { servers: 'nats://localhost:4222', streams: [{ name: 'EVENTS', subjects: ['events.>'] }], }, }, }); await app.start(); ``` The framework instantiates the adapter with `new Adapter(queue.options)` and uses it as the queue backend. When you pass a class constructor as `adapter`, `options` is automatically typed to match the adapter's constructor argument — no type assertions needed. For a ready-made NATS/JetStream adapter, use the `@onebun/nats` package if available and pass its adapter class and options the same way. ### NatsQueueAdapter NATS pub/sub for lightweight messaging (no persistence). Pass the adapter class in application options — the framework handles instantiation and connection automatically: ```typescript import { OneBunApplication } from '@onebun/core'; import { NatsQueueAdapter } from '@onebun/nats'; const app = new OneBunApplication(AppModule, { queue: { adapter: NatsQueueAdapter, options: { servers: 'nats://localhost:4222' }, }, }); await app.start(); ``` `QueueService` is automatically available for injection in any controller or service across all modules — no additional imports or configuration required. **Supported Features:** * Pattern subscriptions * Consumer groups * Scheduled jobs ### JetStreamQueueAdapter NATS JetStream for persistent, reliable messaging. Pass the adapter class in application options: ```typescript import { OneBunApplication } from '@onebun/core'; import { JetStreamQueueAdapter } from '@onebun/nats'; const app = new OneBunApplication(AppModule, { queue: { adapter: JetStreamQueueAdapter, options: { servers: 'nats://localhost:4222', streamDefaults: { retention: 'limits', storage: 'file', replicas: 1, }, streams: [ { name: 'EVENTS', subjects: ['events.>'], }, { name: 'agent_events', subjects: ['agent.events.>'], maxAge: 7 * 24 * 60 * 60 * 1e9, }, { name: 'agent_dlq', subjects: ['agent.dlq.>'], maxAge: 7 * 24 * 60 * 60 * 1e9, storage: 'memory', }, ], }, }, }); await app.start(); ``` All streams are created automatically during startup. `streamDefaults` is merged into each stream definition (per-stream values take priority). `QueueService` is automatically available for injection in any controller or service. When using `@Subscribe('agent.events.task.done')`, the adapter automatically resolves the correct stream. **Supported Features:** * Pattern subscriptions * Consumer groups * Dead letter queue * Retry * Scheduled jobs ## Feature Support Matrix | Feature | Memory | Redis | NATS | JetStream | |---------|--------|-------|------|-----------| | Pattern subscriptions | ✅ | ✅ | ✅ | ✅ | | Delayed messages | ✅ | ✅ | ❌ | ❌ | | Priority | ✅ | ✅ | ❌ | ❌ | | Consumer groups | ❌ | ✅ | ✅ | ✅ | | Dead letter queue | ❌ | ✅ | ❌ | ✅ | | Retry | ❌ | ✅ | ❌ | ✅ | | Scheduled jobs | ✅ | ✅ | ✅ | ✅ | | Persistence | ❌ | ✅ | ❌ | ✅ | ## Publishing Messages ### QueueService: availability and injection **QueueService is available via DI.** You can inject it in the constructor of controllers, providers, WebSocket gateways, and middleware (e.g. `constructor(private queueService: QueueService)`). The framework registers a proxy in the module before creating controllers; when the queue is enabled during `app.start()`, the proxy delegates to the real `QueueService`. No wrapper or `getQueueService()` is required for normal DI-based code. **When is the real QueueService created?**\ The queue system is initialized during `app.start()`, after the module is set up, inside `initializeQueue()`. It is only created when the queue is enabled: either at least one controller has queue decorators (`@Subscribe`, `@Cron`, `@Interval`, `@Timeout`) or `queue.enabled` is set to `true` in application options. **If the queue is not enabled but you injected QueueService:**\ The injected instance is a proxy. Any call to a method (e.g. `publish()`, `subscribe()`) will throw an error with a message explaining how to enable the queue (`queue.enabled: true` or register a controller with queue decorators). **Getting QueueService without DI:**\ Use `app.getQueueService()` when you do not have DI (e.g. bootstrap scripts or code that only has the app reference). It returns `QueueService | null` when the queue is not enabled. ### QueueService Inject `QueueService` in your controller, provider, middleware, or gateway constructor: ```typescript import { QueueService } from '@onebun/core'; class OrderService { constructor(private queue: QueueService) {} async createOrder(data: OrderData) { // Publish with options await this.queue.publish('orders.created', data, { delay: 1000, // Delay 1 second priority: 10, // Higher = more important messageId: 'custom-id', metadata: { authorization: 'Bearer token', serviceId: 'order-service', traceId: 'trace-123', }, }); // Batch publish await this.queue.publishBatch([ { pattern: 'orders.created', data: order1 }, { pattern: 'orders.created', data: order2 }, ]); } } ``` ## Dynamic Job Management QueueService provides programmatic control over scheduled jobs at runtime. ### Adding Jobs ```typescript // Cron job queueService.addJob({ type: 'cron', name: 'cleanup', expression: '0 * * * *', pattern: 'jobs.cleanup', }); // Interval job queueService.addJob({ type: 'interval', name: 'heartbeat', intervalMs: 5000, pattern: 'jobs.heartbeat', }); // Timeout job (one-time) queueService.addJob({ type: 'timeout', name: 'warmup', timeoutMs: 3000, pattern: 'jobs.warmup', }); ``` ### Querying Jobs ```typescript const job = queueService.getJob('cleanup'); const allJobs = queueService.getJobs(); const exists = queueService.hasJob('cleanup'); // Filter by origin — decorator-created vs dynamic const decoratorJobs = allJobs.filter(j => j.declarative); const dynamicJobs = allJobs.filter(j => !j.declarative); ``` `ScheduledJobInfo` fields: `name`, `type`, `pattern`, `paused`, `declarative`, `schedule`, `lastRun`, `nextRun`, `isRunning`. The `declarative` field is `true` for jobs created via `@Cron`/`@Interval`/`@Timeout` decorators, `false` for jobs added via `addJob()`. ### Controlling Jobs ```typescript queueService.pauseJob('cleanup'); // Pause queueService.resumeJob('cleanup'); // Resume queueService.removeJob('cleanup'); // Delete ``` ### Updating Jobs ```typescript queueService.updateJob({ type: 'cron', name: 'cleanup', expression: '*/5 * * * *' }); queueService.updateJob({ type: 'interval', name: 'heartbeat', intervalMs: 10000 }); ``` Jobs created via decorators (`@Cron`, `@Interval`, `@Timeout`) are also accessible through this API by their name (defaults to method name, overridable via `name` option in decorator). **Technical details for AI agents:** * `addJob()`, `getJob()`, `getJobs()`, `hasJob()`, `pauseJob()`, `resumeJob()`, `removeJob()`, `updateJob()` are all synchronous methods on `QueueService` * They delegate to `QueueScheduler` which manages the underlying timers/cron jobs * `addJob()` accepts a discriminated union `AddJobOptions` with `type: 'cron' | 'interval' | 'timeout'` * `updateJob()` accepts `UpdateJobOptions` — same discriminated union but fields (except `name` and `type`) are optional * `getJob()` returns `ScheduledJobInfo | undefined`, `getJobs()` returns `ScheduledJobInfo[]` * `ScheduledJobInfo` includes: `name`, `type`, `pattern`, `paused`, `declarative`, `schedule` (with `cron?`, `every?`, `timeout?`), `lastRun`, `nextRun`, `isRunning` * `declarative: true` for jobs created via `@Cron`/`@Interval`/`@Timeout` decorators, `false` for jobs added via `addJob()` * Jobs added via decorators are registered during `registerService()` and get default names from method names * Decorator-created jobs can be overridden with a custom `name` via the decorator options (e.g. `@Cron('...', { name: 'my-job' })`) * Scheduler management is entirely in-process — it does not use the queue adapter for persistence * `pauseJob()` clears timers and sets `paused: true`; `resumeJob()` restarts timers * `updateJob()` validates type match, updates timing parameters in-place, and restarts timer if running ## Cron Parser OneBun includes a built-in cron parser (no external dependencies). ```typescript import { parseCronExpression, getNextRun, isValidCronExpression, } from '@onebun/core'; // Parse expression const schedule = parseCronExpression('0 30 9 * * 1-5'); // { seconds: [0], minutes: [30], hours: [9], ... } // Get next run time const nextRun = getNextRun(schedule); console.log('Next run:', nextRun); // Validate expression if (isValidCronExpression('0 0 * * *')) { console.log('Valid!'); } ``` ### Supported Syntax | Field | Values | Special | |-------|--------|---------| | Seconds | 0-59 | `*`, `*/N`, `N-M`, `N,M` | | Minutes | 0-59 | `*`, `*/N`, `N-M`, `N,M` | | Hours | 0-23 | `*`, `*/N`, `N-M`, `N,M` | | Day of month | 1-31 | `*`, `*/N`, `N-M`, `N,M` | | Month | 1-12 | `*`, `*/N`, `N-M`, `N,M` | | Day of week | 0-6 (0=Sun) | `*`, `*/N`, `N-M`, `N,M` | ## Pattern Matcher ```typescript import { matchQueuePattern, isQueuePatternMatch, createQueuePatternMatcher, } from '@onebun/core'; // Match with parameter extraction const result = matchQueuePattern('orders.{id}.status', 'orders.123.status'); // { matched: true, params: { id: '123' } } // Simple match check if (isQueuePatternMatch('events.*', 'events.user')) { console.log('Matched!'); } // Create reusable matcher (optimized) const matcher = createQueuePatternMatcher('orders.*.status'); matcher('orders.123.status'); // { matched: true, params: {} } matcher('orders.456.status'); // { matched: true, params: {} } ``` --- --- url: /roadmap.md --- # Roadmap OneBun is actively developed. This page outlines our priorities and planned features. ## Current Status OneBun is at **v0.2.x** — a pre-1.0 release focused on API stabilization and filling production gaps. The core framework, DI system, and all listed packages are functional and tested. ## Phase 1: Production-Ready Critical features required for production deployments. ### HTTP Guards & Authentication Guards already exist for [WebSocket](/api/websocket) and [Queue](/api/queue), but HTTP — the primary transport — lacks them. | Feature | Status | |---------|--------| | `CanActivate` interface for HTTP | Planned | | `@UseGuards()` decorator for controllers/routes | Planned | | Built-in `AuthGuard` (token verification) | Planned | | Built-in `RolesGuard` (RBAC) | Planned | | `createGuard(fn)` factory | Planned | ### Exception Filters Customizable error handling without modifying framework internals. | Feature | Status | |---------|--------| | `ExceptionFilter` interface | Planned | | `@UseFilters()` decorator | Planned | | Global exception filter via `ApplicationOptions` | Planned | | Default filter (current `OneBunBaseError` handling) | Planned | ### Testing Utilities First-class testing support for DI-based applications. | Feature | Status | |---------|--------| | `TestingModule.create()` with mock providers | Planned | | `.overrideProvider().useValue()` / `.useClass()` | Planned | | HTTP testing without starting a server | Planned | | Mock logger, config (existing) | Done | | Fake timers (existing) | Done | ### Security Middleware Built-in security primitives. | Feature | Status | |---------|--------| | CORS middleware | Planned | | Rate limiting (in-memory + Redis) | Planned | | Security headers (helmet-like) | Planned | *** ## Phase 2: Developer Experience Features that significantly improve adoption and day-to-day development. ### HTTP Interceptors Transform requests/responses in the pipeline (logging, mapping, caching). | Feature | Status | |---------|--------| | `Interceptor` interface | Planned | | `@UseInterceptors()` decorator | Planned | | Built-in: Logging, Cache, Timeout interceptors | Planned | ### Health Checks Kubernetes-ready health endpoints. | Feature | Status | |---------|--------| | `HealthModule` with `/health` and `/ready` | Planned | | Database, Redis, NATS indicators | Planned | | Status aggregation, liveness/readiness probes | Planned | ### CLI & Scaffolding Project and component generation. | Feature | Status | |---------|--------| | `bunx create-onebun my-app` | Planned | | `bunx onebun generate module/controller/service` | Planned | ### Documentation | Document | Status | |----------|--------| | Migration guide (NestJS to OneBun) | Planned | | Deployment guide (Docker, k8s, CI/CD) | Planned | | Testing guide | Planned | | Expanded Troubleshooting / FAQ | Planned | *** ## Phase 3: Ecosystem Post-1.0 features for broader adoption. | Feature | Status | |---------|--------| | Performance benchmarks | Planned | | GraphQL + Drizzle integration | Planned | | Plugin system | Planned | | Build-time config validation | Planned | *** ## Already Implemented These features are fully functional but may not be immediately obvious. Check the linked documentation for details. | Feature | Package | Docs | |---------|---------|------| | Graceful shutdown | `@onebun/core` | [Core](/api/core) | | Swagger UI + OpenAPI 3.1 | `@onebun/docs` | [API Docs](/api/docs) | | WebSocket guards (6 built-in) | `@onebun/core` | [WebSocket](/api/websocket) | | Queue/message guards (4 built-in) | `@onebun/core` | [Queue](/api/queue) | | ArkType validation (replaces pipes) | `@onebun/core` | [Validation](/api/validation) | | Server-Sent Events (SSE) | `@onebun/core` | [Controllers](/api/controllers) | | Static file serving with SPA fallback | `@onebun/core` | [Core](/api/core) | | Multi-service applications | `@onebun/core` | [Multi-Service example](/examples/multi-service) | | Prometheus metrics + system metrics | `@onebun/metrics` | [Metrics](/api/metrics) | | OpenTelemetry tracing | `@onebun/trace` | [Tracing](/api/trace) | | Typed HTTP client with auth schemes | `@onebun/requests` | [HTTP Client](/api/requests) | | NATS + JetStream | `@onebun/nats` | [Queue](/api/queue) | --- --- url: /api/security.md description: 'Built-in security middleware — CORS, rate limiting, and HTTP security headers.' --- ## Quick Reference for AI **Imports:** ```typescript import { CorsMiddleware, RateLimitMiddleware, MemoryRateLimitStore, RedisRateLimitStore, SecurityHeadersMiddleware, } from '@onebun/core'; ``` **Shorthand options on `ApplicationOptions`:** * `cors: true` or `cors: CorsOptions` — auto-adds `CorsMiddleware` before user middleware * `rateLimit: true` or `rateLimit: RateLimitOptions` — auto-adds `RateLimitMiddleware` after CORS * `security: true` or `security: SecurityHeadersOptions` — auto-adds `SecurityHeadersMiddleware` after user middleware **Manual via `middleware` array:** ```typescript middleware: [CorsMiddleware.configure({ origin: 'https://example.com' })] ``` **Auto-ordering:** CORS → RateLimit → \[user middleware] → SecurityHeaders **Rate limit response:** HTTP 429, `{ success: false, error: 'Too Many Requests', statusCode: 429 }` **Rate limit backends:** * `MemoryRateLimitStore` — default, in-process only * `RedisRateLimitStore(redisClient)` — shared across instances **Security headers set by default (all helmet-equivalent):** Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, Strict-Transport-Security, Referrer-Policy, X-XSS-Protection, Cross-Origin-Opener-Policy, Cross-Origin-Resource-Policy, Origin-Agent-Cluster, X-DNS-Prefetch-Control, X-Download-Options, X-Permitted-Cross-Domain-Policies # Security Middleware OneBun provides three built-in security middleware components that can be enabled via `ApplicationOptions` shorthand properties or applied manually via the `middleware` array. ## Quick Setup ```typescript import { OneBunApplication } from '@onebun/core'; import { AppModule } from './app.module'; const app = new OneBunApplication(AppModule, { cors: { origin: 'https://my-frontend.example.com', credentials: true }, rateLimit: { windowMs: 60_000, max: 100 }, security: true, // use all defaults }); await app.start(); ``` **Auto-ordering when all three are active:** ``` Request → CorsMiddleware → RateLimitMiddleware → [your middleware] → SecurityHeadersMiddleware → Handler ``` *** ## CorsMiddleware Handles preflight `OPTIONS` requests and adds `Access-Control-*` headers to all responses. ### Via `ApplicationOptions.cors` ```typescript const app = new OneBunApplication(AppModule, { cors: { origin: 'https://my-frontend.example.com', credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE'], allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'], exposedHeaders: ['X-Request-Id'], maxAge: 3600, }, }); ``` Pass `cors: true` to allow all origins with default settings. ### Via `middleware` array (manual configuration) ```typescript import { CorsMiddleware } from '@onebun/core'; const app = new OneBunApplication(AppModule, { middleware: [ CorsMiddleware.configure({ origin: /\.example\.com$/, // RegExp origin matching }), ], }); ``` ### CorsOptions | Property | Type | Default | Description | |----------|------|---------|-------------| | `origin` | `string \| RegExp \| Array<...> \| ((origin) => boolean)` | `'*'` | Allowed origin(s) | | `methods` | `string[]` | `['GET','HEAD','PUT','PATCH','POST','DELETE','OPTIONS']` | Allowed methods | | `allowedHeaders` | `string[]` | `['Content-Type', 'Authorization']` | Allowed request headers | | `exposedHeaders` | `string[]` | — | Headers exposed to the browser | | `credentials` | `boolean` | `false` | Allow cookies / credentials | | `maxAge` | `number` | `86400` | Preflight cache duration (seconds) | | `preflightContinue` | `boolean` | `false` | Pass OPTIONS to next handler | ### Origin variants ```typescript // Any origin (default) cors: true // Exact string cors: { origin: 'https://example.com' } // RegExp cors: { origin: /\.example\.com$/ } // Array cors: { origin: ['https://app1.com', 'https://app2.com', /\.dev$/] } // Function predicate cors: { origin: (o) => o.startsWith('https://trusted') } ``` *** ## RateLimitMiddleware Limits the number of requests per time window per client IP. Supports in-memory and Redis backends. ### Via `ApplicationOptions.rateLimit` ```typescript const app = new OneBunApplication(AppModule, { rateLimit: { windowMs: 15 * 60 * 1000, // 15 minutes max: 200, // 200 requests per window }, }); ``` Pass `rateLimit: true` for defaults (100 requests / 60 seconds, in-memory, IP-based). ### Redis-backed (multi-instance) ```typescript import { RateLimitMiddleware, RedisRateLimitStore } from '@onebun/core'; import { SharedRedisProvider } from '@onebun/core'; const redis = await SharedRedisProvider.getClient(); const app = new OneBunApplication(AppModule, { middleware: [ RateLimitMiddleware.configure({ windowMs: 60_000, max: 100, store: new RedisRateLimitStore(redis), }), ], }); ``` ### Custom key generator ```typescript RateLimitMiddleware.configure({ max: 50, windowMs: 60_000, keyGenerator: (req) => req.headers.get('x-api-key') ?? 'anon', }) ``` ### RateLimitOptions | Property | Type | Default | Description | |----------|------|---------|-------------| | `windowMs` | `number` | `60_000` | Time window in ms | | `max` | `number` | `100` | Max requests per window | | `keyGenerator` | `(req) => string` | IP from `x-forwarded-for` | Key for grouping requests | | `message` | `string` | `'Too Many Requests'` | Error message when limit exceeded | | `standardHeaders` | `boolean` | `true` | Add `RateLimit-*` headers | | `legacyHeaders` | `boolean` | `false` | Add `X-RateLimit-*` headers | | `store` | `RateLimitStore` | `MemoryRateLimitStore` | Storage backend | ### Rate limit response (HTTP 429) ```json { "success": false, "error": "Too Many Requests", "statusCode": 429 } ``` Response headers (when `standardHeaders: true`): * `RateLimit-Limit: 100` * `RateLimit-Remaining: 0` * `RateLimit-Reset: 42` (seconds until window resets) *** ## SecurityHeadersMiddleware Sets security-related HTTP response headers on every response — analogous to [helmet](https://helmetjs.github.io/). ### Via `ApplicationOptions.security` ```typescript // All defaults const app = new OneBunApplication(AppModule, { security: true }); // Custom configuration const app = new OneBunApplication(AppModule, { security: { contentSecurityPolicy: "default-src 'self'; img-src *", strictTransportSecurity: false, // disable HSTS in development }, }); ``` ### Default headers set | Header | Default value | |--------|---------------| | `Content-Security-Policy` | `default-src 'self'` | | `Cross-Origin-Opener-Policy` | `same-origin` | | `Cross-Origin-Resource-Policy` | `same-origin` | | `Origin-Agent-Cluster` | `?1` | | `Referrer-Policy` | `no-referrer` | | `Strict-Transport-Security` | `max-age=15552000; includeSubDomains` | | `X-Content-Type-Options` | `nosniff` | | `X-DNS-Prefetch-Control` | `off` | | `X-Download-Options` | `noopen` | | `X-Frame-Options` | `SAMEORIGIN` | | `X-Permitted-Cross-Domain-Policies` | `none` | | `X-XSS-Protection` | `0` (disabled — use CSP instead) | ### SecurityHeadersOptions Each property accepts a `string` (custom value) or `false` (disable the header entirely). ```typescript security: { contentSecurityPolicy: "default-src 'self'; connect-src 'self' https://api.example.com", xFrameOptions: 'DENY', strictTransportSecurity: false, // disable in local dev } ``` *** ## Implementing a Custom Store You can plug in any storage backend by implementing the `RateLimitStore` interface: ```typescript import type { RateLimitStore } from '@onebun/core'; class MyCustomStore implements RateLimitStore { async increment( key: string, windowMs: number, ): Promise<{ count: number; resetAt: number }> { // ...custom logic... return { count: 1, resetAt: Date.now() + windowMs }; } } const app = new OneBunApplication(AppModule, { middleware: [ RateLimitMiddleware.configure({ store: new MyCustomStore() }), ], }); ``` --- --- url: /api/services.md description: >- BaseService class, @Service decorator, dependency injection patterns, service lifecycle, Effect.js integration. --- # Services API Package: `@onebun/core` ## BaseService Base class for all application services. Provides logger and configuration access. ### Class Definition ```typescript export class BaseService { protected logger: SyncLogger; // Available after super() in constructor protected config: unknown; // Available after super() in constructor /** Set ambient init context (called by framework before construction) */ static setInitContext(logger: SyncLogger, config: unknown): void; /** Clear ambient init context (called by framework after construction) */ static clearInitContext(): void; /** Initialize service with logger and config (fallback, called by framework) */ initializeService(logger: SyncLogger, config: unknown): void; /** Check if service is initialized */ get isInitialized(): boolean; /** Run an effect with error handling */ protected async runEffect(effect: Effect.Effect): Promise; /** Format an error for consistent handling */ protected formatError(error: unknown): Error; } ``` ## Creating a Service ::: tip Automatic Logger and Config Injection The framework automatically injects `logger` and `config` into services extending `BaseService`. Both are available **immediately after `super()`** in the constructor — you can use `this.config` and `this.logger` directly in the constructor body. The framework uses an ambient init context set before service construction. ::: ### Basic Service ```typescript import { Service, BaseService } from '@onebun/core'; @Service() export class CounterService extends BaseService { private count = 0; increment(): number { this.count++; this.logger.debug('Counter incremented', { count: this.count }); return this.count; } decrement(): number { this.count--; return this.count; } getValue(): number { return this.count; } } ``` ### Service with Dependencies Dependencies are automatically injected via constructor. Logger and config are available immediately after `super()`: ```typescript import { Service, BaseService } from '@onebun/core'; import { CacheService } from '@onebun/cache'; @Service() export class UserService extends BaseService { // Dependencies are auto-injected via constructor // Logger and config are available immediately after super() constructor( private cacheService: CacheService, private repository: UserRepository, ) { super(); } async findById(id: string): Promise { // Check cache first const cacheKey = `user:${id}`; const cached = await this.cacheService.get(cacheKey); if (cached) { this.logger.debug('User found in cache', { id }); return cached; } // Fetch from database const user = await this.repository.findById(id); if (user) { await this.cacheService.set(cacheKey, user, { ttl: 300 }); } return user; } } ``` ## Service Registration Services must be: 1. Decorated with `@Service()` 2. Listed in module's `providers` array Within a module, all providers are automatically available to that module's controllers and to other providers in the same module. The `exports` array is only required when you want to use a provider in **another** module that imports this one (cross-module injection). ```typescript import { Module } from '@onebun/core'; import { CacheModule } from '@onebun/cache'; import { UserService } from './user.service'; import { UserRepository } from './user.repository'; @Module({ imports: [CacheModule], // Makes CacheService available providers: [ UserService, UserRepository, // Also a service ], exports: [UserService], // Only needed for use in other modules that import this one }) export class UserModule {} ``` ## Lifecycle Hooks Services can implement lifecycle hooks to execute code at specific points in the application lifecycle. ### Available Hooks | Interface | Method | When Called | |-----------|--------|-------------| | `OnModuleInit` | `onModuleInit()` | After service instantiation and DI | | `OnApplicationInit` | `onApplicationInit()` | After all modules initialized, before HTTP server starts | | `OnModuleDestroy` | `onModuleDestroy()` | During shutdown, after HTTP server stops | | `BeforeApplicationDestroy` | `beforeApplicationDestroy(signal?)` | At the very start of shutdown | | `OnApplicationDestroy` | `onApplicationDestroy(signal?)` | At the very end of shutdown | ::: tip Eager Instantiation & Standalone Services All services listed in `providers` are instantiated **eagerly** during module initialization — not lazily on first use. `onModuleInit` is called for **every** service that implements the interface, even if the service is not injected into any controller or other service. This makes it safe to use "standalone" services whose main work happens inside `onModuleInit` — for example, cron schedulers, event listeners, or background workers. ::: ::: info Initialization Order `onModuleInit` hooks are called **sequentially in dependency order**: if service A depends on service B, then B's `onModuleInit` will complete **before** A's `onModuleInit` starts. This guarantees that when your `onModuleInit` runs, all injected dependencies are already fully initialized. ::: ::: tip Cross-Module Lifecycle `onModuleInit` is called for services and controllers in **all modules** across the entire import tree — not just the root module. If `AppModule` imports `UserModule` which imports `AuthModule`, then services in all three modules will have their `onModuleInit` called. Deeply nested modules are initialized first (depth-first order). ::: ### Usage Implement lifecycle interfaces to hook into the application lifecycle: ```typescript import { Service, BaseService, OnModuleInit, OnModuleDestroy } from '@onebun/core'; @Service() export class DatabaseService extends BaseService implements OnModuleInit, OnModuleDestroy { private pool: ConnectionPool | null = null; async onModuleInit(): Promise { // Called after DI, before application starts this.pool = await createPool(this.config.get('database.url')); this.logger.info('Database pool initialized'); } async onModuleDestroy(): Promise { // Called during graceful shutdown if (this.pool) { await this.pool.end(); this.logger.info('Database pool closed'); } } async query(sql: string): Promise { if (!this.pool) { throw new Error('Database not initialized'); } return this.pool.query(sql); } } ``` ### Standalone Service Example A service that is not injected anywhere but performs useful work via `onModuleInit`: ```typescript import { Service, BaseService, OnModuleInit, OnModuleDestroy } from '@onebun/core'; @Service() export class TaskSchedulerService extends BaseService implements OnModuleInit, OnModuleDestroy { private intervalId: Timer | null = null; async onModuleInit(): Promise { // Start background work — no injection needed this.intervalId = setInterval(() => { this.logger.debug('Running scheduled task...'); }, 60_000); this.logger.info('Task scheduler started'); } async onModuleDestroy(): Promise { if (this.intervalId) { clearInterval(this.intervalId); } } } ``` Then simply register it in a module's `providers`: ```typescript @Module({ providers: [TaskSchedulerService], // No controllers or other services reference it — it works on its own }) export class SchedulerModule {} ``` ### Shutdown Hooks with Signal The shutdown hooks receive the signal that triggered the shutdown (e.g., `SIGTERM`, `SIGINT`): ```typescript import { Service, BaseService, BeforeApplicationDestroy, OnApplicationDestroy } from '@onebun/core'; @Service() export class CleanupService extends BaseService implements BeforeApplicationDestroy, OnApplicationDestroy { async beforeApplicationDestroy(signal?: string): Promise { this.logger.info(`Shutdown initiated by ${signal || 'unknown'}`); // Notify external services, flush buffers, etc. } async onApplicationDestroy(signal?: string): Promise { this.logger.info('Final cleanup completed'); // Last chance to do cleanup } } ``` ### Lifecycle Order ``` STARTUP: 1. Framework sets ambient init context (logger + config) 2. Service instantiation → constructor() └─ super() picks up logger and config from init context └─ this.config and this.logger are available after super() 3. Framework clears init context 4. Framework calls initializeService() (no-op if already initialized via constructor) 5. Module init hook → onModuleInit() (sequential, in dependency order) 6. Application init hook → onApplicationInit() 7. HTTP server starts SHUTDOWN: 1. Before destroy hook → beforeApplicationDestroy(signal) 2. HTTP server stops 3. Module destroy hook → onModuleDestroy() 4. Application destroy hook → onApplicationDestroy(signal) ``` ## Accessing Logger ```typescript @Service() export class EmailService extends BaseService { async send(to: string, subject: string, body: string): Promise { this.logger.info('Sending email', { to, subject }); try { // Send email logic await this.smtp.send({ to, subject, body }); this.logger.info('Email sent successfully', { to }); return true; } catch (error) { this.logger.error('Failed to send email', { to, error: error instanceof Error ? error.message : String(error), }); return false; } } } ``` ### Log Levels ```typescript this.logger.trace('Very detailed info'); // Level 0 this.logger.debug('Debug information'); // Level 1 this.logger.info('General information'); // Level 2 this.logger.warn('Warning message'); // Level 3 this.logger.error('Error occurred'); // Level 4 this.logger.fatal('Fatal error'); // Level 5 ``` ### Logging with Context ```typescript // Additional context as object this.logger.info('User action', { userId: user.id, action: 'login', ip: request.ip, }); // Error logging this.logger.error('Operation failed', new Error('Something went wrong')); // Multiple arguments this.logger.debug('Processing', data, { step: 1 }, 'extra info'); ``` ## Accessing Configuration ```typescript @Service() export class DatabaseService extends BaseService { private readonly connectionUrl: string; private readonly maxConnections: number; constructor() { super(); // Config is available immediately after super() this.connectionUrl = this.config.get('database.url') as string; this.maxConnections = this.config.get('database.maxConnections') as number; } async connect(): Promise { // Config is also available in regular methods this.logger.info('Connecting to database', { maxConnections: this.maxConnections }); // ... } } ``` ## Service with Tracing Use `@Span()` decorator to create trace spans: ```typescript import { Service, BaseService, Span } from '@onebun/core'; @Service() export class OrderService extends BaseService { @Span('create-order') async createOrder(data: CreateOrderDto): Promise { this.logger.info('Creating order', { customerId: data.customerId }); // All code inside is traced const order = await this.repository.create(data); await this.notificationService.notify(data.customerId, order.id); return order; } @Span('process-payment') async processPayment(orderId: string, amount: number): Promise { // Traced operation return this.paymentGateway.charge(orderId, amount); } @Span() // Uses method name as span name async validateOrder(order: Order): Promise { // Span name: "validateOrder" return this.validator.validate(order); } } ``` ## Using Effect.js in Services ### Bridge: Effect to Promise ```typescript import { Service, BaseService, Effect } from '@onebun/core'; import { pipe } from 'effect'; @Service() export class DataService extends BaseService { // Internal Effect-based implementation private fetchDataEffect(id: string): Effect.Effect { return pipe( Effect.tryPromise({ try: () => fetch(`/api/data/${id}`).then(r => r.json()), catch: (e) => new FetchError(String(e)), }), Effect.tap((data) => Effect.sync(() => this.logger.debug('Data fetched', { id })) ), ); } // Public Promise-based API async fetchData(id: string): Promise { const effect = pipe( this.fetchDataEffect(id), Effect.retry({ times: 3, delay: '1 second' }), ); return this.runEffect(effect); } } ``` ### runEffect Helper The `runEffect` method handles Effect execution: ```typescript protected async runEffect(effect: Effect.Effect): Promise { try { return await Effect.runPromise(effect); } catch (error) { throw this.formatError(error); } } ``` ## Repository Pattern Services often work with repositories: ```typescript // user.repository.ts import { Service, BaseService } from '@onebun/core'; import { DrizzleService, eq } from '@onebun/drizzle'; import { users } from './schema'; @Service() export class UserRepository extends BaseService { constructor(private db: DrizzleService) { super(); } async findById(id: string): Promise { const result = await this.db.query( db => db.select().from(users).where(eq(users.id, id)).limit(1) ); return result[0] || null; } async findAll(options?: { limit?: number; offset?: number }): Promise { return this.db.query( db => db.select().from(users) .limit(options?.limit || 100) .offset(options?.offset || 0) ); } async create(data: InsertUser): Promise { const result = await this.db.query( db => db.insert(users).values(data).returning() ); return result[0]; } async update(id: string, data: Partial): Promise { const result = await this.db.query( db => db.update(users).set(data).where(eq(users.id, id)).returning() ); return result[0] || null; } async delete(id: string): Promise { const result = await this.db.query( db => db.delete(users).where(eq(users.id, id)).returning() ); return result.length > 0; } } // user.service.ts @Service() export class UserService extends BaseService { constructor( private repository: UserRepository, private cacheService: CacheService, private eventService: EventService, ) { super(); } async createUser(data: CreateUserDto): Promise { // Business logic const existingUser = await this.repository.findByEmail(data.email); if (existingUser) { throw new ConflictError('Email already exists'); } // Create user const user = await this.repository.create({ ...data, password: await hash(data.password), }); // Side effects await this.eventService.emit('user.created', { userId: user.id }); this.logger.info('User created', { userId: user.id }); return user; } } ``` ## Service Tags (Advanced) For advanced Effect.js usage, you can create custom tags: ```typescript import { Service, BaseService } from '@onebun/core'; import { Context } from 'effect'; // Create custom tag export const UserServiceTag = Context.GenericTag('UserService'); @Service(UserServiceTag) export class UserService extends BaseService { // Service with explicit tag } // Use in Effect-based code const program = pipe( UserServiceTag, Effect.flatMap((userService) => Effect.promise(() => userService.findAll())), ); ``` ## Complete Service Example ```typescript import { Service, BaseService, Span } from '@onebun/core'; import { CacheService } from '@onebun/cache'; import { type } from 'arktype'; // Types interface User { id: string; name: string; email: string; createdAt: Date; } interface CreateUserDto { name: string; email: string; password: string; } interface UpdateUserDto { name?: string; email?: string; } interface PaginatedResult { items: T[]; total: number; page: number; limit: number; } @Service() export class UserService extends BaseService { private users = new Map(); constructor(private cacheService: CacheService) { super(); } @Span('user-find-all') async findAll(options?: { page?: number; limit?: number }): Promise> { const page = options?.page || 1; const limit = options?.limit || 10; const offset = (page - 1) * limit; this.logger.debug('Finding all users', { page, limit }); const allUsers = Array.from(this.users.values()); const items = allUsers.slice(offset, offset + limit); return { items, total: allUsers.length, page, limit, }; } @Span('user-find-by-id') async findById(id: string): Promise { // Check cache const cacheKey = `user:${id}`; const cached = await this.cacheService.get(cacheKey); if (cached) { this.logger.trace('User cache hit', { id }); return cached; } this.logger.trace('User cache miss', { id }); const user = this.users.get(id) || null; if (user) { await this.cacheService.set(cacheKey, user, { ttl: 300 }); } return user; } @Span('user-create') async create(data: CreateUserDto): Promise { // Check for duplicate email const existingUser = await this.findByEmail(data.email); if (existingUser) { this.logger.warn('Duplicate email attempt', { email: data.email }); throw new Error('Email already exists'); } const user: User = { id: crypto.randomUUID(), name: data.name, email: data.email, createdAt: new Date(), }; this.users.set(user.id, user); this.logger.info('User created', { userId: user.id, email: user.email }); return user; } @Span('user-update') async update(id: string, data: UpdateUserDto): Promise { const user = this.users.get(id); if (!user) { this.logger.warn('User not found for update', { id }); return null; } const updatedUser = { ...user, ...data }; this.users.set(id, updatedUser); // Invalidate cache await this.cacheService.delete(`user:${id}`); this.logger.info('User updated', { userId: id, fields: Object.keys(data) }); return updatedUser; } @Span('user-delete') async delete(id: string): Promise { const deleted = this.users.delete(id); if (deleted) { await this.cacheService.delete(`user:${id}`); this.logger.info('User deleted', { userId: id }); } return deleted; } private async findByEmail(email: string): Promise { for (const user of this.users.values()) { if (user.email === email) { return user; } } return null; } } ``` --- --- url: /testing.md description: >- Testing utilities for OneBun applications — unit testing helpers, integration testing module, fake timers, mock loggers, and testcontainers. --- ## Testing Utilities Internal Notes **Unit Testing Helpers** (`createTestService`, `createTestController`): * Create instances with mock logger (using `bun:test` `mock()`) and mock config * Call `initializeService()` / `initializeController()` internally so `this.logger` and `this.config` are available * Logger methods are `mock()` functions — assert with `.mock.calls` * Config returns values from the provided `config` object via `get(path)` * Dependencies passed via `deps` array are spread into the constructor **TestingModule** (integration/e2e testing): * Creates a real HTTP server on port 0 (OS picks free port) * Uses `makeMockLoggerLayer()` for silent logging * `overrideProvider()` injects mock via Effect.Context tag before `setup()` * `inject()` makes real HTTP requests via `undici.fetch` (bypasses global fetch mocks) * Always call `close()` in `afterEach` to prevent port leaks * `_testProviders` is an internal option used to pass overrides to the application **Testcontainers** (`createRedisContainer`, `createNatsContainer`): * Require Docker daemon running * Return `TestContainer` with `url`, `host`, `port`, `container`, `stop()` * Default images: `redis:7-alpine`, `nats:2.10-alpine` * NATS supports `enableJetStream: true` option * Always call `stop()` in `afterAll` to clean up containers **Mock Utilities**: * `createMockConfig(values, options)` — returns `IConfig` with `get()` returning from values map * `createMockSyncLogger()` — no-op sync logger, `child()` returns itself * `createMockLogger()` — no-op async Effect logger * `makeMockLoggerLayer()` — Effect Layer providing mock async logger * `useFakeTimers()` — replaces global `setTimeout`/`setInterval`/`Date.now`, returns control object * `FakeTimers` class and `fakeTimers` singleton are also exported for direct use, but `useFakeTimers()` is the recommended API **Exported types**: `TestInstanceResult`, `TestContainer`, `RedisContainerOptions`, `NatsContainerOptions`, `CompiledTestingModule` # Testing OneBun provides a set of testing utilities for unit and integration testing of services, controllers, and full application modules. All testing utilities are exported from `@onebun/core/testing`: ```typescript import { createTestService, createTestController, TestingModule, useFakeTimers, createMockConfig, createMockLogger, makeMockLoggerLayer, createMockSyncLogger, createRedisContainer, createNatsContainer, } from '@onebun/core/testing'; ``` ## Unit Testing — `createTestService` / `createTestController` For isolated unit testing of services and controllers without starting an HTTP server. ### `createTestService` Creates a service instance with a mock logger and mock config. Calls `initializeService()` internally, so `this.logger` and `this.config` are available in the service. ```typescript import { createTestService } from '@onebun/core/testing'; const { instance, logger, config } = createTestService(UserService); // Use the service const result = instance.findById('123'); // Assert logger calls (logger methods are bun:test mock functions) expect((logger.info as any).mock.calls).toHaveLength(1); ``` #### With config and dependencies ```typescript const { instance } = createTestService(UserService, { config: { 'database.url': 'postgres://localhost/test' }, deps: [mockRepository, mockCacheService], }); ``` **Options:** * `config` — `Record` — values returned by `config.get(path)` * `deps` — `unknown[]` — constructor arguments (injected dependencies) **Return type: `TestInstanceResult`** * `instance: T` — the created service instance * `logger: SyncLogger` — mock logger with `mock()` functions (assert with `.mock.calls`) * `config: IConfig` — mock config ### `createTestController` Same API as `createTestService`, but calls `initializeController()` instead. ```typescript import { createTestController } from '@onebun/core/testing'; const { instance, logger, config } = createTestController(UserController, { deps: [mockUserService], }); ``` ## Integration Testing — `TestingModule` For full integration testing with a real HTTP server, middleware pipeline, and DI. ### Basic Usage ```typescript import { describe, it, expect, afterEach } from 'bun:test'; import { TestingModule, type CompiledTestingModule } from '@onebun/core/testing'; describe('UserController', () => { let module: CompiledTestingModule; afterEach(async () => { await module.close(); }); it('returns users', async () => { module = await TestingModule .create({ controllers: [UserController], providers: [UserService], }) .compile(); const response = await module.inject('GET', '/users'); expect(response.status).toBe(200); }); }); ``` ### API #### `TestingModule.create(options)` Creates a new `TestingModule` builder. * `controllers` — controller classes to include * `providers` — service/provider classes to include * `imports` — pre-decorated `@Module()` classes to import #### `.overrideProvider(ServiceClass)` Replaces a service with a mock. Returns an override builder: ```typescript // Replace with a plain object module = await TestingModule .create({ controllers: [UserController], providers: [UserService] }) .overrideProvider(UserService).useValue({ findById: () => mockUser }) .compile(); // Replace with another class module = await TestingModule .create({ controllers: [UserController], providers: [UserService] }) .overrideProvider(UserService).useClass(MockUserService) .compile(); ``` #### `.setOptions(options)` Sets additional application options (`basePath`, `envSchema`, `cors`, etc.): ```typescript module = await TestingModule .create({ controllers: [UserController], providers: [UserService] }) .setOptions({ basePath: '/api', envSchema: myEnvSchema }) .compile(); ``` #### `.compile()` Starts the application on a random free port. Returns a `CompiledTestingModule`. #### `module.inject(method, path, options?)` Sends a real HTTP request to the test server: ```typescript const response = await module.inject('POST', '/users', { body: { name: 'Alice' }, headers: { 'Authorization': 'Bearer token' }, query: { include: 'profile' }, }); ``` #### `module.get(ServiceClass)` Retrieves a service instance by class: ```typescript const service = module.get(UserService); expect(service).toBeInstanceOf(UserService); ``` #### `module.getApp()` Returns the underlying `OneBunApplication` instance. #### `module.getPort()` Returns the port the test server is listening on. #### `module.getConfig()` Returns the application config. Requires `envSchema` to be set via `setOptions()`. #### `module.close()` Stops the test server and releases resources. Always call this in `afterEach` or `afterAll`. ## Testcontainers — `createRedisContainer` / `createNatsContainer` Helpers for spinning up Redis and NATS containers in tests. Requires Docker. ### `createRedisContainer` ```typescript import { createRedisContainer, type TestContainer } from '@onebun/core/testing'; let redis: TestContainer; beforeAll(async () => { redis = await createRedisContainer(); // redis.url → 'redis://localhost:55123' // redis.host → 'localhost' // redis.port → 55123 }); afterAll(async () => { await redis.stop(); }); ``` **Options:** * `image` — Docker image (default: `redis:7-alpine`) * `startupTimeout` — timeout in ms (default: `30000`) ### `createNatsContainer` ```typescript import { createNatsContainer, type TestContainer } from '@onebun/core/testing'; let nats: TestContainer; beforeAll(async () => { nats = await createNatsContainer({ enableJetStream: true }); // nats.url → 'nats://localhost:55124' }); afterAll(async () => { await nats.stop(); }); ``` **Options:** * `image` — Docker image (default: `nats:2.10-alpine`) * `startupTimeout` — timeout in ms (default: `30000`) * `enableJetStream` — enable JetStream (default: `false`) ### `TestContainer` interface ```typescript interface TestContainer { url: string; // Full connection URL host: string; // Container host port: number; // Mapped port container: StartedTestContainer; // testcontainers instance stop(): Promise; // Stop and remove container } ``` ## Other Utilities ### `useFakeTimers` Replaces global `setTimeout`, `setInterval`, `clearTimeout`, `clearInterval`, and `Date.now` with controllable fakes. Useful for testing timer-based logic without real delays. ```typescript import { useFakeTimers } from '@onebun/core/testing'; import { describe, it, expect, afterEach } from 'bun:test'; describe('TimerService', () => { const timers = useFakeTimers(); afterEach(() => { timers.restore(); }); it('executes callback after delay', () => { let called = false; setTimeout(() => { called = true; }, 1000); timers.advanceTime(999); expect(called).toBe(false); timers.advanceTime(1); expect(called).toBe(true); }); }); ``` **Returned methods:** * `advanceTime(ms)` — advance time by `ms` milliseconds, executing any due timers * `runAllTimers()` — execute all pending `setTimeout` callbacks (not intervals) * `now()` — get current fake time * `getTimerCount()` — get number of pending timers * `clearAllTimers()` — clear all pending timers without executing * `restore()` — restore real timers ### `createMockConfig` Creates a mock `IConfig` object for testing. Returns values from the provided map via `get(path)`. ```typescript import { createMockConfig } from '@onebun/core/testing'; const config = createMockConfig({ 'server.port': 3000, 'server.host': '0.0.0.0', }); config.get('server.port'); // 3000 config.isInitialized; // true ``` **Options:** * `values` — `Record` — config values * `options.isInitialized` — whether config is initialized (default: `true`) ### `createMockLogger` Creates a silent async `Logger` (Effect-based). All methods return `Effect.succeed(undefined)`, `child()` returns itself. ```typescript import { createMockLogger } from '@onebun/core/testing'; const logger = createMockLogger(); // Use with Effect programs that require Logger ``` ### `makeMockLoggerLayer` Creates an Effect `Layer` that provides a silent mock logger. Used internally by `TestingModule`. ```typescript import { makeMockLoggerLayer } from '@onebun/core/testing'; const loggerLayer = makeMockLoggerLayer(); // Use with Effect.provide(loggerLayer) ``` ### `createMockSyncLogger` Creates a silent no-op `SyncLogger`. All methods are no-ops, `child()` returns itself. ```typescript import { createMockSyncLogger } from '@onebun/core/testing'; const logger = createMockSyncLogger(); logger.info('this does nothing'); logger.child({ context: 'test' }); // returns same logger ``` --- --- url: /api/trace.md description: >- Distributed tracing with @Span decorator. TraceService, W3C trace context, span attributes, OpenTelemetry-compatible export. --- # Tracing API Package: `@onebun/trace` ## Overview OneBun provides OpenTelemetry-compatible distributed tracing with: * Automatic HTTP request tracing * Context propagation between services * Custom span creation * Integration with logging ## Enabling Tracing ### In Application ```typescript import { OneBunApplication } from '@onebun/core'; import { AppModule } from './app.module'; const app = new OneBunApplication(AppModule, { tracing: { enabled: true, serviceName: 'my-service', serviceVersion: '1.0.0', samplingRate: 1.0, // 100% of requests traceHttpRequests: true, traceDatabaseQueries: true, defaultAttributes: { 'service.name': 'my-service', 'deployment.environment': process.env.NODE_ENV, }, }, }); ``` ### Configuration Options ```typescript interface TracingOptions { /** Enable/disable tracing (default: true) */ enabled?: boolean; /** Service name for traces (default: 'onebun-service') */ serviceName?: string; /** Service version (default: '1.0.0') */ serviceVersion?: string; /** Sampling rate 0.0-1.0 (default: 1.0) */ samplingRate?: number; /** Auto-trace HTTP requests (default: true) */ traceHttpRequests?: boolean; /** Auto-trace database queries (default: true) */ traceDatabaseQueries?: boolean; /** Default span attributes */ defaultAttributes?: Record; /** Export configuration */ exportOptions?: { endpoint?: string; headers?: Record; timeout?: number; batchSize?: number; batchTimeout?: number; }; } ``` ## @Span() Decorator Create trace spans for methods. ```typescript import { Service, BaseService, Span } from '@onebun/core'; @Service() export class UserService extends BaseService { @Span('find-user-by-id') async findById(id: string): Promise { // This method execution is traced return this.repository.findById(id); } @Span() // Uses method name as span name async processUser(user: User): Promise { // Span name: "processUser" await this.validate(user); await this.save(user); } @Span('user-search') async search(query: string): Promise { // Nested spans are automatically linked const normalized = await this.normalizeQuery(query); return this.repository.search(normalized); } @Span('normalize-query') private async normalizeQuery(query: string): Promise { return query.toLowerCase().trim(); } } ``` ## Trace Context ### Automatic HTTP Context Trace context is automatically extracted from and propagated via HTTP headers: ``` traceparent: 00-abc123def456789-span123-01 tracestate: onebun=true x-trace-id: abc123def456789 x-span-id: span123 ``` ### Accessing Current Context ```typescript @Service() export class MyService extends BaseService { async doSomething(): Promise { // Access trace service const traceService = (globalThis as any).__onebunTraceService; if (traceService) { // Get current trace context const context = await Effect.runPromise( traceService.getCurrentTraceContext() ); this.logger.info('Current trace', { traceId: context.traceId, spanId: context.spanId, }); } } } ``` ### Context Propagation When making HTTP calls, trace context is automatically propagated: ```typescript import { createHttpClient } from '@onebun/core'; const client = createHttpClient({ baseUrl: 'http://other-service:3000', }); // Trace headers are automatically added to outgoing requests const response = await client.get('/api/data'); ``` ## Manual Span Creation ```typescript @Service() export class OrderService extends BaseService { private traceService: any; constructor() { super(); this.traceService = (globalThis as any).__onebunTraceService; } async processOrder(orderId: string): Promise { if (!this.traceService) { return this.doProcess(orderId); } // Start a span manually const span = await Effect.runPromise( this.traceService.startSpan('process-order', { orderId, operation: 'processOrder', }) ); try { // Add events during processing await Effect.runPromise( this.traceService.addEvent('validation-started') ); await this.validateOrder(orderId); await Effect.runPromise( this.traceService.addEvent('validation-completed') ); // Process the order const order = await this.doProcess(orderId); // Add attributes await Effect.runPromise( this.traceService.setAttributes({ 'order.status': order.status, 'order.total': order.total, }) ); return order; } catch (error) { // Record error in span await Effect.runPromise( this.traceService.addEvent('error', { errorType: error instanceof Error ? error.name : 'UnknownError', errorMessage: error instanceof Error ? error.message : String(error), }) ); throw error; } finally { // End the span await Effect.runPromise( this.traceService.endSpan(span) ); } } } ``` ## Trace-Log Integration Logs automatically include trace context: ```json { "level": "info", "message": "Processing order", "timestamp": "2024-01-15T10:30:45.123Z", "context": { "orderId": "abc-123" }, "trace": { "traceId": "abc123def456789", "spanId": "span456", "parentSpanId": "span123" } } ``` ## Exporting Traces ### OTLP Exporter ```typescript const app = new OneBunApplication(AppModule, { tracing: { enabled: true, serviceName: 'my-service', exportOptions: { endpoint: 'http://jaeger:4318/v1/traces', // OTLP HTTP endpoint headers: { 'Authorization': 'Bearer token', }, timeout: 10000, batchSize: 100, batchTimeout: 5000, }, }, }); ``` ### Jaeger Integration ```yaml # docker-compose.yml services: jaeger: image: jaegertracing/all-in-one:latest ports: - "16686:16686" # UI - "4318:4318" # OTLP HTTP environment: - COLLECTOR_OTLP_ENABLED=true ``` ## Sampling Control what percentage of requests are traced: ```typescript tracing: { // Sample 10% of requests in production samplingRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0, } ``` ## Best Practices ### 1. Meaningful Span Names ```typescript // Good: descriptive, includes operation type @Span('user-create') @Span('order-process-payment') @Span('cache-lookup') // Bad: too generic @Span('process') @Span('do-thing') ``` ### 2. Add Relevant Attributes ```typescript @Span('user-find-by-id') async findById(id: string): Promise { // Add context that helps with debugging const traceService = (globalThis as any).__onebunTraceService; if (traceService) { await Effect.runPromise( traceService.setAttributes({ 'user.id': id, 'db.system': 'postgresql', 'db.operation': 'SELECT', }) ); } return this.repository.findById(id); } ``` ### 3. Trace Error Boundaries ```typescript @Span('process-order') async processOrder(orderId: string): Promise { try { return await this.doProcess(orderId); } catch (error) { // Trace service will record the error this.logger.error('Order processing failed', error); throw error; } } ``` ### 4. Don't Over-Trace ```typescript // Good: trace business-significant operations @Span('place-order') async placeOrder(data: OrderData): Promise {} // Avoid: tracing every tiny utility function // @Span('format-date') // Too granular formatDate(date: Date): string {} ``` ## Complete Example ```typescript import { Module, Controller, BaseController, Service, BaseService, Get, Post, Param, Body, Span } from '@onebun/core'; // Service with comprehensive tracing @Service() export class OrderService extends BaseService { private traceService: any; constructor( private paymentService: PaymentService, private inventoryService: InventoryService, ) { super(); this.traceService = (globalThis as any).__onebunTraceService; } @Span('order-create') async createOrder(data: CreateOrderDto): Promise { this.logger.info('Creating order', { customerId: data.customerId }); // Validate items await this.validateItems(data.items); // Create order record const order = await this.repository.create(data); // Add trace attributes await this.addTraceAttributes({ 'order.id': order.id, 'order.item_count': order.items.length, 'order.total': order.total, }); return order; } @Span('order-process-payment') async processPayment(orderId: string): Promise { const order = await this.repository.findById(orderId); // Nested traced call const result = await this.paymentService.charge({ orderId, amount: order.total, currency: 'USD', }); await this.addTraceEvent('payment-completed', { transactionId: result.transactionId, status: result.status, }); return result; } @Span('order-validate-items') private async validateItems(items: OrderItem[]): Promise { for (const item of items) { const available = await this.inventoryService.checkStock(item.productId); if (available < item.quantity) { await this.addTraceEvent('validation-failed', { productId: item.productId, requested: item.quantity, available, }); throw new Error(`Insufficient stock for product ${item.productId}`); } } await this.addTraceEvent('validation-passed', { itemCount: items.length, }); } private async addTraceAttributes(attributes: Record): Promise { if (this.traceService) { await Effect.runPromise( this.traceService.setAttributes(attributes) ); } } private async addTraceEvent(name: string, attributes?: Record): Promise { if (this.traceService) { await Effect.runPromise( this.traceService.addEvent(name, attributes) ); } } } // Controller @Controller('/orders') export class OrderController extends BaseController { constructor(private orderService: OrderService) { super(); } @Post('/') async create(@Body() body: CreateOrderDto): Promise { const order = await this.orderService.createOrder(body); return this.success(order, 201); } @Post('/:id/pay') async pay(@Param('id') id: string): Promise { const result = await this.orderService.processPayment(id); return this.success(result); } } // Application with tracing const app = new OneBunApplication(AppModule, { tracing: { enabled: true, serviceName: 'order-service', serviceVersion: '1.0.0', samplingRate: 1.0, defaultAttributes: { 'service.name': 'order-service', 'deployment.environment': process.env.NODE_ENV || 'development', }, exportOptions: { endpoint: process.env.OTLP_ENDPOINT || 'http://jaeger:4318/v1/traces', }, }, }); ``` --- --- url: /api/validation.md description: >- Schema validation with ArkType. validate(), validateOrThrow(), toJsonSchema(). Built-in schema helpers. --- # Validation API Package: `@onebun/core` (uses ArkType) OneBun uses [ArkType](https://arktype.io/) for runtime type validation and TypeScript type inference. ## Basic Usage ### Defining Schemas ```typescript import { type } from 'arktype'; // Primitive types const stringSchema = type('string'); const numberSchema = type('number'); const booleanSchema = type('boolean'); // Object schema const userSchema = type({ name: 'string', email: 'string.email', age: 'number > 0', }); // Infer TypeScript type from schema type User = typeof userSchema.infer; // { name: string; email: string; age: number } ``` ### Using in Controllers ```typescript import { Controller, BaseController, Post, Body } from '@onebun/core'; import { type } from 'arktype'; const createUserSchema = type({ name: 'string', email: 'string.email', 'age?': 'number > 0', // Optional field }); @Controller('/users') export class UserController extends BaseController { @Post('/') async create( // Body is validated and typed @Body(createUserSchema) body: typeof createUserSchema.infer, ): Promise { // body is guaranteed to be valid here // body.name: string // body.email: string // body.age: number | undefined return this.success({ user: body }); } } ``` ## Schema Types ### Primitives ```typescript import { type } from 'arktype'; type('string') // string type('number') // number type('boolean') // boolean type('bigint') // bigint type('symbol') // symbol type('null') // null type('undefined') // undefined ``` ### String Constraints ```typescript // Built-in string formats type('string.email') // Valid email type('string.url') // Valid URL type('string.uuid') // Valid UUID type('string.date') // Date string (YYYY-MM-DD) type('string.datetime') // ISO datetime string type('string.numeric') // String containing only digits // Length constraints type('string > 5') // Length > 5 type('string >= 5') // Length >= 5 type('string < 100') // Length < 100 type('string <= 100') // Length <= 100 type('5 <= string < 100') // Length between 5 and 99 // Pattern matching type('/^[a-z]+$/') // Regex pattern type('string.alphanumeric') // Only letters and numbers // Transformations type('string.trim') // Trim whitespace type('string.lower') // To lowercase type('string.upper') // To uppercase ``` ### Number Constraints ```typescript // Comparisons type('number > 0') // Positive numbers type('number >= 0') // Non-negative type('number < 100') // Less than 100 type('0 < number < 100') // Range (exclusive) type('0 <= number <= 100') // Range (inclusive) // Integer type('number.integer') // Integer only type('integer > 0') // Positive integer // Special values type('number.positive') // > 0 type('number.negative') // < 0 type('number.nonNegative') // >= 0 ``` ### Arrays ```typescript // Basic array type('string[]') // Array of strings type('number[]') // Array of numbers // Array with constraints type('string[] > 0') // Non-empty array type('string[] <= 10') // Max 10 items type('1 <= string[] <= 10') // Between 1 and 10 items // Complex item types const userArraySchema = type({ name: 'string', age: 'number', }).array(); ``` ### Objects ```typescript // Required fields const schema = type({ name: 'string', email: 'string.email', }); // Optional fields (use '?' suffix) const schema = type({ name: 'string', 'email?': 'string.email', // Optional 'age?': 'number > 0', // Optional }); // Nested objects const schema = type({ user: { name: 'string', address: { street: 'string', city: 'string', }, }, }); // Index signatures const schema = type({ '+': 'string', // Allow additional string properties }); ``` ### Unions and Intersections ```typescript // Union (OR) type('string | number') // string or number type('"active" | "inactive"') // Literal union type('"admin" | "user" | "guest"') // Enum-like // Intersection (AND) const baseSchema = type({ id: 'string' }); const extendedSchema = type({ name: 'string' }); const combined = baseSchema.and(extendedSchema); // { id: string; name: string } ``` ### Literals ```typescript // Literal values type('42') // Exactly 42 type('"hello"') // Exactly "hello" type('true') // Exactly true // Enum-like type('"red" | "green" | "blue"') ``` ### Tuples ```typescript // Fixed-length arrays type(['string', 'number']) // [string, number] type(['string', 'number', 'boolean']) // [string, number, boolean] ``` ## Validation Functions ### validate() Validate data against a schema, returning a result object. ```typescript import { validate } from '@onebun/core'; import { type } from 'arktype'; const schema = type({ name: 'string', age: 'number > 0', }); const result = validate(schema, { name: 'John', age: 30 }); if (result.success) { // result.data is typed as { name: string; age: number } console.log(result.data.name); } else { // result.errors is string[] console.error(result.errors); } ``` **Return Type:** ```typescript type ValidationResult = | { success: true; data: T } | { success: false; errors: string[] }; ``` ### validateOrThrow() Validate data and throw an error if validation fails. ```typescript import { validateOrThrow } from '@onebun/core'; import { type } from 'arktype'; const schema = type({ name: 'string', email: 'string.email', }); try { const data = validateOrThrow(schema, inputData); // data is typed as { name: string; email: string } } catch (error) { // error.message: "Validation failed: ..." // error.validationErrors: string[] } ``` ## Common Schema Patterns ### Create/Update DTOs ```typescript import { type } from 'arktype'; // Create DTO - all fields required const createUserSchema = type({ name: 'string', email: 'string.email', password: 'string >= 8', role: '"admin" | "user"', }); // Update DTO - all fields optional const updateUserSchema = type({ 'name?': 'string', 'email?': 'string.email', 'password?': 'string >= 8', 'role?': '"admin" | "user"', }); // Export types export type CreateUserDto = typeof createUserSchema.infer; export type UpdateUserDto = typeof updateUserSchema.infer; ``` ### Pagination ```typescript const paginationSchema = type({ 'page?': 'number.integer > 0', 'limit?': 'number.integer > 0', 'sort?': '"asc" | "desc"', 'sortBy?': 'string', }); @Get('/') async findAll( @Query('page') page?: string, @Query('limit') limit?: string, ): Promise { const pagination = validate(paginationSchema, { page: page ? parseInt(page) : 1, limit: limit ? parseInt(limit) : 10, }); if (!pagination.success) { return this.error('Invalid pagination', 400, 400); } // Use pagination.data } ``` ### API Request Body ```typescript const createOrderSchema = type({ customerId: 'string.uuid', items: type({ productId: 'string.uuid', quantity: 'number.integer > 0', 'notes?': 'string', }).array().configure({ minLength: 1 }), 'shippingAddress?': { street: 'string', city: 'string', country: 'string', 'zipCode?': 'string', }, 'paymentMethod': '"card" | "paypal" | "bank_transfer"', }); @Post('/orders') async createOrder( @Body(createOrderSchema) body: typeof createOrderSchema.infer, ): Promise { // body is fully typed and validated const order = await this.orderService.create(body); return this.success(order, 201); } ``` ### Response Validation ```typescript import { ApiResponse } from '@onebun/core'; const userResponseSchema = type({ id: 'string.uuid', name: 'string', email: 'string.email', createdAt: 'string.datetime', }); @Controller('/users') export class UserController extends BaseController { @Get('/:id') @ApiResponse(200, { schema: userResponseSchema, description: 'User found', }) @ApiResponse(404, { description: 'User not found', }) async findOne(@Param('id') id: string): Promise { const user = await this.userService.findById(id); if (!user) { return this.error('User not found', 404, 404); } // Response will be validated against userResponseSchema return this.success(user); } } ``` ### Complex Nested Schema ```typescript const apiRequestSchema = type({ // Auth header auth: { token: 'string', 'refreshToken?': 'string', }, // Request metadata meta: { requestId: 'string.uuid', timestamp: 'string.datetime', 'source?': '"web" | "mobile" | "api"', }, // Actual payload payload: { action: '"create" | "update" | "delete"', resource: 'string', data: {}, // Any object }, }); ``` ## Error Messages ArkType provides detailed error messages: ```typescript const schema = type({ name: 'string > 2', age: 'number >= 18', }); const result = schema({ name: 'Jo', age: 16 }); if (result instanceof type.errors) { console.log(result.summary); // "name must be more than 2 characters (was 2)" // "age must be at least 18 (was 16)" } ``` ## JSON Schema Conversion Convert ArkType schemas to JSON Schema for OpenAPI/Swagger: ```typescript import { toJsonSchema, getJsonSchema } from '@onebun/core'; import { type } from 'arktype'; const userSchema = type({ name: 'string', age: 'number > 0', }); // Basic conversion const jsonSchema = toJsonSchema(userSchema); // With fallback for unsupported types const jsonSchemaWithFallback = getJsonSchema(userSchema, { fallback: (ctx) => ({ ...ctx.base, description: 'Custom fallback' }), }); // Result: // { // type: 'object', // properties: { // name: { type: 'string' }, // age: { type: 'number', exclusiveMinimum: 0 }, // }, // required: ['name', 'age'], // } ``` ## Best Practices ### 1. Define Schemas in Separate Files ```typescript // schemas/user.schema.ts import { type } from 'arktype'; export const createUserSchema = type({ name: 'string', email: 'string.email', }); export const updateUserSchema = type({ 'name?': 'string', 'email?': 'string.email', }); export type CreateUserDto = typeof createUserSchema.infer; export type UpdateUserDto = typeof updateUserSchema.infer; ``` ### 2. Reuse Schema Components ```typescript const addressSchema = type({ street: 'string', city: 'string', country: 'string', }); const userSchema = type({ name: 'string', email: 'string.email', address: addressSchema, }); const companySchema = type({ name: 'string', address: addressSchema, // Reused }); ``` ### 3. Use Type Inference ```typescript // Let ArkType infer the type const schema = type({ name: 'string', age: 'number', }); // Use inferred type everywhere type User = typeof schema.infer; function processUser(user: User) { // Fully typed } ``` ### 4. Validate Early ```typescript @Post('/') async create(@Body(createUserSchema) body: typeof createUserSchema.infer) { // Validation happens at parameter extraction // Body is guaranteed valid here return this.success(await this.service.create(body)); } ``` --- --- url: /examples/websocket-chat.md description: >- Real-time chat application with WebSocket Gateway. Rooms, message broadcasting, connection handling. --- # WebSocket Chat Application This example shows how to build a real-time chat application with OneBun WebSocket Gateway: gateway, module, application config, and two client options (native and Socket.IO). ## Overview We'll create a chat application with: * Multiple chat rooms * User authentication * Message broadcasting * Room management ## Project structure ``` src/ ├── chat.gateway.ts # WebSocket gateway ├── chat.service.ts # Business logic ├── chat.module.ts # Module definition ├── auth.guard.ts # Custom guard └── index.ts # Application entry ``` ## Step 1: Create the gateway Define the WebSocket gateway with connection and room handlers. Use `@WebSocketGateway({ path: '/chat' })` so clients connect to `/chat`. ```typescript // src/chat.gateway.ts import { WebSocketGateway, BaseWebSocketGateway, OnConnect, OnDisconnect, OnJoinRoom, OnLeaveRoom, OnMessage, Client, MessageData, RoomName, PatternParams, UseWsGuards, } from '@onebun/core'; import type { WsClientData } from '@onebun/core'; import { ChatService } from './chat.service'; import { ChatAuthGuard } from './auth.guard'; interface ChatMessage { text: string; } @WebSocketGateway({ path: '/chat' }) export class ChatGateway extends BaseWebSocketGateway { constructor(private chatService: ChatService) { super(); } @OnConnect() async handleConnect(@Client() client: WsClientData) { this.logger.info(`Client ${client.id} connected`); // Send welcome message return { event: 'welcome', data: { message: 'Welcome to the chat!', clientId: client.id, timestamp: Date.now(), }, }; } @OnDisconnect() async handleDisconnect(@Client() client: WsClientData) { this.logger.info(`Client ${client.id} disconnected`); // Notify all rooms the client was in for (const room of client.rooms) { this.emitToRoom(room, 'user:left', { userId: client.id, room, }); } } @OnJoinRoom('room:{roomId}') async handleJoinRoom( @Client() client: WsClientData, @RoomName() room: string, @PatternParams() params: { roomId: string }, ) { this.logger.info(`Client ${client.id} joining room ${params.roomId}`); // Add to room await this.joinRoom(client.id, room); // Notify others in the room this.emitToRoom(room, 'user:joined', { userId: client.id, room, }, [client.id]); // Exclude the joining user // Get message history const history = await this.chatService.getMessageHistory(params.roomId); return { event: 'room:joined', data: { room: params.roomId, history, users: await this.getClientsInRoom(room), }, }; } @OnLeaveRoom('room:{roomId}') async handleLeaveRoom( @Client() client: WsClientData, @RoomName() room: string, @PatternParams() params: { roomId: string }, ) { // Remove from room await this.leaveRoom(client.id, room); // Notify others this.emitToRoom(room, 'user:left', { userId: client.id, room, }); } @UseWsGuards(ChatAuthGuard) @OnMessage('chat:{roomId}:message') async handleMessage( @Client() client: WsClientData, @MessageData() data: ChatMessage, @PatternParams() params: { roomId: string }, ) { // Validate client is in the room if (!client.rooms.includes(`room:${params.roomId}`)) { return { event: 'error', data: { message: 'Not in room' }, }; } // Save message const message = await this.chatService.saveMessage({ roomId: params.roomId, userId: client.id, text: data.text, timestamp: Date.now(), }); // Broadcast to room this.emitToRoom(`room:${params.roomId}`, 'chat:message', message); // Acknowledge sender return { event: 'chat:message:ack', data: { messageId: message.id }, }; } @OnMessage('typing:{roomId}') handleTyping( @Client() client: WsClientData, @PatternParams() params: { roomId: string }, ) { // Broadcast typing indicator (except to sender) this.emitToRoom( `room:${params.roomId}`, 'typing', { userId: client.id }, [client.id], ); } // Helper method to get clients in a room private async getClientsInRoom(roomName: string): Promise { const clients = await super.getClientsByRoom(roomName); return clients.map(c => c.id); } } ``` ## Step 2: Chat service Business logic for messages and history. ```typescript // src/chat.service.ts import { Service } from '@onebun/core'; interface Message { id: string; roomId: string; userId: string; text: string; timestamp: number; } @Service() export class ChatService { private messages: Map = new Map(); private messageIdCounter = 0; async saveMessage(data: Omit): Promise { const message: Message = { id: `msg_${++this.messageIdCounter}`, ...data, }; const roomMessages = this.messages.get(data.roomId) || []; roomMessages.push(message); this.messages.set(data.roomId, roomMessages); return message; } async getMessageHistory(roomId: string, limit = 50): Promise { const roomMessages = this.messages.get(roomId) || []; return roomMessages.slice(-limit); } async clearRoom(roomId: string): Promise { this.messages.delete(roomId); } } ``` ## Step 3: Auth guard Optional guard to require authentication on message handlers. ```typescript // src/auth.guard.ts import { createGuard, type WsExecutionContext } from '@onebun/core'; export const ChatAuthGuard = createGuard((context: WsExecutionContext) => { const client = context.getClient(); // Check if client has authenticated if (!client.auth?.authenticated) { return false; } // Optional: Check for specific permissions // return client.auth.permissions?.includes('chat:send') ?? false; return true; }); ``` ## Step 4: Register the gateway in the module **A WebSocket gateway is a controller.** Add `ChatGateway` to the module's `controllers` array so the framework discovers it. Do not add it to `providers`. ```typescript // src/chat.module.ts import { Module } from '@onebun/core'; import { ChatGateway } from './chat.gateway'; import { ChatService } from './chat.service'; @Module({ controllers: [ChatGateway], // Gateways are controllers — register here providers: [ChatService], }) export class ChatModule {} ``` ## Step 5: Application entry and WebSocket config Create the application and pass `websocket` in the options. You can optionally enable Socket.IO on a separate path. ```typescript // src/index.ts import { OneBunApplication } from '@onebun/core'; import { ChatModule } from './chat.module'; const app = new OneBunApplication(ChatModule, { port: 3000, websocket: { // Optional: enable Socket.IO on /socket.io for socket.io-client // socketio: { enabled: true, path: '/socket.io' }, }, }); await app.start(); console.log('Chat server running on http://localhost:3000'); console.log('Native WebSocket: ws://localhost:3000/chat'); // If socketio.enabled: Socket.IO at ws://localhost:3000/socket.io ``` ## Client implementation You can use: **typed client** (with definition), **standalone client** (no definition, same API), or **Socket.IO** (enable `socketio` in app config). ### Option A: Typed client (native WebSocket, with definition) Connect to the gateway path. Default `protocol` is `'native'`. ```typescript // client-native.ts import { createWsServiceDefinition, createWsClient } from '@onebun/core'; import { ChatModule } from './chat.module'; const definition = createWsServiceDefinition(ChatModule); const client = createWsClient(definition, { url: 'ws://localhost:3000/chat', protocol: 'native', auth: { token: 'user-jwt-token' }, reconnect: true, reconnectInterval: 2000, maxReconnectAttempts: 5, }); // Connection lifecycle client.on('connect', () => { console.log('Connected to chat server'); }); client.on('disconnect', (reason) => { console.log('Disconnected:', reason); }); client.on('reconnect', (attempt) => { console.log(`Reconnected after ${attempt} attempts`); }); client.on('error', (error) => { console.error('Connection error:', error); }); // Connect await client.connect(); // Subscribe to events client.ChatGateway.on('welcome', (data) => { console.log('Welcome message:', data.message); }); client.ChatGateway.on('chat:message', (message) => { console.log(`[${message.userId}]: ${message.text}`); }); client.ChatGateway.on('user:joined', (data) => { console.log(`User ${data.userId} joined ${data.room}`); }); client.ChatGateway.on('user:left', (data) => { console.log(`User ${data.userId} left ${data.room}`); }); client.ChatGateway.on('typing', (data) => { console.log(`${data.userId} is typing...`); }); // Join a room const roomInfo = await client.ChatGateway.emit('join', 'room:general'); console.log('Joined room with history:', roomInfo.history); // Send a message const ack = await client.ChatGateway.emit('chat:general:message', { text: 'Hello everyone!', }); console.log('Message sent, id:', ack.messageId); // Send typing indicator client.ChatGateway.send('typing:general', {}); // Leave room when done client.ChatGateway.send('leave', 'room:general'); // Disconnect client.disconnect(); ``` ### Option B: Standalone client (no definition) Use when the frontend does not depend on the backend module (e.g. in a monorepo). Same message format and API as the typed client. ```typescript // client-standalone.ts import { createNativeWsClient } from '@onebun/core'; const client = createNativeWsClient({ url: 'ws://localhost:3000/chat', protocol: 'native', auth: { token: 'user-jwt-token' }, reconnect: true, }); client.on('connect', () => console.log('Connected to chat')); client.on('welcome', (data) => console.log('Welcome:', data.message)); client.on('chat:message', (msg) => console.log(`[${msg.userId}]: ${msg.text}`)); client.on('user:joined', (data) => console.log(`User ${data.userId} joined`)); client.on('user:left', (data) => console.log(`User ${data.userId} left`)); await client.connect(); // Join room (server expects event 'join' with room name as data) const roomInfo = await client.emit('join', 'room:general'); console.log('Joined room:', roomInfo); await client.emit('chat:general:message', { text: 'Hello everyone!' }); client.send('typing:general', {}); client.send('leave', 'room:general'); client.disconnect(); ``` ### Option C: socket.io-client (Socket.IO protocol) Enable Socket.IO in the application config (`websocket.socketio.enabled: true`), then connect to the server origin with `path: '/socket.io'`. ```typescript // client-socketio.ts import { io } from 'socket.io-client'; const socket = io('http://localhost:3000', { path: '/socket.io', auth: { token: 'user-jwt-token' }, transports: ['websocket'], }); socket.on('connect', () => { console.log('Connected with id:', socket.id); }); socket.on('welcome', (data) => { console.log('Welcome:', data); }); socket.on('chat:message', (message) => { console.log('Message:', message); }); // Join room socket.emit('join', 'room:general', (response) => { console.log('Room joined:', response); }); // Send message socket.emit('chat:general:message', { text: 'Hello!' }, (ack) => { console.log('Message acknowledged:', ack); }); socket.disconnect(); ``` ### Option D: Typed client with Socket.IO If Socket.IO is enabled on the server, you can use the typed client with `protocol: 'socketio'` and the Socket.IO path: ```typescript const client = createWsClient(definition, { url: 'ws://localhost:3000/socket.io', protocol: 'socketio', auth: { token: 'user-jwt-token' }, }); await client.connect(); // Same API: client.ChatGateway.emit(...), client.ChatGateway.on(...) ``` ## Authentication ### Token-based Authentication Clients can authenticate by providing a token in the connection options: ```typescript const client = createWsClient(definition, { url: 'ws://localhost:3000/chat', auth: { token: 'your-jwt-token', }, }); ``` The token can be validated in a connect handler or guard: ```typescript @OnConnect() async handleConnect(@Client() client: WsClientData) { if (client.auth?.token) { try { const decoded = await verifyJwtToken(client.auth.token); client.auth.authenticated = true; client.auth.userId = decoded.userId; client.auth.permissions = decoded.permissions; } catch { // Token invalid client.auth.authenticated = false; } } } ``` ## Running the Example 1. Start the server: ```bash bun run src/index.ts ``` 2. Connect clients using the typed client or socket.io-client. ## Testing ```typescript // chat.test.ts import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; import { OneBunApplication, createWsServiceDefinition, createWsClient } from '@onebun/core'; import { ChatModule } from './chat.module'; describe('Chat WebSocket', () => { let app: OneBunApplication; let client: ReturnType; beforeAll(async () => { app = new OneBunApplication(ChatModule, { port: 3001 }); await app.start(); const definition = createWsServiceDefinition(ChatModule); client = createWsClient(definition, { url: 'ws://localhost:3001/chat', }); await client.connect(); }); afterAll(async () => { client.disconnect(); await app.stop(); }); it('should receive welcome message on connect', (done) => { client.ChatGateway.on('welcome', (data) => { expect(data.message).toBe('Welcome to the chat!'); done(); }); }); it('should join room and receive history', async () => { const response = await client.ChatGateway.emit('join', 'room:test'); expect(response.room).toBe('test'); expect(response.history).toBeArray(); }); it('should send and receive messages', async () => { // Join room first await client.ChatGateway.emit('join', 'room:test'); // Setup listener const messagePromise = new Promise((resolve) => { client.ChatGateway.on('chat:message', resolve); }); // Send message const ack = await client.ChatGateway.emit('chat:test:message', { text: 'Hello' }); expect(ack.messageId).toBeDefined(); // Verify received const received = await messagePromise; expect(received.text).toBe('Hello'); }); }); ``` --- --- url: /api/websocket.md description: >- @WebSocketGateway decorator, message handlers (@OnMessage, @OnConnect), rooms, guards, native WebSocket and Socket.IO. --- # WebSocket Gateway API ## Overview OneBun provides a WebSocket Gateway system similar to NestJS. Two protocols are supported: * **Native WebSocket** — default; simple JSON messages `{ event, data }`. No extra handshake or heartbeat. * **Socket.IO** — optional; runs on a separate path (e.g. `/socket.io`), full Engine.IO/Socket.IO protocol for compatibility with `socket.io-client`. Gateways are **controllers**: you register them in the module's `controllers` array and pass WebSocket options to the application. ## Quick Start Minimal setup: a gateway, a module that lists it as a controller, and application options that enable WebSocket. ```typescript // gateway.ts import { WebSocketGateway, BaseWebSocketGateway, OnConnect, OnMessage, Client, MessageData } from '@onebun/core'; import type { WsClientData } from '@onebun/core'; @WebSocketGateway({ path: '/ws' }) export class AppGateway extends BaseWebSocketGateway { @OnConnect() handleConnect(@Client() client: WsClientData) { return { event: 'welcome', data: { id: client.id } }; } @OnMessage('ping') handlePing() { return { event: 'pong', data: {} }; } } ``` ```typescript // app.module.ts import { Module } from '@onebun/core'; import { AppGateway } from './gateway'; @Module({ controllers: [AppGateway], // Gateways are controllers — add your gateway here providers: [], }) export class AppModule {} ``` ```typescript // index.ts import { OneBunApplication } from '@onebun/core'; import { AppModule } from './app.module'; const app = new OneBunApplication(AppModule, { port: 3000, websocket: { // Optional: storage, socketio, maxPayload }, }); await app.start(); // Native WebSocket: ws://localhost:3000/ws ``` > **Gateways are controllers**\ > A WebSocket gateway is a controller. You must add it to your module's `controllers` array (e.g. `@Module({ controllers: [AppGateway, ChatGateway], providers: [...] })`). The framework discovers gateways from that list and does not register HTTP routes for them. ## Configuration Pass `websocket` in the application options to enable and tune WebSocket and optionally Socket.IO. ### Application options ```typescript const app = new OneBunApplication(AppModule, { port: 3000, websocket: { enabled: true, // default: auto (enabled if gateways exist) storage: { type: 'memory', // 'memory' | 'redis' redis: { url: 'redis://localhost:6379', prefix: 'ws:', }, }, socketio: { enabled: false, // set true to enable Socket.IO on a separate path path: '/socket.io', pingInterval: 25000, pingTimeout: 20000, }, maxPayload: 1048576, // 1MB }, }); ``` | Option | Type | Default | Description | |--------|------|---------|-------------| | `enabled` | `boolean` | auto | Enable WebSocket (auto when gateways exist) | | `storage` | object | - | `memory` or `redis` storage | | `socketio.enabled` | `boolean` | `false` | Enable Socket.IO protocol on `socketio.path` | | `socketio.path` | `string` | `'/socket.io'` | Path for Socket.IO connections | | `socketio.pingInterval` | `number` | `25000` | Socket.IO heartbeat interval (ms) | | `socketio.pingTimeout` | `number` | `20000` | Socket.IO heartbeat timeout (ms) | | `maxPayload` | `number` | `1048576` | Max message size (bytes) | On startup you will see separate log lines for each protocol, for example: * `WebSocket server (native) enabled at ws://127.0.0.1:3000` * `WebSocket server (Socket.IO) enabled at ws://127.0.0.1:3000/socket.io` (only when `socketio.enabled` is true) ## Native WebSocket Default mode. Clients connect to the gateway path (e.g. `ws://host:port/ws`). Messages are plain JSON: `{ "event": "string", "data": any, "ack"?: number }`. ### Message format * **Client → Server**: `{ "event": "eventName", "data": payload, "ack"?: number }` * **Server → Client**: same shape; `ack` used for request-response. ### Typed client (native) Use `createWsClient` with `protocol: 'native'` (or omit; it is the default). Connect to the gateway path. ```typescript import { createWsServiceDefinition, createWsClient } from '@onebun/core'; import { AppModule } from './app.module'; const definition = createWsServiceDefinition(AppModule); const client = createWsClient(definition, { url: 'ws://localhost:3000/ws', protocol: 'native', auth: { token: 'xxx' }, }); await client.connect(); const reply = await client.AppGateway.emit('ping', {}); client.AppGateway.on('pong', (data) => console.log(data)); client.disconnect(); ``` ### Standalone client (no definition) When you do not want to depend on backend modules (e.g. frontend in a monorepo), use `createNativeWsClient`. Same message format and API (emit, send, on, off), but no gateway proxies and no `createWsServiceDefinition`. ```typescript import { createNativeWsClient } from '@onebun/core'; const client = createNativeWsClient({ url: 'ws://localhost:3000/chat', protocol: 'native', auth: { token: 'xxx' }, }); await client.connect(); // Lifecycle client.on('connect', () => console.log('Connected')); client.on('disconnect', (reason) => console.log('Disconnected', reason)); // Server events (same event names as your gateway) client.on('welcome', (data) => console.log(data)); client.on('chat:message', (msg) => console.log(msg)); await client.emit('chat:message', { text: 'Hello' }); client.send('typing', {}); client.disconnect(); ``` ## Socket.IO To use Socket.IO, enable it in application options and connect clients to the Socket.IO path. ### Enabling Socket.IO ```typescript const app = new OneBunApplication(AppModule, { port: 3000, websocket: { socketio: { enabled: true, path: '/socket.io', pingInterval: 25000, pingTimeout: 20000, }, }, }); ``` Clients must connect to the Socket.IO path (e.g. `ws://localhost:3000/socket.io` with query `EIO=4&transport=websocket`). ### Using socket.io-client ```typescript import { io } from 'socket.io-client'; const socket = io('http://localhost:3000', { path: '/socket.io', transports: ['websocket'], auth: { token: 'user-jwt' }, }); socket.on('connect', () => console.log('Connected', socket.id)); socket.on('welcome', (data) => console.log('Welcome', data)); socket.emit('ping', {}, (ack) => console.log('Pong', ack)); socket.disconnect(); ``` ### Typed client (Socket.IO) Use `createWsClient` with `protocol: 'socketio'` and a URL that includes the Socket.IO path. ```typescript const client = createWsClient(definition, { url: 'ws://localhost:3000/socket.io', protocol: 'socketio', auth: { token: 'xxx' }, }); await client.connect(); ``` ### Protocol support * Engine.IO v4, Socket.IO v4 * WebSocket and HTTP long-polling transports * Namespaces, acknowledgements * Binary data (base64 encoded) ## WebSocketGateway decorator The `@WebSocketGateway` decorator marks a class as a WebSocket gateway. ```typescript @WebSocketGateway({ path: '/ws', namespace: 'chat' }) export class ChatGateway extends BaseWebSocketGateway { // handlers... } ``` | Option | Type | Default | Description | |--------|------|---------|-------------| | `path` | `string` | `'/'` | WebSocket connection path | | `namespace` | `string` | - | Namespace for isolating gateways | ## Event decorators ### @OnConnect Handles client connection events. ```typescript @OnConnect() handleConnect(@Client() client: WsClientData) { this.logger.info(`Client ${client.id} connected`); return { event: 'welcome', data: { message: 'Welcome!' } }; } ``` ### @OnDisconnect Handles client disconnection events. ```typescript @OnDisconnect() handleDisconnect(@Client() client: WsClientData) { this.logger.info(`Client ${client.id} disconnected`); } ``` ### @OnJoinRoom Handles room join events. Optionally accepts a pattern. ```typescript @OnJoinRoom('room:{roomId}') handleJoinRoom( @Client() client: WsClientData, @RoomName() room: string, @PatternParams() params: { roomId: string } ) { this.emitToRoom(room, 'user:joined', { userId: client.id }); } ``` ### @OnLeaveRoom Handles room leave events. ```typescript @OnLeaveRoom('room:*') handleLeaveRoom(@Client() client: WsClientData, @RoomName() room: string) { this.emitToRoom(room, 'user:left', { userId: client.id }); } ``` ### @OnMessage Handles incoming messages. Requires an event pattern. ```typescript @OnMessage('chat:message') handleMessage(@Client() client: WsClientData, @MessageData() data: { text: string }) { this.broadcast('chat:message', { userId: client.id, text: data.text }); } ``` #### Pattern syntax | Pattern | Example match | Description | |---------|---------------|-------------| | `chat:message` | `chat:message` | Exact match | | `chat:*` | `chat:general`, `chat:private` | Wildcard (one segment) | | `chat:{roomId}` | `chat:general` → `{ roomId: 'general' }` | Named parameter | | `user:{id}:*` | `user:123:action` → `{ id: '123' }` | Combined | ## Parameter decorators ### @Client() Injects the client data object. ### @Socket() Injects the raw Bun WebSocket object. ### @MessageData(property?: string) Injects message data or a specific property. ### @RoomName() Injects the room name (for join/leave handlers). ### @PatternParams() Injects parameters extracted from the pattern. ### @WsServer() Injects the WebSocket server reference. ## BaseWebSocketGateway Base class providing client/room management and messaging. Messages are encoded per client protocol (native or Socket.IO). Every gateway automatically receives `this.logger` (a child logger scoped to the gateway class name) and `this.config` (the application configuration) — the same DI that regular controllers get. Use them instead of `console.log`. ### Emit methods ```typescript emit(clientId: string, event: string, data: unknown): void; broadcast(event: string, data: unknown, excludeClientIds?: string[]): void; emitToRoom(room: string, event: string, data: unknown, excludeClientIds?: string[]): void; emitToRooms(rooms: string[], event: string, data: unknown): Promise; emitToRoomPattern(pattern: string, event: string, data: unknown): Promise; ``` ### Connection and room management ```typescript disconnectClient(clientId: string, reason?: string): void; disconnectAll(reason?: string): void; disconnectRoom(room: string, reason?: string): Promise; joinRoom(clientId: string, room: string): Promise; leaveRoom(clientId: string, room: string): Promise; getClient(clientId: string): Promise; getRoom(roomName: string): Promise; getClientsByRoom(roomName: string): Promise; getRoomsByPattern(pattern: string): Promise; ``` ## Guards Use `@UseWsGuards(...guards)` on handlers. Built-in: `WsAuthGuard`, `WsPermissionGuard`, `WsAnyPermissionGuard`, `WsRoomGuard`, `WsServiceGuard`. Custom: `createGuard((ctx) => boolean)`. ## Storage adapters Default is in-memory. For Redis, set `websocket.storage: { type: 'redis', redis: { url, prefix } }` and use `createRedisWsStorage(redisClient)` when providing a custom storage to the handler. ## WebSocket client options | Option | Type | Default | Description | |--------|------|---------|-------------| | `url` | `string` | - | WebSocket server URL (gateway path for native; socket.io path for Socket.IO) | | `protocol` | `'native' \| 'socketio'` | `'native'` | Protocol to use | | `auth.token` | `string` | - | Bearer token | | `reconnect` | `boolean` | `true` | Auto-reconnection | | `reconnectInterval` | `number` | `1000` | Reconnection delay (ms) | | `maxReconnectAttempts` | `number` | `10` | Max reconnection attempts | | `timeout` | `number` | `5000` | Request timeout (ms) | ## Types ### WsClientData ```typescript interface WsClientData { id: string; rooms: string[]; connectedAt: number; auth: WsAuthData | null; metadata: Record; protocol: 'native' | 'socketio'; } ``` ### WsAuthData ```typescript interface WsAuthData { authenticated: boolean; userId?: string; serviceId?: string; permissions?: string[]; token?: string; } ``` ### WsRoom ```typescript interface WsRoom { name: string; clientIds: string[]; metadata?: Record; } ```