# Asena > High-performance IoC web framework built on Bun runtime with decorator-based dependency injection, achieving 200k-300k requests/sec. - Documentation: https://asena.sh - GitHub: https://github.com/AsenaJs/Asena - Examples: https://github.com/LibirSoft/AsenaExample > This file contains the full text of every documentation page, concatenated. > For a linked index instead, see https://asena.sh/llms.txt --- # Philosophy Section: Docs Source: https://asena.sh/raw/philosophy.md # Philosophy `Spring Boot` and `Quarkus` have established proven patterns for enterprise application development, but developers transitioning to TypeScript often find themselves reimplementing familiar concepts or adapting to unfamiliar architectures. `AsenaJS` addresses this gap by bringing automatic component discovery and field-based dependency injection to the `Bun` ecosystem. The framework eliminates boilerplate through automatic scanning of decorator-annotated classes, removing the need for explicit module declarations or manual wiring. Components marked with `@Controller`, `@Service`, or `@Repository` (via the asena-drizzle package) are discovered and registered automatically, allowing developers to focus on business logic rather than configuration. Combined with Bun's native performance characteristics, this delivers both the familiar developer experience of Spring Boot and the speed expected from a modern JavaScript runtime. `AsenaJS` is designed to make developers familiar with `Spring Boot` and `Quarkus` feel at home in the TypeScript ecosystem. As the framework evolves, we remain committed to this philosophy — bringing more proven patterns from the Java world while maintaining the performance advantages that `Bun` provides and the flexibility of TypeScript. ## Core Principles These are the rules we hold ourselves to. They explain most of the design decisions you will encounter in the codebase. ### Zero Dependencies The core framework depends on exactly one package: `reflect-metadata`. Nothing else. Every feature is measured against this bar first. Before reaching for an npm package we ask: can Bun's native APIs do this? Can the framework do it in a few hundred lines we actually understand? Only when both answers are no does a dependency become a candidate — and even then it goes into a separate `@asenajs/*` package rather than the core. This is why Redis, Kafka, OpenTelemetry, Drizzle and Winston all live in optional packages. Your application pays for what it uses and nothing more. ### Bun Native First Asena is not a framework that happens to run on Bun. It is built for Bun. `Bun.serve()`, `Bun.file()`, `server.upgrade()`, `Bun.CookieMap`, native cron, and Bun's own router are used directly instead of being abstracted behind portable shims. The `Ergenecore` adapter has zero runtime dependencies and hands routing entirely to Bun's native router — which is precisely why it reaches roughly 295k requests/sec. The trade-off is deliberate: Asena will not run on Node.js. In exchange, it does not pay the abstraction tax that portability demands. ### Adapter Agnostic The framework defines the contract; adapters implement it. `AsenaAdapter` and `AsenaContext` describe what an HTTP and WebSocket layer must provide. [Ergenecore](/docs/adapters/ergenecore) implements it with Bun natives for maximum speed; [Hono](/docs/adapters/hono) implements it on top of the Hono ecosystem for middleware compatibility. Your controllers, services and tests do not change when you switch between them. This same seam is what lets microservice transports be swapped — Redis Streams, Kafka, or an in-memory transport — without a single change to a `@MessageController`. ### Convention Over Configuration A component should announce what it is, not how to wire it. `@Controller`, `@Service`, `@Middleware`, `@WebSocket`, `@Schedule`, `@EventService`, `@MessageController` — each decorator is enough for the container to discover, instantiate and inject the class. There are no module files, no provider arrays, no manual registration lists. Field injection (`@Inject`) is chosen over constructor injection on purpose: it keeps constructors free for real initialization and makes inheritance hierarchies behave predictably. ### Testability Is a Feature, Not an Afterthought A framework that is hard to test is a framework that quietly resists change. Asena ships its testing utilities in the box, at three levels: [`mockComponent`](/docs/testing/mock-component) for unit tests that bypass the container entirely, [`createWebTest`](/docs/testing/web-test) for controller slices where routing and validation stay real, and [`createTestApp`](/docs/testing/test-app) for full-application tests where any component can be swapped for a mock. They run on Bun's native test runner, add no external dependencies, and exercise the real routing pipeline rather than a simulation of it. ### Honest Performance Benchmarks are published with the adapter, the machine and the methodology attached, and we do not quote numbers we have not measured. Where an abstraction costs performance, we say so. Where a faster path exists but sacrifices correctness, we take the correct one and document the trade-off. ## What Asena Is Not Being clear about non-goals is as useful as listing features: - **Not a Node.js framework.** Bun-only, by design. - **Not a batteries-included monolith.** The core stays small; capability arrives through optional packages. - **Not a Spring clone.** We borrow patterns that earned their place — IoC, component scanning, declarative transactions — and leave behind the ceremony that TypeScript does not need. - **Not a full-stack meta-framework.** Asena serves APIs, WebSockets, messaging and static or Bun-bundled HTML. It does not ship an opinionated frontend rendering layer. ## Related - [Get Started](/docs/get-started) - Build your first Asena application - [Dependency Injection](/docs/concepts/dependency-injection) - How the IoC container works - [Adapters Overview](/docs/adapters/overview) - Ergenecore vs Hono, and the contract between them - [Testing Overview](/docs/testing/overview) - The three levels of testing - [Roadmap](/docs/roadmap) - What is shipped and what is planned --- # Get Started Section: Docs Source: https://asena.sh/raw/get-started.md # Get Started Get up and running with Asena in minutes. This guide shows you how to create your first Asena application. ## Prerequisites - [Bun](https://bun.sh) v1.3.12 or higher **Verify Bun installation:** ```bash bun --version ``` ## Option 1: With Asena CLI (Recommended) The fastest way to create a new Asena project. ### 1. Install Asena CLI ```bash bun install -g @asenajs/asena-cli ``` ### 2. Create Project ```bash asena create ``` Answer the interactive prompts: ```bash ✔ Enter your project name: my-app ✔ Select adapter: Ergenecore ✔ Do you want to setup ESLint? No ✔ Do you want to setup Prettier? No ``` ### 3. Start Development Server ```bash cd my-app asena dev start ``` ::: warning You shouldn't use `asena dev start` for production. It will be removed in feature releases. Its for quick testings only ::: Your server is now running at `http://localhost:3000`! Test it: ```bash curl http://localhost:3000 # Output: Hello asena ``` That's it! You now have a working Asena application. Skip to [Next Steps](#next-steps) to learn more. --- ## Option 2: Manual Setup If you prefer to set up your project manually. ### 1. Create Project ```bash mkdir my-app cd my-app bun init -y ``` ### 2. Install Dependencies ::: code-group ```bash [For ergenecore] bun add @asenajs/asena @asenajs/ergenecore @asenajs/asena-logger bun add -D @asenajs/asena-cli bunx asena init ✔ Which adapter do you want to use? Ergenecore Adapter ``` ```bash [For hono] bun add @asenajs/asena @asenajs/hono-adapter hono @asenajs/asena-logger bun add -D @asenajs/asena-cli bunx asena init ✔ Which adapter do you want to use? Hono Adapter ``` ::: ### 3. Configure TypeScript Update your `tsconfig.json` to enable decorators: ```json { "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true } } ``` ::: tip These two settings are **required** for Asena decorators to work properly. ::: ### 4. Create Logger Create `src/logger.ts`: ```typescript import { AsenaLogger } from '@asenajs/asena-logger'; export const logger = new AsenaLogger(); ``` ### 5. Create Entry Point Create `src/index.ts`: ::: code-group ```typescript [Ergenecore] import { AsenaServerFactory } from '@asenajs/asena'; import { createErgenecoreAdapter } from '@asenajs/ergenecore'; import { logger } from './logger'; const adapter = createErgenecoreAdapter(); const server = await AsenaServerFactory.create({ adapter, logger, port: 3000 }); await server.start(); ``` ```typescript [Hono] import { AsenaServerFactory } from '@asenajs/asena'; import { createHonoAdapter } from '@asenajs/hono-adapter'; import { logger } from './logger'; // createHonoAdapter returns a tuple and requires a logger const [adapter] = createHonoAdapter({ logger }); const server = await AsenaServerFactory.create({ adapter, logger, port: 3000 }); await server.start(); ``` ::: ::: tip Components in the entry file Asena never forces you to split everything into separate files - a small app can declare a `@Controller` or `@Service` directly in `src/index.ts` and it will be registered as usual. Declare it **above** the `AsenaServerFactory.create()` call. Anything below that line has not been evaluated yet when components are collected, so it cannot be registered; Asena logs a warning naming any component it finds in that position. ::: ### 6. Create Your First Controller Create `src/controllers/HelloController.ts`: ::: code-group ```typescript [Ergenecore] import { Controller } from '@asenajs/asena/decorators'; import { Get } from '@asenajs/asena/decorators/http'; import { type Context } from '@asenajs/ergenecore'; @Controller('/') export class HelloController { @Get('/') async hello(context: Context) { return context.send('Hello World!'); } } ``` ```typescript [Hono] import { Controller } from '@asenajs/asena/decorators'; import { Get } from '@asenajs/asena/decorators/http'; import { type Context } from '@asenajs/hono-adapter'; @Controller('/') export class HelloController { @Get('/') async hello(context: Context) { return context.send('Hello World!'); } } ``` ::: ### 7. Initialize CLI Configuration ```bash asena init ``` This creates `asena-config.ts` with default build settings. ### 8. Run Your Application **Development mode:** ```bash # index.ts must be your root file bun run src/index.ts ``` ::: tip Hot Reload for Faster Development Enable hot reloading to automatically restart your server on file changes: ```bash bun run --hot src/index.ts ``` **What hot reload does:** - Watches all source files for changes - Automatically refreshes the server process - Preserves in-memory state when possible - Ideal for rapid iteration and testing ::: **Production build:** ```bash asena build bun dist/index.asena.js ``` Test your application: ```bash curl http://localhost:3000 # Output: Hello World! ``` --- ## Project Structure Your project should now look like this: ``` my-app/ ├── src/ │ ├── controllers/ │ │ └── HelloController.ts │ ├── index.ts │ └── logger.ts ├── asena-config.ts ├── package.json └── tsconfig.json ``` --- ## Next Steps Now that you have a working Asena application: - **Add more routes** - Learn about [Controllers](/docs/concepts/controllers) - **Add business logic** - Learn about [Services](/docs/concepts/services) - **Add middleware** - Learn about [Middleware](/docs/concepts/middleware) - **Add validation** - Learn about [Validation](/docs/concepts/validation) - **Add cron jobs** - Learn about [Scheduled Tasks](/docs/concepts/scheduled-tasks) - **Serve HTML pages** - Learn about [Frontend Controller](/docs/concepts/frontend-controller) - **Auto-generate API docs** - Set up [OpenAPI](/docs/packages/openapi) - **Add caching** - Integrate [Redis](/docs/packages/redis) - **Explore CLI** - Check out [CLI Commands](/docs/cli/commands) - **See examples** - Browse [Examples](/docs/examples) --- ## Common Issues ### Decorators not working Make sure your `tsconfig.json` has: ```json { "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true } } ``` ### Command not found: asena Add Bun's global bin directory to your PATH: ```bash # For bash echo 'export PATH="$HOME/.bun/bin:$PATH"' >> ~/.bashrc source ~/.bashrc # For zsh echo 'export PATH="$HOME/.bun/bin:$PATH"' >> ~/.zshrc source ~/.zshrc ``` ### Port already in use Change the port in `AsenaServerFactory.create()`: ```typescript const server = await AsenaServerFactory.create({ adapter, logger, port: 3001 }); ``` --- **Need help?** Check out our [documentation](https://asena.sh) or visit our [GitHub repository](https://github.com/AsenaJs/Asena). --- # Examples Section: Docs Source: https://asena.sh/raw/examples.md # Examples Practical examples showing how to build real applications with Asena. Each example demonstrates core concepts with working code. ## Complete REST API A full-featured REST API with controllers, services, validation, and database integration. ### Project Structure ``` my-api/ ├── src/ │ ├── controllers/ │ │ └── UserController.ts │ ├── services/ │ │ └── UserService.ts │ ├── validators/ │ │ ├── CreateUserValidator.ts │ │ └── UpdateUserValidator.ts │ ├── middlewares/ │ │ └── AuthMiddleware.ts │ ├── repositories/ │ │ └── UserRepository.ts │ ├── database.ts │ ├── logger.ts │ └── index.ts ├── asena-config.ts ├── package.json └── tsconfig.json ``` ### Database Setup ```typescript // src/database.ts import { AsenaDatabaseService, Database } from '@asenajs/asena-drizzle'; import { pgTable, uuid, text, timestamp } from 'drizzle-orm/pg-core'; export const users = pgTable('users', { id: uuid('id').primaryKey().defaultRandom(), name: text('name').notNull(), email: text('email').notNull().unique(), createdAt: timestamp('created_at').defaultNow() }); @Database({ type: 'postgresql', config: { host: process.env.DB_HOST || 'localhost', port: Number(process.env.DB_PORT) || 5432, database: process.env.DB_NAME || 'myapp', user: process.env.DB_USER || 'postgres', password: process.env.DB_PASSWORD || 'postgres' } }) export class MainDB extends AsenaDatabaseService {} ``` ### Repository ```typescript // src/repositories/UserRepository.ts import { Repository, BaseRepository } from '@asenajs/asena-drizzle'; import { users } from '../database'; import { eq } from 'drizzle-orm'; @Repository({ table: users, databaseService: 'MainDB' }) export class UserRepository extends BaseRepository> { async findByEmail(email: string) { return this.findOne(eq(users.email, email)); } } ``` ::: tip Type-Safe Repository Pattern Always provide both generic parameters to your repository for full TypeScript support: ```typescript // First parameter: Your table schema // Second parameter: Your database connection type export class UserRepository extends BaseRepository> { // Now you have full IntelliSense and type checking! } ``` **Why this matters:** Without the database type parameter, TypeScript cannot infer the correct query builder methods, and you'll lose IDE autocomplete features. **Available Database Types:** | Database | Driver | Type to Use | |----------|--------|-------------| | PostgreSQL | `pg` | `NodePgDatabase` | | MySQL | `mysql2` | `MySql2Database` | | SQLite | `bun:sqlite` | `BunSQLDatabase` | **Complete Example:** ```typescript import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { users } from './schema'; @Repository({ table: users, databaseService: 'MainDatabase' }) export class UserRepository extends BaseRepository> { // Type-safe methods with IntelliSense async findByEmail(email: string) { return this.findOne(eq(users.email, email)); } } ``` ::: ### Service ```typescript // src/services/UserService.ts import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { UserRepository } from '../repositories/UserRepository'; @Service() export class UserService { @Inject(UserRepository) private userRepo: UserRepository; async getAllUsers() { return await this.userRepo.findAll(); } async getUserById(id: string) { const user = await this.userRepo.findById(id); if (!user) { throw new Error('User not found'); } return user; } async createUser(data: { name: string; email: string }) { const existing = await this.userRepo.findByEmail(data.email); if (existing) { throw new Error('Email already exists'); } return await this.userRepo.create(data); } async updateUser(id: string, data: Partial<{ name: string; email: string }>) { return await this.userRepo.update(id, data); } async deleteUser(id: string) { return await this.userRepo.delete(id); } } ``` ### Validators ```typescript // src/validators/CreateUserValidator.ts import { Middleware } from '@asenajs/asena/decorators'; import { ValidationService } from '@asenajs/ergenecore'; import { z } from 'zod'; @Middleware({ validator: true }) export class CreateUserValidator extends ValidationService { json() { return z.object({ name: z.string().min(3).max(50), email: z.string().email() }); } } // src/validators/UpdateUserValidator.ts @Middleware({ validator: true }) export class UpdateUserValidator extends ValidationService { json() { return z.object({ name: z.string().min(3).max(50).optional(), email: z.string().email().optional() }); } } ``` ### Controller ```typescript // src/controllers/UserController.ts import { Controller } from '@asenajs/asena/decorators'; import { Get, Post, Put, Delete } from '@asenajs/asena/decorators/http'; import { Inject } from '@asenajs/asena/decorators/ioc'; import type { Context } from '@asenajs/ergenecore'; import { UserService } from '../services/UserService'; import { CreateUserValidator } from '../validators/CreateUserValidator'; import { UpdateUserValidator } from '../validators/UpdateUserValidator'; @Controller('/users') export class UserController { @Inject(UserService) private userService: UserService; @Get('/') async list(context: Context) { const users = await this.userService.getAllUsers(); return context.send({ users }); } @Get('/:id') async getById(context: Context) { try { const user = await this.userService.getUserById(context.getParam('id')); return context.send({ user }); } catch (error) { return context.send({ error: error.message }, 404); } } @Post({ path: '/', validator: CreateUserValidator }) async create(context: Context) { try { const data = await context.getBody<{ name: string; email: string }>(); const user = await this.userService.createUser(data); return context.send({ created: true, user }, 201); } catch (error) { return context.send({ error: error.message }, 400); } } @Put({ path: '/:id', validator: UpdateUserValidator }) async update(context: Context) { try { const id = context.getParam('id'); const data = await context.getBody(); const user = await this.userService.updateUser(id, data); return context.send({ updated: true, user }); } catch (error) { return context.send({ error: error.message }, 400); } } @Delete('/:id') async delete(context: Context) { try { await this.userService.deleteUser(context.getParam('id')); return context.send({ deleted: true }); } catch (error) { return context.send({ error: error.message }, 404); } } } ``` ### Entry Point ```typescript // src/index.ts import { AsenaServerFactory } from '@asenajs/asena'; import { createErgenecoreAdapter } from '@asenajs/ergenecore'; import { logger } from './logger'; const adapter = createErgenecoreAdapter(); const server = await AsenaServerFactory.create({ adapter, logger, port: 3000 }); await server.start(); ``` ### Logger ```typescript // src/logger.ts import { AsenaLogger } from '@asenajs/asena-logger'; export const logger = new AsenaLogger(); ``` ### Run the Application ```bash asena dev start ``` You'll see: ``` 2025-10-15 20:21:56 [INFO]: ✅ Database Connected [POSTGRESQL] 2025-10-15 20:21:56 [INFO]: Adapter: ErgenecoreAdapter implemented 2025-10-15 20:21:56 [INFO]: All components registered and ready to use 2025-10-15 20:21:56 [INFO]: ✓ Successfully registered CONTROLLER UserController (5 routes) 2025-10-15 20:21:56 [INFO]: Server running at http://localhost:3000 ``` ### Test the API ```bash # Get all users curl http://localhost:3000/users # Create user curl -X POST http://localhost:3000/users \ -H "Content-Type: application/json" \ -d '{"name":"John Doe","email":"john@example.com"}' # Get user by ID curl http://localhost:3000/users/123e4567-e89b-12d3-a456-426614174000 # Update user curl -X PUT http://localhost:3000/users/123e4567-e89b-12d3-a456-426614174000 \ -H "Content-Type: application/json" \ -d '{"name":"Jane Doe"}' # Delete user curl -X DELETE http://localhost:3000/users/123e4567-e89b-12d3-a456-426614174000 ``` --- ## WebSocket Chat Application A real-time chat application with room management and broadcasting. ### WebSocket Service ```typescript // src/websockets/ChatSocket.ts import { WebSocket } from '@asenajs/asena/decorators'; import { AsenaWebSocketService } from '@asenajs/asena/web-socket'; import type { Socket } from '@asenajs/asena/web-socket'; interface ChatData { username: string; room: string; } @WebSocket({ path: '/chat', name: 'ChatSocket' }) export class ChatSocket extends AsenaWebSocketService { protected async onOpen(ws: Socket): Promise { const username = ws.data.values?.username || 'Anonymous'; const room = ws.data.values?.room || 'general'; // Send message to socket ws.send(`Hello ${username}`) // Join room ws.subscribe(room); // send message to all room. this.server.to(room, `User joined: ${username}`) } protected async onMessage(ws: Socket, message: string): Promise { const username = ws.data.values?.username || 'Anonymous'; const room = ws.data.values?.room || 'general'; // send message to other sockets in room. (except itself) ws.publish(room, message); } protected async onClose(ws: Socket): Promise { const username = ws.data.values?.username || 'Anonymous'; const room = ws.data.values?.room || 'general'; // leave room ws.unsubscribe(room); // send message to all room. this.server.to(room, `User leaved: ${username}`) } } ``` ## Authentication with Middleware Implementing JWT authentication with middleware. ### Auth Service ```typescript // src/services/AuthService.ts import { Service } from '@asenajs/asena/decorators'; @Service() export class AuthService { async verifyToken(token: string): Promise<{ id: string; email: string }> { // In production, use proper JWT verification if (token === 'valid-token') { return { id: '123', email: 'user@example.com' }; } throw new Error('Invalid token'); } async generateToken(userId: string): Promise { // In production, use proper JWT generation return 'valid-token'; } } ``` ### Auth Middleware ```typescript // src/middlewares/AuthMiddleware.ts import { Middleware } from '@asenajs/asena/decorators'; import { MiddlewareService, type Context } from '@asenajs/ergenecore'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { AuthService } from '../services/AuthService'; @Middleware() export class AuthMiddleware extends MiddlewareService { @Inject(AuthService) private authService: AuthService; async handle(context: Context, next: () => Promise): Promise { const token = context.headers['authorization']?.replace('Bearer ', ''); if (!token) { return context.send({ error: 'No token provided' }, 401); } try { const user = await this.authService.verifyToken(token); context.setValue('user', user); await next(); } catch (error) { return context.send({ error: 'Invalid token' }, 401); } } } ``` ### Protected Controller ```typescript // src/controllers/ProfileController.ts import { Controller } from '@asenajs/asena/decorators'; import { Get } from '@asenajs/asena/decorators/http'; import type { Context } from '@asenajs/ergenecore'; import { AuthMiddleware } from '../middlewares/AuthMiddleware'; @Controller({ path: '/profile', middlewares: [AuthMiddleware] }) export class ProfileController { @Get('/') async getProfile(context: Context) { const user = context.getValue('user'); return context.send({ user }); } @Get('/settings') async getSettings(context: Context) { const user = context.getValue('user'); return context.send({ userId: user.id, settings: {} }); } } ``` ### Login Controller ```typescript // src/controllers/AuthController.ts import { Controller } from '@asenajs/asena/decorators'; import { Post } from '@asenajs/asena/decorators/http'; import { Inject } from '@asenajs/asena/decorators/ioc'; import type { Context } from '@asenajs/ergenecore'; import { AuthService } from '../services/AuthService'; @Controller('/auth') export class AuthController { @Inject(AuthService) private authService: AuthService; @Post('/login') async login(context: Context) { const { email, password } = await context.getBody<{ email: string; password: string; }>(); // Validate credentials (simplified) if (email === 'user@example.com' && password === 'password') { const token = await this.authService.generateToken('123'); return context.send({ token }); } return context.send({ error: 'Invalid credentials' }, 401); } } ``` ### Usage ```bash # Login curl -X POST http://localhost:3000/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"user@example.com","password":"password"}' # Response: {"token":"valid-token"} # Access protected route curl http://localhost:3000/profile \ -H "Authorization: Bearer valid-token" # Response: {"user":{"id":"123","email":"user@example.com"}} ``` --- ## Rate Limiting Using built-in rate limiter middleware. ```typescript // src/middlewares/ApiRateLimiter.ts import { Middleware } from '@asenajs/asena/decorators'; import { RateLimiterMiddleware } from '@asenajs/ergenecore'; @Middleware() export class ApiRateLimiter extends RateLimiterMiddleware { constructor() { super({ capacity: 100, // 100 requests refillRate: 100 / 60, // per minute keyGenerator: (ctx) => { // Rate limit per user or IP const user = ctx.getValue('user'); return user?.id || ctx.req.headers.get('x-forwarded-for') || 'anonymous'; }, skip: (ctx) => { // Skip for admins const user = ctx.getValue('user'); return user?.role === 'admin'; }, cost: (ctx) => { // Expensive operations cost more tokens if (ctx.req.url.includes('/search')) return 5; if (ctx.req.url.includes('/export')) return 10; return 1; } }); } } // Apply to specific routes @Controller('/api') export class ApiController { @Get({ path: '/data', middlewares: [ApiRateLimiter] }) async getData(context: Context) { return context.send({ data: [] }); } } ``` --- ## CORS Configuration Setting up CORS for cross-origin requests. ```typescript // src/middlewares/GlobalCors.ts import { Middleware } from '@asenajs/asena/decorators'; import { CorsMiddleware } from '@asenajs/ergenecore'; @Middleware() export class GlobalCors extends CorsMiddleware { constructor() { super({ origin: ['https://example.com', 'https://app.example.com'], credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE'], allowedHeaders: ['Content-Type', 'Authorization'], maxAge: 86400 }); } } // src/config/ServerConfig.ts import { Config } from '@asenajs/asena/decorators'; import { ConfigService } from '@asenajs/ergenecore'; import { GlobalCors } from '../middlewares/GlobalCors'; @Config() export class ServerConfig extends ConfigService { globalMiddlewares() { return [GlobalCors]; } } ``` --- ## OpenAPI Auto-Documentation Automatically generate OpenAPI specs from your existing validators — zero extra annotations. ### Setup ```typescript // src/openapi/AppOpenApi.ts import { OpenApi, OpenApiPostProcessor } from '@asenajs/asena-openapi'; @OpenApi({ info: { title: 'My API', version: '1.0.0' }, path: '/api/openapi', ui: true, }) export class AppOpenApi extends OpenApiPostProcessor {} ``` ### Validator with Response Schema ```typescript // src/validators/CreateUserValidator.ts import { Middleware } from '@asenajs/asena/decorators'; import { ValidationService } from '@asenajs/ergenecore'; import { z } from 'zod'; @Middleware({ validator: true }) export class CreateUserValidator extends ValidationService { json() { return z.object({ name: z.string().min(1), email: z.string().email(), }); } response() { return { 201: z.object({ id: z.string(), name: z.string() }), 400: { schema: z.object({ error: z.string() }), description: 'Validation error' }, }; } } ``` ### Test ```bash # Get OpenAPI spec curl http://localhost:3000/api/openapi # Open Swagger UI in browser open http://localhost:3000/api/openapi/ui ``` ::: info For full documentation, see [OpenAPI Package](/docs/packages/openapi). ::: --- ## Redis Caching Add Redis-powered caching with decorator-based setup. ### Redis Service ```typescript // src/redis/AppRedis.ts import { Redis, AsenaRedisService } from '@asenajs/asena-redis'; @Redis({ config: { url: 'redis://localhost:6379' }, name: 'AppRedis', }) export class AppRedis extends AsenaRedisService { async getOrSet(key: string, factory: () => Promise, ttl?: number): Promise { const cached = await this.get(key); if (cached) return cached; const value = await factory(); await this.set(key, value, ttl); return value; } } ``` ### Cache Controller ```typescript // src/controllers/CacheController.ts import { Controller } from '@asenajs/asena/decorators'; import { Get } from '@asenajs/asena/decorators/http'; import { Inject } from '@asenajs/asena/decorators/ioc'; import type { Context } from '@asenajs/ergenecore'; @Controller('/api/cache') export class CacheController { @Inject('AppRedis') private redis: AppRedis; @Get('/user/:id') async getUser(context: Context) { const id = context.getParam('id'); const name = await this.redis.getOrSet(`user:${id}`, async () => { // Simulate DB lookup return `User-${id}`; }, 60); return context.send({ id, name, cached: true }); } } ``` ### Test ```bash # First call — fetches from "DB" and caches curl http://localhost:3000/api/cache/user/42 # Second call — served from Redis cache curl http://localhost:3000/api/cache/user/42 ``` ::: info For full documentation, see [Redis Package](/docs/packages/redis). ::: --- ## Scheduled Tasks Run background jobs on a cron schedule using Bun's native cron support. ### Cleanup Task ```typescript // src/schedule/SessionCleanup.ts import { Schedule } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import type { AsenaSchedule } from '@asenajs/asena/schedule'; @Schedule({ cron: '0 2 * * *' }) // Daily at 2:00 AM export class SessionCleanup implements AsenaSchedule { @Inject('SessionRepository') private sessionRepo: SessionRepository; public async execute() { const count = await this.sessionRepo.deleteExpired(); console.log(`Cleaned up ${count} expired sessions`); } } ``` ### Cron Health Endpoint ```typescript // src/controllers/CronController.ts import { Controller } from '@asenajs/asena/decorators'; import { Get } from '@asenajs/asena/decorators/http'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { ICoreServiceNames } from '@asenajs/asena/ioc/types'; import type { CronRunner } from '@asenajs/asena/schedule'; import type { Context } from '@asenajs/ergenecore'; @Controller('/api/cron') export class CronController { @Inject(ICoreServiceNames.CRON_RUNNER) private cronRunner: CronRunner; @Get('/status') async status(context: Context) { return context.send({ jobs: this.cronRunner.getJobNames(), count: this.cronRunner.jobCount, running: this.cronRunner.hasRunningJobs, }); } } ``` ### Test ```bash curl http://localhost:3000/api/cron/status # {"jobs":["SessionCleanup"],"count":1,"running":true} ``` ::: info For full documentation, see [Scheduled Tasks](/docs/concepts/scheduled-tasks). ::: --- ## Frontend Controller Serve HTML pages using Bun's native HTML imports — zero middleware overhead. ```typescript // src/frontend/AppFrontendController.ts import { FrontendController } from '@asenajs/asena/decorators'; import { Page } from '@asenajs/asena/decorators/http'; @FrontendController('/ui') export class AppFrontendController { @Page('/') public home() { return import('./pages/home.html'); } @Page('/settings') public settings() { return import('./pages/settings.html'); } } ``` Visit `http://localhost:3000/ui` to see the home page, and `http://localhost:3000/ui/settings` for settings. ::: warning FrontendController routes bypass the middleware chain entirely (no CORS, auth, etc.). Use `@Controller` for routes that need middleware. ::: ::: info For full documentation, see [Frontend Controller](/docs/concepts/frontend-controller). ::: --- ## Related Documentation - [Controllers](/docs/concepts/controllers) - HTTP routing - [Services](/docs/concepts/services) - Business logic - [Middleware](/docs/concepts/middleware) - Request interception - [Validation](/docs/concepts/validation) - Request validation - [WebSocket](/docs/concepts/websocket) - Real-time communication - [Scheduled Tasks](/docs/concepts/scheduled-tasks) - Cron-based task scheduling - [Frontend Controller](/docs/concepts/frontend-controller) - HTML page serving - [PostProcessor](/docs/concepts/post-processor) - Component interception - [OpenAPI Package](/docs/packages/openapi) - API documentation - [Redis Package](/docs/packages/redis) - Redis integration - [Drizzle Package](/docs/packages/drizzle) - Database integration - [Logger Package](/docs/packages/logger) - Logging --- **Need more examples?** Check out the [AsenaExample repository](https://github.com/LibirSoft/AsenaExample) for additional use cases. --- # Showcase Section: Docs Source: https://asena.sh/raw/showcase.md # Showcase Discover production applications built with Asena Framework. These real-world projects demonstrate Asena's capabilities in building high-performance, scalable web applications. ::: tip Built with Asena? If you've built a project with Asena and want to showcase it here, please [open a pull request](https://github.com/AsenaJs/Website/pulls) or [create an issue](https://github.com/AsenaJs/Asena/issues) with your project details! ::: --- ## Production Applications ### ScrumPoker.me **Description:** ScrumPoker.me is a production-grade Scrum poker application built entirely with Asena Framework from the ground up. It leverages Asena's WebSocket capabilities for real-time voting and collaboration, combined with REST API endpoints for user management and session persistence. **Website**: [scrumpoker.me](https://scrumpoker.me/) --- ### Check Crypto Address **Description:** Internal tooling application for cryptocurrency address validation, verification, and security checks. Built with Asena to handle high-throughput validation requests efficiently. **Migration Story:** This project was **migrated from Node.js/Express to Asena/Bun**, achieving significant performance improvements and simplified codebase maintenance. The migration showcases Asena's compatibility and ease of adoption for existing projects. **Website**: [checkcryptoaddress.com](https://checkcryptoaddress.com/) --- ## Why These Projects Chose Asena ### Performance Asena's native Bun integration provides exceptional performance for both REST APIs and WebSocket connections, making it ideal for real-time applications like ScrumPoker.me. Projects migrating from Node.js/Express have reported significant performance improvements. ### Developer Experience TypeScript-first design with decorators provides a clean, intuitive API that accelerates development without sacrificing type safety. ### WebSocket Support Built-in WebSocket support with decorators makes it easy to build real-time features without external libraries or complex setup. ### Scalability Dependency injection and modular architecture enable easy scaling from small internal tools to large production applications. ### Scheduled Tasks Built-in cron scheduling with `@Schedule` lets you run background jobs — database cleanup, cache warming, report generation — without external tools like Bull or Agenda. ### API Documentation Automatic OpenAPI 3.1 spec generation from your existing validators with `@asenajs/asena-openapi`. Built-in Swagger UI, zero extra annotations needed. ### 🚀 Coming Soon More projects are being built with Asena! Check back soon or [submit your project](https://github.com/AsenaJs/Asena/issues). ## Stats & Insights ::: info Framework Adoption Asena is being used in production environments for: - Real-time collaboration tools - Internal business tooling - API services and microservices - WebSocket-heavy applications ::: ## Submit Your Project Have you built something with Asena? We'd love to feature it! ### Submission Guidelines To be featured in the showcase, your project should: - ✅ Use **Asena Framework** as backend - ✅ Be in **production** or near-production state - ✅ Provide a **brief description** of features and tech stack(optional) ### How to Submit 1. **Via GitHub Issue** Create an issue with the `showcase` label: [GitHub Issues](https://github.com/AsenaJs/Asena/issues/new) 2. **Via Pull Request** Edit this page directly: [Edit on GitHub](https://github.com/AsenaJs/Website/edit/master/docs/showcase.md) 3. **Include This Information** ```markdown - Project Name: [Name] - Website: https://... - Description: [Brief description] - GitHub: https://github.com/... (optional) - Tech Stack: [Technologies used] (optional) - Asena Features: [Controllers, WebSocket, etc.] ``` --- ## Get Inspired Looking to build your own project with Asena? Check out these resources: - [Get Started](/docs/get-started) - Quick start guide - [Examples](/docs/examples) - Code examples and tutorials - [WebSocket Guide](/docs/concepts/websocket) - Build real-time apps - [OpenAPI](/docs/packages/openapi) - Auto-generate API documentation - [Redis](/docs/packages/redis) - Caching and multi-pod WebSocket - [Scheduled Tasks](/docs/concepts/scheduled-tasks) - Background jobs - [CLI Tools](/docs/cli/overview) - Scaffold projects quickly --- ## Community Join the Asena community and share your projects: - **GitHub**: [AsenaJs/Asena](https://github.com/AsenaJs/Asena) - **Issues**: [Report bugs or request features](https://github.com/AsenaJs/Asena/issues) - **Discussions**: Share your projects and get help --- ## Related - [Examples](/docs/examples) - Sample code and tutorials - [Roadmap](/docs/roadmap) - Upcoming features and plans - [Get Started](/docs/get-started) - Start building with Asena - [GitHub Repository](https://github.com/AsenaJs/Asena) - Source code and contributions --- # Roadmap Section: Docs Source: https://asena.sh/raw/roadmap.md # Roadmap This page outlines the current state of Asena Framework and our plans for future development. Our goal is to build a production-ready, high-performance IoC framework for Bun with enterprise-grade features. ::: info Living Document This roadmap is updated regularly as we complete features and adjust priorities based on community feedback. ::: --- ## ✅ Current Release (v0.9.x) These features are **stable and production-ready** in the current release: ### New in v0.9 - **[Decorator Inheritance](/docs/concepts/inheritance)** - A route, page, event or message handler declared on a base class is now inherited by the decorated subclass. What changes the behaviour of a request travels with the route; what says where the route lives, or what a class is, stays with the concrete class - **`onNotFound`** - An unmatched route is a routing outcome, not an error, so it has [its own config hook](/docs/guides/error-handling#not-found) and `onError` never has to ask which it is looking at - **[Uniform error handling](/docs/guides/error-handling#adapter-logging)** - Both adapters answer the same 404, 500 and validation envelopes, and the framework's default log fires exactly when its default response does - **`isHttpException()`** - Branded exception detection that survives a project resolving two copies of a package, where `instanceof` silently answers false and turns every deliberate 401/403 into a 500 ### Core Framework - **Dependency Injection Container** - Full IoC container with constructor, property, and method injection - **Decorator-Based API** - TypeScript decorators for controllers, services, middleware, and more - **Controller System** - REST API controllers with route decorators (`@Get`, `@Post`, `@Put`, `@Delete`, `@Patch`, `@All`, `@Route`) - **Service Layer** - Service components with lifecycle management - **Middleware System** - Global, pattern-based, controller, and route-level middleware - **Context API** - Unified request context abstraction across adapters - **[Validation](/docs/concepts/validation)** - Zod-based request validation with `@Middleware({ validator: true })` - **[Static File Serving](/docs/concepts/static-files)** - `@StaticServe` with lifecycle hooks - **WebSocket Support** - Decorator-based WebSocket with namespace, room management, and [multi-pod transport](/docs/concepts/websocket#multi-pod-websocket) - **[Ulak](/docs/concepts/ulak)** - Central message hub that breaks circular dependencies between services, WebSocket namespaces, and microservice transports - **Configuration Management** - `@Config` decorator for server configuration - **EventService Support** - Built-in native EventService support with `@EventService` and `@On` - **[Scheduled Tasks](/docs/concepts/scheduled-tasks)** - Cron-based task scheduling with `@Schedule` and `CronRunner` - **[FrontendController](/docs/concepts/frontend-controller)** - Serve HTML pages using Bun's native HTML imports with `@FrontendController` and `@Page` - **[PostProcessor](/docs/concepts/post-processor)** - Component lifecycle hooks for metadata collection and instance transformation - **SSE/Streaming** - Server-Sent Events with `stream()`, `streamSSE()`, `streamText()` - **Duplicate Route Detection** - Prevents accidental route conflicts at startup - **Graceful Server Shutdown** - `server.stop()` with proper resource cleanup - **[Microservices](/docs/concepts/microservices)** - Broker-agnostic messaging with `@MessageController`, `@MessagePattern` (RPC), `@EventPattern` (events), Ulak client API, and multiple named transports - **Headless Mode** - Start without an HTTP adapter for message-driven internal services, with optional health endpoint ### Testing - **[mockComponent](/docs/testing/mock-component)** - Unit-level testing that auto-mocks every `@Inject` dependency of a component, including expression injections such as `ulak()` - **[createTestApp](/docs/testing/test-app)** - Boot a full application in a test, replace any registered component with a mock, and assert on real HTTP responses with a fluent chain - **[createWebTest](/docs/testing/web-test)** - Controller-slice testing: routing, middlewares and validators stay real while every other dependency is auto-mocked - **Unix Socket Dispatch** - Run the adapter's real routing pipeline without occupying a TCP port, so parallel suites never collide ### Adapters - **Ergenecore Adapter** - Native Bun adapter with SIMD-accelerated routing, streaming, and unified `HttpException` - **Hono Adapter** - Hono-based adapter with [strict mode (trailing slash)](/docs/adapters/hono#trailing-slash-strict-mode), streaming, and middleware compatibility ### Official Packages - **[@asenajs/asena-logger](/docs/packages/logger)** - Structured logging with multiple transports (console, file, Loki) - **[@asenajs/asena-drizzle](/docs/packages/drizzle)** - Drizzle ORM integration with repository pattern - **[@asenajs/asena-openapi](/docs/packages/openapi)** - Automatic OpenAPI 3.1 spec generation from existing validators - **[@asenajs/asena-redis](/docs/packages/redis)** - Redis client with multi-pod WebSocket transport (pub/sub) and production-grade microservice transport (Redis Streams: consumer groups, retry + DLQ, graceful drain) - **[@asenajs/asena-kafka](/docs/packages/kafka)** - Kafka microservice transport with deterministic topic management, broker-tracked retry attempts, DLQ, and external-topic interop - **[@asenajs/asena-otel](/docs/packages/opentelemetry)** - OpenTelemetry tracing with automatic instrumentation, including distributed tracing across microservices via `otelMessaging()` ::: info Independent Versioning Adapters and official packages version independently of the core framework. `@asenajs/asena` v0.9.x is the baseline they all target — check each package page for its own current version. ::: ### CLI Tools - **Project Scaffolding** - `asena create` command for project generation - **Code Generation** - Generate controllers, services, middleware, WebSocket services - **Project Bundling** - `asena build` command bundles your project based on [`asena-config.ts`](/docs/cli/configuration), significantly improving performance by reducing cold start time and package size ## 📋 Planned for v1.0 These features are **planned for the v1.0 release** and will make Asena enterprise-ready: ### Plugin System A powerful plugin architecture allowing third-party extensions. **Features:** - Plugin lifecycle hooks (`onLoad`, `onStart`, `onStop`) - Plugin dependency resolution - Plugin configuration management - Official plugin registry **Use Cases:** - Authentication plugins (OAuth, JWT, SAML) - Database adapters (PostgreSQL, MySQL, MongoDB) - Monitoring and APM integrations --- ## 💡 Future Ideas (CLI) These features are **under consideration** for future CLI releases: ### Dev Console (Quarkus-inspired) Interactive development console for debugging and inspection. **Planned Features:** - Web-based dashboard running during development - Live route inspection and testing - Request/response logging - Performance metrics in real-time **Inspiration:** Similar to Quarkus Dev UI ### Container Visualizer Visual representation of IoC container dependencies. **Planned Features:** - Dependency graph visualization - Service lifecycle tracking - Circular dependency detection **Benefits:** - Understanding service dependencies ::: warning No Timeline Yet These CLI features are **ideas under discussion** and do not have a fixed release timeline. ::: --- ## 🎯 Release Philosophy ### Version Strategy - **v0.x.x** - Pre-1.0 releases with breaking changes possible - **v1.0.0** - First stable release with API stability guarantees - **v1.x.x** - Patch and minor releases with backward compatibility - **v2.0.0** - Major release with new features and potential breaking changes ### Stability Guarantees | Version | Status | Breaking Changes | Production Use | |:--------|:-------|:-----------------|:---------------| | v0.8.x | Previous | Possible | Yes (with caution) | | v0.9.x | Current | Possible | Yes (with caution) | | v1.0.0+ | Stable | Semantic versioning | Recommended | ### Development Priorities 1. **Stability First** - Bug fixes and reliability improvements take priority 2. **Performance** - Maintain Bun's performance advantages 3. **Developer Experience** - Clear APIs, great documentation, helpful errors 4. **Community Feedback** - Feature requests and issues guide our roadmap --- ## 🤝 Community Involvement ### How to Contribute We welcome contributions in many forms: - **Code Contributions** - Submit PRs for features or bug fixes - **Documentation** - Improve guides, add examples, fix typos - **Feature Requests** - Open GitHub issues with your ideas - **Bug Reports** - Report issues with reproducible examples - **Testing** - Test pre-release versions and provide feedback ### Feature Request Process 1. Open a GitHub issue with the `feature-request` label 2. Describe the use case and expected behavior 3. Community discussion and feedback 4. Core team review and prioritization 5. Implementation and release --- ## 📅 Release Schedule ### Current Cycle - **v0.7.0** - Released April 2026 (OpenAPI, Redis, OTel, FrontendController, Schedule, PostProcessor, Streaming) - **v0.7.1** - Released April 2026 (`routePattern` on Context, FrontendController registration refinements) - **v0.8.0** - Released July 2026 (Microservices, Headless mode, Kafka package, test harness, Redis Streams transport, distributed tracing) - **v0.9.0** - Released July 2026 (Decorator inheritance, `onNotFound` hook, uniform error and 404 handling across adapters, branded `HttpException`) - **v1.0.0** - TBD (Plugin system) ::: tip Follow Progress Track development progress on our [GitHub repository](https://github.com/AsenaJs/Asena) and join discussions in [GitHub Issues](https://github.com/AsenaJs/Asena/issues). ::: --- ## 🔮 Long-Term Vision Our vision for Asena is to become the **go-to framework for Bun-based web applications**, combining: - **Enterprise-Grade Features** - Plugin system, observability, API documentation - **Developer Experience** - Intuitive APIs, excellent tooling, comprehensive docs - **Performance** - Native Bun speed with minimal overhead - **Ecosystem** - Rich collection of official and community packages --- ## Related - [Get Started](/docs/get-started) - Start building with Asena today - [CLI Overview](/docs/cli/overview) - Learn about Asena CLI tools - [GitHub Repository](https://github.com/AsenaJs/Asena) - Contribute to the project - [Examples](/docs/examples) - See real-world usage examples --- # Controllers Section: Concepts Source: https://asena.sh/raw/concepts/controllers.md # Controllers Controllers are the backbone of your Asena application. They handle incoming HTTP requests, process them, and return responses to the client. Using TypeScript decorators, you can define routes, inject services, and integrate middleware seamlessly. ## Quick Start Here's a complete controller example with both Ergenecore and Hono adapters: ::: code-group ```typescript [Ergenecore] import { Controller } from '@asenajs/asena/decorators'; import { Get, Post } from '@asenajs/asena/decorators/http'; import type { Context } from '@asenajs/ergenecore'; @Controller('/users') export class UserController { @Get('/') async list(context: Context) { const page = await context.getQuery('page') || '1'; return context.send({ users: [], page }); } @Get('/:id') async getById(context: Context) { const id = context.getParam('id'); return context.send({ id, name: 'John Doe' }); } @Post('/') async create(context: Context) { const body = await context.getBody(); return context.send({ created: true, data: body }, 201); } } ``` ```typescript [Hono] import { Controller } from '@asenajs/asena/decorators'; import { Get, Post } from '@asenajs/asena/decorators/http'; import type { Context } from '@asenajs/hono-adapter'; @Controller('/users') export class UserController { @Get('/') async list(context: Context) { const page = await context.getQuery('page') || '1'; return context.send({ users: [], page }); } @Get('/:id') async getById(context: Context) { const id = context.getParam('id'); return context.send({ id, name: 'John Doe' }); } @Post('/') async create(context: Context) { const body = await context.getBody(); return context.send({ created: true, data: body }, 201); } } ``` ::: ::: tip Unified Context API Both Ergenecore and Hono adapters provide the **same Context API** (`getParam`, `getQuery`, `getBody`, `send`, etc.). Only import the Context type from your chosen adapter package. ::: ## The @Controller Decorator The `@Controller()` decorator marks a class as a request handler and defines the base path for all routes within it. ### Syntax ```typescript @Controller(path: string) @Controller(params: ControllerParams) ``` ### Simple Path (String) ```typescript @Controller('/api/users') export class UserController { // All routes will be prefixed with /api/users } ``` ### With ControllerParams Object When you need to add middlewares or other options, use the `ControllerParams` object: ```typescript import { AuthMiddleware } from '../middlewares/AuthMiddleware'; @Controller({ path: '/admin', middlewares: [AuthMiddleware] }) export class AdminController { // All routes require authentication } ``` ### ControllerParams Interface ```typescript interface ControllerParams { path: string; // Required: Base path for all routes middlewares?: Middleware[]; // Optional: Controller-level middlewares } ``` | Property | Type | Required | Description | |:---------|:-----|:---------|:------------| | `path` | `string` | **Yes** | Base path for all routes in this controller | | `middlewares` | `Middleware[]` | No | Array of middleware classes to apply to all routes | ::: tip Path is Always Required When using the object notation, `path` is **required**. If you want routes at the root level, use `path: '/'`: ```typescript @Controller({ path: '/', middlewares: [LoggerMiddleware] }) export class AppController { } ``` ::: ## HTTP Method Decorators Asena provides decorators for all standard HTTP methods: | Decorator | HTTP Method | Common Use Case | |:----------|:------------|:----------------| | `@Get()` | GET | Retrieve resources | | `@Post()` | POST | Create resources | | `@Put()` | PUT | Update resources (full replacement) | | `@Patch()` | PATCH | Partial update | | `@Delete()` | DELETE | Delete resources | | `@Options()` | OPTIONS | CORS preflight | | `@Head()` | HEAD | Metadata only | ### Simple Route ```typescript @Get('/profile') async getProfile(context: Context) { return context.send({ name: 'John' }); } ``` ### With Path Parameter ```typescript @Get('/:id') async getById(context: Context) { const id = context.getParam('id'); return context.send({ id }); } ``` ### With Route-Level Options ```typescript import { AuthMiddleware } from '../middlewares/AuthMiddleware'; import { CreateUserValidator } from '../validators/CreateUserValidator'; import type { Context } from '@asenajs/hono-adapter'; @Post({ path: '/', middlewares: [AuthMiddleware], validator: CreateUserValidator }) public async create(context: Context) { const body = await context.getBody(); return context.send({ created: true, data: body }, 201); } ``` ## Working with Request Data ### Route Parameters Extract dynamic segments from the URL path: ```typescript @Get('/:userId/posts/:postId') async getPost(context: Context) { const userId = context.getParam('userId'); const postId = context.getParam('postId'); return context.send({ userId, postId }); } ``` ### Query Parameters Access URL query strings: ```typescript @Get('/search') async search(context: Context) { const query = await context.getQuery('q'); const page = await context.getQuery('page') || '1'; const limit = await context.getQuery('limit') || '10'; return context.send({ query, page, limit }); } ``` ### Request Body Parse JSON request bodies: ```typescript @Post('/users') async create(context: Context) { const body = await context.getBody<{ name: string; email: string; age: number; }>(); // Type-safe body access return context.send({ created: true, user: body }, 201); } ``` ### Headers Access request headers: ```typescript @Get('/profile') async getProfile(context: Context) { const token = context.headers["authorization"]; const userAgent = context.headers["user-agent"]; return context.send({ token, userAgent }); } ``` ## Sending Responses ### JSON Response ```typescript @Get('/data') async getData(context: Context) { return context.send({ message: 'Success', data: [] }); } ``` ### Custom Status Code ```typescript @Post('/users') async create(context: Context) { return context.send({ created: true }, 201); } @Get('/not-found') async notFound(context: Context) { return context.send({ error: 'Not found' }, 404); } ``` ### With Custom Headers ```typescript @Get('/headers') async download(context: Context) { context.res.headers.set('X-My-Header', 'Awsome-Header'); return context.send('File content', 200); } ``` ## Context API Reference Both adapters provide a **unified Context API** for common operations. ### Common Methods (Both Adapters) ::: code-group ```typescript [ergenecore] import type { Context } from '@asenajs/ergenecore'; ``` ```typescript [hono] import type { Context } from '@asenajs/hono-adapter' ``` ::: | Method | Description | Example | |:-------|:------------|:--------| | `getParam(key)` | Get route parameter | `context.getParam('id')` | | `getQuery(key)` | Get single query parameter | `await context.getQuery('page')` | | `getQueryAll(key)` | Get all values for query parameter | `await context.getQueryAll('colors')` | | `getBody()` | Get typed request body | `await context.getBody()` | | `getParseBody()` | Get parsed multipart/form-data body | `await context.getParseBody()` | | `getArrayBuffer()` | Get body as ArrayBuffer | `await context.getArrayBuffer()` | | `getBlob()` | Get body as Blob | `await context.getBlob()` | | `getFormData()` | Get body as FormData | `await context.getFormData()` | | `getCookie(name, secret?)` | Get cookie value | `await context.getCookie('session')` | | `setCookie(name, value, options?)` | Set cookie | `await context.setCookie('token', 'abc123')` | | `deleteCookie(name, options?)` | Delete cookie | `await context.deleteCookie('session')` | | `setValue(key, value)` | Store value in context state | `context.setValue('userId', 123)` | | `getValue(key)` | Get value from context state | `context.getValue('userId')` | | `setWebSocketValue(value)` | Store WebSocket-specific value | `context.setWebSocketValue(data)` | | `getWebSocketValue()` | Get WebSocket-specific value | `context.getWebSocketValue()` | | `send(data, status?)` | Send response with auto content-type | `context.send({ success: true }, 200)` | | `html(data)` | Send HTML response | `context.html('

Hello

')` | | `redirect(url)` | Perform HTTP redirect | `context.redirect('/login')` | **Properties:** | Property | Type | Description | |:---------|:-----|:------------| | `req` | `R` | Original request object | | `res` | `S` | Original response object | | `headers` | `Record` | Request headers as key-value pairs | ::: tip What differs between adapters The whole table above is unified - `setResponseHeader()` included. What actually differs is the **type of `context.req`**: a native `Request` on Ergenecore, a `HonoRequest` on Hono. One behavioural difference worth knowing: calling `setResponseHeader()` twice with the same key **replaces** the value on Ergenecore but **appends** a second header on Hono. See adapter documentation for the complete API reference. ::: For more details about Context API methods and advanced usage, see the [Context API Reference](/docs/concepts/context) guide. ## Service Injection Controllers can inject services using the `@Inject` decorator for business logic separation. The `@Inject` decorator supports two injection methods: 1. **Class-based injection** - Inject by passing the service class directly 2. **String-based injection** - Inject by passing the service name as a string ### Create a Service ```typescript // src/services/UserService.ts import { Service } from '@asenajs/asena/decorators'; @Service() export class UserService { private users = [ { id: 1, name: 'John Doe', email: 'john@example.com' }, { id: 2, name: 'Jane Smith', email: 'jane@example.com' } ]; findAll() { return this.users; } findById(id: number) { return this.users.find(user => user.id === id); } create(userData: { name: string; email: string }) { const newUser = { id: this.users.length + 1, ...userData }; this.users.push(newUser); return newUser; } } ``` ### Inject Service in Controller ```typescript import { Controller } from '@asenajs/asena/decorators'; import { Get, Post } from '@asenajs/asena/decorators/http'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { UserService } from '../services/UserService'; import type { Context } from '@asenajs/ergenecore'; @Controller('/users') export class UserController { @Inject(UserService) private userService: UserService; @Get('/') async list(context: Context) { const users = this.userService.findAll(); return context.send({ users }); } @Get('/:id') async getById(context: Context) { const id = Number(context.getParam('id')); const user = this.userService.findById(id); if (!user) { return context.send({ error: 'User not found' }, 404); } return context.send({ user }); } @Post('/') async create(context: Context) { const body = await context.getBody<{ name: string; email: string }>(); const user = this.userService.create(body); return context.send({ created: true, user }, 201); } } ``` ### String-Based Injection You can also inject services using their registered name as a string. This is useful when you want to decouple your code from concrete implementations or when working with dynamically registered services. First, register your service with a custom name: ::: tip Name Your Components for String-Based Injection A component is registered under its `name` if you give one, and under its **class name** otherwise - so `@Inject('UserService')` already resolves an unnamed `@Service()` class called `UserService`. **Why give an explicit name anyway?** The Bun bundler may rename classes during a production build, and the container key would change with it. An explicit `@Service('UserService')` pins the key so string injection keeps working after minification. **Example:** ```typescript @Service('UserService') class UserService { } @Service('AuthController') class AuthController { @Inject('UserService') // ✅ Safe - uses explicit name private userService!: UserService; } ``` ::: ```typescript // src/services/UserService.ts import { Service } from '@asenajs/asena/decorators'; @Service('UserService') // Register with custom name export class UserService { // ... same implementation as above } ``` Then inject using the string name: ```typescript import { Controller } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { Get, Post } from '@asenajs/asena/decorators/http'; import type { Context } from '@asenajs/hono-adapter'; @Controller('/users') export class UserController { @Inject('UserService') // Inject by string name private userService: any; // Type needs to be specified manually or use a type assertion @Get('/') async list(context: Context) { const users = this.userService.findAll(); return context.send({ users }); } } ``` ::: tip Class-based vs String-based Injection **Class-based injection** (recommended): - ✅ Type-safe - TypeScript knows the exact type - ✅ Refactor-friendly - Renaming the class updates all references - ✅ Better IDE support (autocomplete, go-to-definition) **String-based injection**: - ✅ Loose coupling - No direct dependency on the class - ✅ Dynamic service resolution - ❌ No type safety - Manual type annotations required - ❌ Runtime errors if service name is misspelled ::: ::: tip Learn More About DI See the [Dependency Injection](/docs/concepts/dependency-injection) guide for advanced patterns. ::: ## Middleware Integration You can apply middleware at three levels: global, controller, and route. ### Controller-Level Middleware ```typescript import { Controller } from '@asenajs/asena/decorators'; import { Get } from '@asenajs/asena/decorators/http'; import type { Context } from '@asenajs/ergenecore'; import { AuthMiddleware } from '../middlewares/AuthMiddleware'; @Controller({ path: '/admin', middlewares: [AuthMiddleware] }) export class AdminController { @Get('/dashboard') async dashboard(context: Context) { // AuthMiddleware runs before this handler const userId = context.getValue('userId'); return context.send({ userId, message: 'Admin dashboard' }); } } ``` ### Route-Level Middleware ```typescript import { Controller } from '@asenajs/asena/decorators'; import { Get, Post } from '@asenajs/asena/decorators/http'; import type { Context } from '@asenajs/ergenecore'; import { AuthMiddleware } from '../middlewares/AuthMiddleware'; @Controller('/posts') export class PostController { @Get('/') async list(context: Context) { // Public endpoint - no middleware return context.send({ posts: [] }); } @Post({ path: '/', middlewares: [AuthMiddleware] }) async create(context: Context) { // Only runs if AuthMiddleware passes const body = await context.getBody(); return context.send({ created: true, post: body }, 201); } } ``` ::: tip Middleware Execution Order Middleware executes in this order: 1. Global middleware (defined in `@Config`) 2. Controller-level middleware 3. Route-level middleware 4. Route handler ::: ## Validation Asena supports automatic request validation using Zod schemas. Both adapters support it. ### Create a Validator ```typescript // src/validators/CreateUserValidator.ts import { Middleware } from '@asenajs/asena/decorators'; import { ValidationService } from '@asenajs/ergenecore'; import { z } from 'zod'; @Middleware({ validator: true }) export class CreateUserValidator extends ValidationService { json() { return z.object({ name: z.string().min(3).max(50), email: z.string().email(), age: z.number().min(18).max(120) }); } } ``` ### Use Validator in Controller ```typescript import { Controller } from '@asenajs/asena/decorators'; import { Post } from '@asenajs/asena/decorators/http'; import type { Context } from '@asenajs/ergenecore'; import { CreateUserValidator } from '../validators/CreateUserValidator'; @Controller('/users') export class UserController { @Post({ path: '/', validator: CreateUserValidator }) async create(context: Context) { // Body is automatically validated const body = await context.getBody(); return context.send({ created: true, data: body }, 201); } } ``` ::: warning Validation Errors If validation fails and your application defines **no** `onError` handler, the adapter answers with its own `400 Bad Request` envelope. As soon as you define `ConfigService.onError`, the failure is routed there instead as a `ValidationError` - match it with `isValidationError()`. See [Validation](/docs/concepts/validation#validation-error-responses). ::: ::: tip Learn More About Validation See the [Validation](/docs/concepts/validation) guide for advanced schemas and custom error handling. ::: ## Combining Features Here's a real-world example combining services, middleware, and validation: ```typescript import { Controller } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { Get, Post, Put, Delete } from '@asenajs/asena/decorators/http'; import { AuthMiddleware } from '../middlewares/AuthMiddleware'; import { CreatePostValidator } from '../validators/CreatePostValidator'; import { UpdatePostValidator } from '../validators/UpdatePostValidator'; import { PostService } from '../services/PostService'; import type { Context } from '@asenajs/ergenecore'; @Controller({ path: '/posts', middlewares: [AuthMiddleware] // All routes require authentication }) export class PostController { @Inject(PostService) private postService: PostService; @Get('/') async list(context: Context) { const page = Number(await context.getQuery('page')) || 1; const limit = Number(await context.getQuery('limit')) || 10; const posts = await this.postService.findAll(page, limit); return context.send({ posts, page, limit }); } @Get('/:id') async getById(context: Context) { const id = Number(context.getParam('id')); const post = await this.postService.findById(id); if (!post) { return context.send({ error: 'Post not found' }, 404); } return context.send({ post }); } @Post({ path: '/', validator: CreatePostValidator }) async create(context: Context) { const userId = context.getValue('userId'); const body = await context.getBody<{ title: string; content: string; }>(); const post = await this.postService.create(userId, body); return context.send({ created: true, post }, 201); } @Put({ path: '/:id', validator: UpdatePostValidator }) async update(context: Context) { const id = Number(context.getParam('id')); const userId = context.getValue('userId'); const body = await context.getBody(); const post = await this.postService.update(id, userId, body); if (!post) { return context.send({ error: 'Post not found or unauthorized' }, 404); } return context.send({ updated: true, post }); } @Delete('/:id') async delete(context: Context) { const id = Number(context.getParam('id')); const userId = context.getValue('userId'); const deleted = await this.postService.delete(id, userId); if (!deleted) { return context.send({ error: 'Post not found or unauthorized' }, 404); } return context.send({ deleted: true }); } } ``` ## Best Practices ::: tip Keep Controllers Thin Move business logic to services. Controllers should only handle: - Request parsing - Calling services - Returning responses ::: ::: tip Use Type Safety Always type your request bodies and service responses for better IDE support and fewer runtime errors: ```typescript interface CreateUserDto { name: string; email: string; age: number; } const body = await context.getBody(); ``` ::: ::: tip Consistent Error Handling Use consistent error response formats across your application: ```typescript return context.send({ error: 'User not found', code: 'USER_NOT_FOUND' }, 404); ``` ::: ::: warning Async Handlers Always use `async` handlers when working with asynchronous operations like database calls or external APIs. Do not forget `await` your `Promises`. If not awaited promises throws an error, it will shut down server. ::: ## Related Documentation - [Services](/docs/concepts/services) - Business logic separation - [Middleware](/docs/concepts/middleware) - Request/response interception - [Validation](/docs/concepts/validation) - Request validation with Zod - [Dependency Injection](/docs/concepts/dependency-injection) - IoC container - [Inheritance](/docs/concepts/inheritance) - Sharing routes through a base controller - [Context API](/docs/concepts/context) - Detailed context methods for each adapter - [Ergenecore Adapter](/docs/adapters/ergenecore) - Ergenecore-specific features - [Hono Adapter](/docs/adapters/hono) - Hono-specific features --- # Services Section: Concepts Source: https://asena.sh/raw/concepts/services.md # Services Services contain your application's business logic and are the core of your application's functionality. They are injectable classes that can be used throughout your application using Asena's dependency injection system. ## What is a Service? A service is a class decorated with `@Service()` that contains business logic, data manipulation, external API calls, or any other application-specific functionality. Services promote: - **Separation of Concerns**: Keep controllers thin and business logic in services - **Reusability**: Share logic across multiple controllers - **Testability**: Easy to unit test in isolation - **Maintainability**: Centralized business logic ## Creating a Service ### Basic Service ```typescript import { Service } from '@asenajs/asena/decorators'; @Service() export class UserService { async findUser(id: string) { // Business logic return { id, name: 'John Doe', email: 'john@example.com' }; } async createUser(data: { name: string; email: string }) { // Validation and business logic if (!data.email.includes('@')) { throw new Error('Invalid email'); } return { id: 'new-id', ...data }; } async deleteUser(id: string) { // Business logic return { success: true }; } } ``` ## Using Services in Controllers Inject services into controllers using the `@Inject` decorator: ```typescript import { Controller } from '@asenajs/asena/decorators'; import { Get, Post, Delete } from '@asenajs/asena/decorators/http'; import { Inject } from '@asenajs/asena/decorators/ioc'; import type { Context } from '@asenajs/ergenecore'; @Controller('/users') export class UserController { @Inject(UserService) private userService: UserService; @Get('/:id') async getUser(context: Context) { const id = context.getParam('id'); const user = await this.userService.findUser(id); return context.send(user); } @Post('/') async createUser(context: Context) { const data = await context.getBody(); const user = await this.userService.createUser(data); return context.send(user, 201); } @Delete('/:id') async deleteUser(context: Context) { const id = context.getParam('id'); await this.userService.deleteUser(id); return context.send({ success: true }, 200); } } ``` ## Service Dependencies Services can depend on other services: ```typescript // Emailservice.ts import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; @Service() export class EmailService { async sendEmail(to: string, subject: string, body: string) { console.log(`Sending email to ${to}: ${subject}`); // Email sending logic } } ``` ```typescript // UserService.ts import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; @Service() export class UserService { @Inject(EmailService) private emailService: EmailService; async createUser(data: { name: string; email: string }) { const user = { id: 'new-id', ...data }; // Send welcome email await this.emailService.sendEmail( user.email, 'Welcome!', `Hello ${user.name}, welcome to our platform!` ); return user; } } ``` ## Layered Architecture Asena promotes a clean layered architecture: ``` Request -> Controller -> Service -> Repository -> Database Response <- Controller <- Service <- Repository <- Database ``` ### Example: Complete Layered Structure ```typescript // repository.ts import { Repository, BaseRepository } from '@asenajs/asena-drizzle'; import { eq } from 'drizzle-orm'; @Repository({ table: users, databaseService: 'MainDB' }) export class UserRepository extends BaseRepository { async findByEmail(email: string) { return this.findOne(eq(users.email, email)); } } // service.ts @Service() export class UserService { @Inject(UserRepository) private userRepo: UserRepository; @Inject(EmailService) private emailService: EmailService; async createUser(data: { name: string; email: string }) { // Check if user exists const existing = await this.userRepo.findByEmail(data.email); if (existing) { throw new Error('Email already exists'); } // Create user const user = await this.userRepo.create(data); // Send welcome email await this.emailService.sendEmail( user.email, 'Welcome!', `Hello ${user.name}!` ); return user; } async getUser(id: string) { const user = await this.userRepo.findById(id); if (!user) { throw new Error('User not found'); } return user; } } // controller.ts @Controller('/users') export class UserController { @Inject(UserService) private userService: UserService; @Get('/:id') async getUser(context: Context) { const user = await this.userService.getUser(context.getParam('id')); return context.send(user); } @Post('/') async createUser(context: Context) { const data = await context.getBody(); const user = await this.userService.createUser(data); return context.send(user, 201); } } ``` ## Service Patterns ### 1. Business Logic Service ```typescript @Service() export class OrderService { @Inject(OrderRepository) private orderRepo: OrderRepository; @Inject(InventoryService) private inventoryService: InventoryService; @Inject(PaymentService) private paymentService: PaymentService; async createOrder(items: Array<{ productId: string; quantity: number }>) { // Check inventory for (const item of items) { const available = await this.inventoryService.checkStock( item.productId, item.quantity ); if (!available) { throw new Error(`Product ${item.productId} is out of stock`); } } // Calculate total const total = await this.calculateTotal(items); // Process payment const payment = await this.paymentService.charge(total); // Create order const order = await this.orderRepo.create({ items, total, paymentId: payment.id, status: 'pending' }); // Update inventory for (const item of items) { await this.inventoryService.decrementStock( item.productId, item.quantity ); } return order; } private async calculateTotal(items: Array<{ productId: string; quantity: number }>) { // Calculate logic return 100; } } ``` ### 2. Integration Service ```typescript @Service() export class StripeService { private stripeClient = new Stripe(process.env.STRIPE_SECRET_KEY); async createPaymentIntent(amount: number, currency: string = 'usd') { return await this.stripeClient.paymentIntents.create({ amount: amount * 100, // Convert to cents currency, }); } async confirmPayment(paymentIntentId: string) { return await this.stripeClient.paymentIntents.confirm(paymentIntentId); } } ``` ### 3. Utility Service ```typescript @Service() export class CryptoService { async hashPassword(password: string): Promise { return await Bun.password.hash(password); } async verifyPassword(password: string, hash: string): Promise { return await Bun.password.verify(password, hash); } generateToken(length: number = 32): string { const buffer = new Uint8Array(length); crypto.getRandomValues(buffer); return Buffer.from(buffer).toString('hex'); } } ``` ## Service Scopes Services can have different lifecycle scopes that control how instances are created and shared. ### Singleton Scope (Default) By default, all services use `Scope.SINGLETON` - a single instance is created and shared across the entire application: ```typescript import { Service } from '@asenajs/asena/decorators'; import { Scope } from '@asenajs/asena/decorators/ioc'; @Service() // Default: Scope.SINGLETON export class ConfigService { private config = { apiUrl: 'https://api.example.com' }; getConfig() { return this.config; } } // Or explicitly: @Service({ scope: Scope.SINGLETON }) export class DatabaseService { // Same instance shared everywhere } ``` **When to use SINGLETON:** - Configuration services - Database connections - Caching services - Stateless services - Services that maintain application-wide state ### Prototype Scope `Scope.PROTOTYPE` creates a **new instance** for every injection: ```typescript import { Service } from '@asenajs/asena/decorators'; import { Scope } from '@asenajs/asena/decorators/ioc'; @Service({ scope: Scope.PROTOTYPE }) export class RequestLoggerService { private requestId = crypto.randomUUID(); private logs: string[] = []; log(message: string) { this.logs.push(`[${this.requestId}] ${message}`); } getLogs() { return this.logs; } } // Each injection gets a new instance: @Controller('/users') export class UserController { @Inject(RequestLoggerService) // New instance private logger: RequestLoggerService; } @Controller('/posts') export class PostController { @Inject(RequestLoggerService) // Different instance private logger: RequestLoggerService; } ``` **When to use PROTOTYPE:** - Services that hold per-request state - Services that shouldn't be shared - Services with mutable state per instance - Testing scenarios where isolation is needed ### Scope Comparison | Aspect | Singleton | Prototype | |:-------|:----------|:----------| | **Instances** | One per application | One per injection | | **Memory** | Low (single instance) | Higher (multiple instances) | | **State** | Shared across app | Isolated per injection | | **Performance** | Fast (reused) | Slower (created each time) | | **Use Case** | Stateless services, configs | Per-request state, isolation | ::: warning State Management in Singletons Singleton services are shared across all requests. Be careful with mutable state: ```typescript // ❌ Bad: Shared mutable state in singleton @Service() // Singleton by default export class UserService { private currentUser: User; // Shared across all requests - DANGEROUS! setCurrentUser(user: User) { this.currentUser = user; // Race condition! } } // ✅ Good: Pass state as parameters @Service() export class UserService { async getUserData(userId: string) { // Stateless - safe for singletons return await this.userRepo.findById(userId); } } ``` ::: ## String-based vs Class-based Injection Asena supports two ways to inject services: by class reference or by string name. ### Class-based Injection (Recommended) ```typescript @Service() export class UserService { async findUser(id: string) { return { id, name: 'John' }; } } @Controller('/users') export class UserController { @Inject(UserService) // ✅ Type-safe private userService: UserService; @Get('/:id') async getUser(context: Context) { // TypeScript knows all methods const user = await this.userService.findUser(context.getParam('id')); return context.send(user); } } ``` **Advantages:** - ✅ Full TypeScript type safety - ✅ IDE autocomplete and intellisense - ✅ Refactoring support (rename class updates all references) - ✅ Compile-time error detection ### String-based Injection ```typescript @Service('UserService') // Register with custom name export class UserService { async findUser(id: string) { return { id, name: 'John' }; } } @Controller('/users') export class UserController { @Inject('UserService') // Maybe throw runtime error private userService: UserService; @Get('/:id') async getUser(context: Context) { const user = await this.userService.findUser(context.getParam('id')); return context.send(user); } } ``` **Advantages:** - ✅ Loose coupling (no direct class import) - ✅ Dynamic service resolution - ✅ Useful for plugin systems or dynamic loading **Disadvantages:** - ❌ No compile-time type checking - ❌ Runtime errors if service name is misspelled - ❌ No IDE autocomplete ::: tip When to Use String-based Injection? Use string-based injection when: - Building plugin architectures - Need to swap implementations at runtime - Working with dynamically loaded modules - Absolute decoupling is required - it can improve IDE performance For most use cases, **class-based injection is recommended**. ::: ## @Service Decorator API Complete reference for the `@Service` decorator: ```typescript import { Service } from '@asenajs/asena/decorators'; import { Scope } from '@asenajs/asena/decorators/ioc'; // 1. Basic service (singleton by default) @Service() export class BasicService { } // 2. Named service (string-based injection) @Service('MyCustomService') export class NamedService { } // 3. Prototype scope (new instance per injection) @Service({ scope: Scope.PROTOTYPE }) export class PrototypeService { } // 4. Named with prototype scope @Service({ name: 'RequestHandler', scope: Scope.PROTOTYPE }) export class RequestHandlerService { } ``` ### ComponentParams Interface ```typescript interface ComponentParams { name?: string; // Custom name for string-based injection scope?: Scope; // Scope.SINGLETON or Scope.PROTOTYPE } ``` | Parameter | Type | Default | Description | |:----------|:-----|:--------|:------------| | `name` | `string` | the class name | Container key for the service. Pin it explicitly so string-based injection survives bundler minification. | | `scope` | `Scope` | `Scope.SINGLETON` | Service lifecycle scope. | ## Asena-Specific Best Practices ### 1. Choose the Right Scope ```typescript // ✅ Singleton for stateless services @Service() export class EmailService { async send(to: string, subject: string) { // No mutable state - safe as singleton } } // ✅ Prototype for per-request state @Service({ scope: Scope.PROTOTYPE }) export class RequestContextService { private startTime = Date.now(); private metrics: any[] = []; trackMetric(name: string, value: number) { this.metrics.push({ name, value }); } } ``` ### 2. Use Class-based Injection by Default ```typescript // ✅ Preferred: Type-safe class injection @Inject(UserService) private userService: UserService; // ⚠️ Only when necessary: String injection @Inject('UserService') private userService: any; ``` ### 3. Avoid Circular Dependencies ```typescript // ❌ Bad: Circular dependency @Service() export class ServiceA { @Inject(ServiceB) private serviceB: ServiceB; } @Service() export class ServiceB { @Inject(ServiceA) private serviceA: ServiceA; // Circular! } // ✅ Good: Introduce an intermediate service @Service() export class SharedService { } @Service() export class ServiceA { @Inject(SharedService) private shared: SharedService; } @Service() export class ServiceB { @Inject(SharedService) private shared: SharedService; } ``` ::: tip WebSocket + Services Circular Dependencies A common circular dependency occurs when **services need to send WebSocket messages** and **WebSocket handlers need to inject services** for business logic. **Problem:** ```typescript // ❌ This creates a circular dependency @WebSocket('/notifications') export class NotificationWebSocket extends AsenaWebSocketService<{}> { @Inject(UserService) // WebSocket needs service private userService: UserService; } @Service('UserService') export class UserService { @Inject(NotificationWebSocket) // ❌ Circular! private notificationWs: NotificationWebSocket; } ``` **Solution: Use Ulak Message Broker** Ulak breaks this circular dependency by acting as a centralized message broker: ```typescript import { Service} from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { ulak, type Ulak } from '@asenajs/asena/messaging'; @Service('UserService') export class UserService { // ✅ Inject Ulak namespace instead of WebSocket service @Inject(ulak('/notifications')) private notifications: Ulak.NameSpace<'/notifications'>; async createUser(name: string, email: string) { const user = await this.saveUser(name, email); // Send WebSocket message without injecting the WebSocket service await this.notifications.broadcast({ type: 'user_created', user }); return user; } private async saveUser(name: string, email: string) { // Database logic return { id: '123', name, email }; } } ``` For complete documentation on breaking WebSocket circular dependencies, see [Ulak - WebSocket Messaging System](/docs/concepts/ulak). ::: ## Testing Services Services are easy to test in isolation: ```typescript import { describe, expect, it, mock } from 'bun:test'; import { UserService } from './UserService'; describe('UserService', () => { it('should create user successfully', async () => { const mockRepo = { findByEmail: mock(() => Promise.resolve(null)), create: mock((data) => Promise.resolve({ id: '123', ...data })) }; const service = new UserService(); service['userRepo'] = mockRepo as any; const result = await service.createUser({ name: 'John', email: 'john@example.com' }); expect(result).toEqual({ id: '123', name: 'John', email: 'john@example.com' }); expect(mockRepo.create).toHaveBeenCalled(); }); it('should throw error if email exists', async () => { const mockRepo = { findByEmail: mock(() => Promise.resolve({ id: '1', email: 'john@example.com' })) }; const service = new UserService(); service['userRepo'] = mockRepo as any; await expect( service.createUser({ name: 'John', email: 'john@example.com' }) ).rejects.toThrow('Email already exists'); }); }); ``` ## Related Documentation - [Dependency Injection](/docs/concepts/dependency-injection) - [Controllers](/docs/concepts/controllers) - [Ulak - WebSocket Messaging System](/docs/concepts/ulak) - Break circular dependencies with WebSocket - [WebSocket](/docs/concepts/websocket) - [Drizzle ORM](/docs/packages/drizzle) - [Testing Guide](/docs/guides/testing) --- **Next Steps:** - Learn about [Dependency Injection](/docs/concepts/dependency-injection) - Share behaviour across services with [Inheritance](/docs/concepts/inheritance) - Explore [Repository Pattern](/docs/packages/drizzle) - Understand [Testing Strategies](/docs/guides/testing) --- # Dependency Injection Section: Concepts Source: https://asena.sh/raw/concepts/dependency-injection.md # Dependency Injection Asena provides a powerful IoC (Inversion of Control) container with field-based dependency injection. This allows you to inject services, repositories, and other components into your classes automatically. ## What is Dependency Injection? Dependency Injection (DI) is a design pattern where dependencies are provided to a class rather than the class creating them itself. This promotes: - **Loose Coupling**: Classes don't need to know how to create their dependencies - **Testability**: Easy to mock dependencies in tests - **Maintainability**: Change implementations without modifying dependent code - **Reusability**: Share instances across your application ## Basic Injection with @Inject The `@Inject` decorator is used to inject dependencies into your classes. ### Simple Class Injection ```typescript import { Controller } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { Delete, Get, Post, Put } from '@asenajs/asena/decorators/http'; import type { Context } from '@asenajs/ergenecore'; import { UserService } from '../services/UserService'; @Controller('/users') export class UserController { @Inject(UserService) private userService: UserService; @Get('/') async list(context: Context) { const users = await this.userService.getAllUsers(); return context.send({ users }); } } ``` ### String-Based Injection You can also inject services using their registered name as a string. This is useful when you want to decouple your code from concrete implementations or when working with dynamically registered services. First, register your service with a custom name: ::: tip Name Your Components for String-Based Injection A component is registered under its `name` if you give one, and under its **class name** otherwise - so `@Inject('UserService')` already resolves an unnamed `@Service()` class called `UserService`. **Why give an explicit name anyway?** The Bun bundler may rename classes during a production build, and the container key would change with it. An explicit `@Service('UserService')` pins the key so string injection keeps working after minification. ::: Inject services by their registered name: ```typescript import { Service } from '@asenajs/asena/decorators'; @Service('UserService') export class UserService { getAllUsers() { return [{ id: 1, name: 'John' }]; } } // Inject by string name @Controller('/users') export class UserController { @Inject('UserService') private userService: UserService; @Get('/') async list(context: Context) { const users = this.userService.getAllUsers(); return context.send({ users }); } } ``` ::: tip Class vs String Injection - **Class-based** - Type-safe, refactor-friendly (recommended) - **String-based** - Loose coupling, dynamic resolution ::: ## Injection with Expressions Expressions allow you to transform the injected dependency or extract specific properties. ### Extract Property from Service ```typescript import { Inject } from '@asenajs/asena/decorators/ioc'; import { DatabaseService } from '../services/DatabaseService'; import type { BunSQLDatabase } from 'drizzle-orm/bun-sql'; @Service() export class UserRepository { // Inject the 'connection' property from DatabaseService @Inject(DatabaseService, (service: DatabaseService) => service.connection) protected db: BunSQLDatabase; async findAll() { return await this.db.select().from(users); } } ``` ### Extract Method Result ```typescript @Service() export class ProductController { // Inject the result of calling getItems() @Inject(ItemService, (service: ItemService) => service.getItems()) private items: string[]; @Get('/items') getItems(context: Context) { return context.send({ items: this.items }); } } ``` ### Complex Transformations ```typescript @Service() export class ConfigService { getConfig() { return { apiUrl: 'https://api.example.com', timeout: 5000, retries: 3 }; } } @Service() export class ApiService { // Extract only the apiUrl from config @Inject(ConfigService, (service: ConfigService) => service.getConfig().apiUrl) private apiUrl: string; async fetchData() { const response = await fetch(`${this.apiUrl}/data`); return response.json(); } } ``` ### Expression Signature ```typescript @Inject(ServiceClass, (service: ServiceType) => any) ``` | Parameter | Type | Description | |:----------|:-----|:------------| | `ServiceClass` | `Class` or `string` | Service to inject | | `expression` | `(service) => any` | Optional transformation function | ## Strategy Pattern with @Strategy The `@Strategy` decorator injects **all implementations** of an interface, enabling the Strategy design pattern. ### 1. Define Interface ```typescript // src/services/NotificationService.ts export interface NotificationService { send(userId: string, message: string): Promise; } ``` ### 2. Implement Multiple Strategies Mark implementations with `@Implements`: ```typescript // src/services/EmailNotificationService.ts import { Service } from '@asenajs/asena/decorators'; import { Implements } from '@asenajs/asena/decorators/ioc'; import type { NotificationService } from './NotificationService'; @Service() @Implements('NotificationService') export class EmailNotificationService implements NotificationService { async send(userId: string, message: string): Promise { console.log(`Sending email to user ${userId}: ${message}`); // Email sending logic } } ``` ```typescript // src/services/SmsNotificationService.ts import { Service } from '@asenajs/asena/decorators'; import { Implements } from '@asenajs/asena/decorators/ioc'; import type { NotificationService } from './NotificationService'; @Service() @Implements('NotificationService') export class SmsNotificationService implements NotificationService { async send(userId: string, message: string): Promise { console.log(`Sending SMS to user ${userId}: ${message}`); // SMS sending logic } } ``` ```typescript // src/services/PushNotificationService.ts import { Service } from '@asenajs/asena/decorators'; import { Implements } from '@asenajs/asena/decorators/ioc'; import type { NotificationService } from './NotificationService'; @Service() @Implements('NotificationService') export class PushNotificationService implements NotificationService { async send(userId: string, message: string): Promise { console.log(`Sending push notification to user ${userId}: ${message}`); // Push notification logic } } ``` ### 3. Inject All Implementations ```typescript import { Strategy } from '@asenajs/asena/decorators/ioc'; import type { NotificationService } from '../services/NotificationService'; @Service() export class NotificationManager { @Strategy('NotificationService') private notificationServices: NotificationService[]; async notifyUser(userId: string, message: string) { // Send notification via all channels for (const service of this.notificationServices) { await service.send(userId, message); } } } ``` ### Strategy with Expressions Extract specific properties from all implementations: ```typescript interface PaymentProvider { name: string; process(amount: number): Promise; } @Service() @Implements('PaymentProvider') export class StripeProvider implements PaymentProvider { name = 'Stripe'; async process(amount: number) { /* ... */ } } @Service() @Implements('PaymentProvider') export class PayPalProvider implements PaymentProvider { name = 'PayPal'; async process(amount: number) { /* ... */ } } // Inject only the names @Service() export class PaymentService { @Strategy('PaymentProvider', (provider: PaymentProvider) => provider.name) private providerNames: string[]; // ['Stripe', 'PayPal'] getAvailableProviders() { return this.providerNames; } } ``` ### How many implementations? A `@Strategy` field is **always an array**, whatever the number of implementations: | `@Implements('Key')` components | injected value | |:--------------------------------|:---------------| | 0 | `[]` | | 1 | `[implementation]` | | 2+ | every implementation | Zero is a normal state, not a misconfiguration. `@Strategy` is the consumer side of a plugin point, and a plugin point with no plugins is how one starts — you write the consumer first and the implementations arrive per feature: ```typescript @Service() export class NotificationManager { @Strategy('NotificationService') private notificationServices: NotificationService[]; // [] until the first @Implements exists async notifyUser(userId: string, message: string) { // Works from day one - iterates nothing until a channel is added for (const service of this.notificationServices) { await service.send(userId, message); } } } ``` Because an empty key is legitimate, a **mistyped** interface name cannot fail at boot either — it would just stay empty forever. To keep that diagnosable, the container logs one line per injection site at startup, at `debug` level: ``` [IocEngine] Strategy key 'NotificationService' has no implementations - NotificationManager.notificationServices will be injected as [] ``` Enable `debug` on your logger when a strategy collection is unexpectedly empty. Keys supplied through a test's `overrides` are not reported. :::warning Upgrading from 0.9.0 Up to and including `0.9.0`, a strategy key with no implementations **aborted the boot** with ` is not registered`, and a key with exactly one implementation injected a **bare instance instead of an array** — so `.length`, `for...of` and `.map()` all failed on it at runtime. If you worked around either of those, the workaround can go. ::: ## Lifecycle Hooks with @PostConstruct The `@PostConstruct` decorator marks a method to be called **after** all dependencies have been injected and the component is fully constructed. ### Basic Usage ```typescript import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { PostConstruct } from '@asenajs/asena/decorators/ioc'; @Service() export class UserService { @Inject(DatabaseService) private db: DatabaseService; private cache: Map; @PostConstruct() async initialize() { // Called after all @Inject dependencies are resolved console.log('UserService initializing...'); // Initialize cache this.cache = new Map(); // Preload data const users = await this.db.getAllUsers(); users.forEach(user => this.cache.set(user.id, user)); console.log(`UserService initialized with ${users.length} cached users`); } getUser(id: string) { return this.cache.get(id); } } ``` ### Use Cases for @PostConstruct **1. Initialization Logic** ```typescript @Service() export class CacheService { private redis: RedisClient; @PostConstruct() async connect() { this.redis = await createRedisClient(); console.log('Redis connection established'); } } ``` **2. Validation** ```typescript @Service() export class ApiKeyService { private apiKey = process.env.API_KEY; @PostConstruct() validate() { if (!this.apiKey || this.apiKey.length < 32) { throw new Error('Invalid API key configuration'); } } } ``` ::: warning `@Inject` resolves components, not values A string token names a **registered component**. There is no value/constant registry, so `@Inject('ENV_API_KEY')` throws `ENV_API_KEY is not registered` at startup. Read plain configuration values from `process.env` (or a `@Service` that wraps it) as above. ::: **3. Setup with Injected Dependencies** ```typescript @Service() export class EventSubscriberService { @Inject(EventBus) private eventBus: EventBus; @PostConstruct() subscribeToEvents() { // Subscribe to events after EventBus is injected this.eventBus.on('user.created', this.handleUserCreated.bind(this)); this.eventBus.on('user.updated', this.handleUserUpdated.bind(this)); } private handleUserCreated(user: any) { console.log('User created:', user); } private handleUserUpdated(user: any) { console.log('User updated:', user); } } ``` **4. Data Preloading** ```typescript @Service() export class CountryService { @Inject(DatabaseService) private db: DatabaseService; private countries: Map; @PostConstruct() async preloadCountries() { const data = await this.db.query('SELECT * FROM countries'); this.countries = new Map(data.map(c => [c.code, c])); console.log(`Preloaded ${this.countries.size} countries`); } getCountry(code: string): Country | undefined { return this.countries.get(code); } } ``` ### Execution Order 1. Class constructor runs 2. All `@Inject` dependencies are resolved 3. All `@Strategy` arrays are resolved (a separate pass) 4. All `@PostConstruct` methods are called 5. Registered `@PostProcessor`s run ::: danger A throwing `@PostConstruct` exits the process The container catches the error, logs it, and calls `process.exit(1)` - it is **not** propagated to the caller. Use `@PostConstruct` for setup that must succeed at boot (opening a connection pool, building a transport) and validate recoverable input elsewhere. ::: ```typescript @Service() export class ExampleService { @Inject(LoggerService) private logger: LoggerService; constructor() { console.log('1. Constructor called'); // this.logger is undefined here! } @PostConstruct() initialize() { console.log('2. PostConstruct called'); // this.logger is available here! this.logger.info('Service initialized'); } } ``` ::: warning Async PostConstruct `@PostConstruct` methods can be async. Asena waits for them to complete before the application starts. ::: ## Service Scopes Control the lifecycle of injected services with scopes. ### Singleton Scope (Default) One instance shared across the entire application: ```typescript import { Service } from '@asenajs/asena/decorators'; @Service() // Default: Scope.SINGLETON export class ConfigService { private config = { apiUrl: 'https://api.example.com' }; getConfig() { return this.config; } } ``` ### Prototype Scope New instance created for every injection: ```typescript import { Service } from '@asenajs/asena/decorators'; import { Scope } from '@asenajs/asena/decorators/ioc'; @Service({ scope: Scope.PROTOTYPE }) export class RequestLogger { private requestId = crypto.randomUUID(); private logs: string[] = []; log(message: string) { this.logs.push(`[${this.requestId}] ${message}`); } getLogs() { return this.logs; } } ``` ::: tip When to Use PROTOTYPE Use `Scope.PROTOTYPE` for: - Per-request state - Isolated instances - Testing scenarios ::: ## Injecting into Different Components ### Controllers ```typescript @Controller('/products') export class ProductController { @Inject(ProductService) private productService: ProductService; @Get('/') async list(context: Context) { const products = await this.productService.getAll(); return context.send({ products }); } } ``` ### Services ```typescript @Service() export class OrderService { @Inject(ProductService) private productService: ProductService; @Inject(PaymentService) private paymentService: PaymentService; async createOrder(items: any[]) { // Use injected services const products = await this.productService.validateItems(items); await this.paymentService.charge(products.total); } } ``` ### Middleware ```typescript @Middleware() export class AuthMiddleware extends MiddlewareService { @Inject(JwtService) private jwtService: JwtService; @Inject(UserService) private userService: UserService; async handle(context: Context, next: () => Promise) { const token = context.req.headers['authorization']; const payload = this.jwtService.verify(token); const user = await this.userService.findById(payload.id); context.setValue('user', user); await next(); } } ``` ### Validators ```typescript @Middleware({ validator: true }) export class UniqueEmailValidator extends ValidationService { @Inject(UserRepository) private userRepo: UserRepository; async json() { return z.object({ email: z.string().email().refine(async (email) => { const exists = await this.userRepo.findByEmail(email); return !exists; }, 'Email already in use') }); } } ``` ### WebSocket Services ```typescript @WebSocket({ path: '/chat', name: 'ChatSocket' }) export class ChatSocket extends AsenaWebSocketService { @Inject(MessageService) private messageService: MessageService; protected async onMessage(ws: Socket, message: string) { await this.messageService.saveMessage(message); // Broadcast to all clients } } ``` ## Complete Example Combining all concepts: ```typescript // Interface export interface StorageProvider { save(key: string, data: any): Promise; get(key: string): Promise; } // Implementations @Service() @Implements('StorageProvider') export class RedisStorage implements StorageProvider { @Inject(RedisService, (service) => service.client) private redis: RedisClient; @PostConstruct() async connect() { await this.redis.connect(); console.log('Redis storage ready'); } async save(key: string, data: any) { await this.redis.set(key, JSON.stringify(data)); } async get(key: string) { const data = await this.redis.get(key); return JSON.parse(data); } } @Service() @Implements('StorageProvider') export class LocalStorage implements StorageProvider { private storage = new Map(); async save(key: string, data: any) { this.storage.set(key, data); } async get(key: string) { return this.storage.get(key); } } // Service using Strategy @Service() export class DataService { @Strategy('StorageProvider') private storageProviders: StorageProvider[]; @PostConstruct() initialize() { console.log(`Loaded ${this.storageProviders.length} storage providers`); } async saveToAll(key: string, data: any) { // Save to all storage providers await Promise.all( this.storageProviders.map(provider => provider.save(key, data)) ); } async getFromFirst(key: string) { // Try each provider until one succeeds for (const provider of this.storageProviders) { try { return await provider.get(key); } catch (error) { continue; } } return null; } } ``` ## Best Practices ### 1. Prefer Class-Based Injection ```typescript // ✅ Good: Type-safe @Inject(UserService) private userService: UserService; // ⚠️ Only when necessary @Inject('UserService') private userService: any; ``` ### 2. Use @PostConstruct for Setup ```typescript // ✅ Good: Initialize after injection @PostConstruct() async initialize() { await this.setupConnection(); } // ❌ Bad: Dependencies not available in constructor constructor() { await this.setupConnection(); // this.dependency is undefined! } ``` ### 3. Avoid Circular Dependencies ```typescript // ❌ Bad: Circular dependency @Service() class ServiceA { @Inject(ServiceB) private serviceB: ServiceB; } @Service() class ServiceB { @Inject(ServiceA) private serviceA: ServiceA; // Circular! } // ✅ Good: Extract shared logic @Service() class SharedService { } @Service() class ServiceA { @Inject(SharedService) private shared: SharedService; } @Service() class ServiceB { @Inject(SharedService) private shared: SharedService; } ``` ### 4. Use Expressions Wisely ```typescript // ✅ Good: Extract specific property @Inject(DatabaseService, (s) => s.connection) private db: Database; // ❌ Bad: Too complex @Inject(ConfigService, (s) => s.getConfig().db.primary.connection.pool) private pool: any; // Hard to maintain ``` ### 5. Document @Strategy Interfaces ```typescript // ✅ Good: Clear interface documentation /** * Payment provider interface. * All implementations will be available via @Strategy('PaymentProvider') */ export interface PaymentProvider { name: string; process(amount: number): Promise; } ``` ## Related Documentation - [Services](/docs/concepts/services) - Creating injectable services - [Controllers](/docs/concepts/controllers) - Using DI in controllers - [Middleware](/docs/concepts/middleware) - DI in middleware - [Validation](/docs/concepts/validation) - DI in validators --- **Next Steps:** - Learn about [Service Scopes](/docs/concepts/services#service-scopes) - Explore [Controllers](/docs/concepts/controllers) - Understand [Middleware](/docs/concepts/middleware) --- # Middleware Section: Concepts Source: https://asena.sh/raw/concepts/middleware.md # Middleware Asena provides a flexible multi-level middleware system that allows you to handle cross-cutting concerns like authentication, logging, rate limiting, and more. Middleware can be applied globally, at the controller level, or on individual routes. ## What is Middleware? Middleware is code that executes **before** your route handler. It can: - Validate authentication/authorization - Log requests - Transform request/response data - Handle CORS - Rate limiting - Error handling ::: info Responding from middleware Both adapters accept the same three ways to answer a request from middleware: - **`return context.send(...)`** - returning a `Response` short-circuits the chain - **`return false`** - short-circuits with `403 Forbidden` - **`throw`** - an `HttpException` (Ergenecore) or `HTTPException` (Hono) is routed to your `onError` handler The one thing that is *not* portable is **omitting `next()`**. See [Stopping Middleware Chain](#stopping-middleware-chain). ::: ## Creating Middleware Create middleware by extending `MiddlewareService` and implementing the `handle` method: ```typescript import { Middleware } from '@asenajs/asena/decorators'; import { MiddlewareService, type Context } from '@asenajs/ergenecore'; @Middleware() export class LoggerMiddleware extends MiddlewareService { async handle(context: Context, next: () => Promise): Promise { const start = Date.now(); const method = context.req.method; const url = context.req.url; console.log(`[${method}] ${url} - Start`); await next(); // Call next middleware or route handler const duration = Date.now() - start; console.log(`[${method}] ${url} - Completed in ${duration}ms`); } } ``` ::: tip Always call `await next()` to pass control to the next middleware or route handler! ::: ## Middleware Levels ### 1. Global Middleware Applied to **all routes** in your application: ```typescript import { Config } from '@asenajs/asena/decorators'; import { ConfigService } from '@asenajs/ergenecore'; @Config() export class AppConfig extends ConfigService { public globalMiddlewares() { return [ LoggerMiddleware, CorsMiddleware ]; } } ``` ::: warning Every entry must be a component The names above stand for **your** middlewares — each one a class carrying `@Middleware()`. The built-ins an adapter ships (`CorsMiddleware`, `RateLimiterMiddleware`) are undecorated base classes meant to be extended, so listing one directly fails at startup with `… is not a component. Decorate it with @Middleware()`. Subclass it first: ```typescript @Middleware() export class GlobalCors extends CorsMiddleware {} ``` Component identity is not inherited, which is exactly why the subclass needs its own decorator — see [Inheritance](/docs/concepts/inheritance). ::: ::: warning It is a method, not a property Global middleware must be returned from a `globalMiddlewares()` **method**. A `middlewares = [...]` property compiles but is never read by the framework, so the middleware silently never runs. ::: ### 2. Pattern-Based Middleware Apply middleware to specific route patterns: ```typescript @Config() export class AppConfig extends ConfigService { public globalMiddlewares() { return [ // Apply to all routes LoggerMiddleware, // Apply only to /api/* and /admin/* routes { middleware: AuthMiddleware, routes: { include: ['/api/*', '/admin/*'] } }, // Apply to all routes except /health and /metrics { middleware: RateLimiterMiddleware, routes: { exclude: ['/health', '/metrics'] } } ]; } } ``` ### 3. Controller-Level Middleware Applied to **all routes** in a controller: ```typescript @Controller({ path: '/admin', middlewares: [AuthMiddleware, AdminRoleMiddleware] }) export class AdminController { @Get('/users') // AuthMiddleware + AdminRoleMiddleware applied async getUsers(context: Context) { return context.send({ users: [] }); } @Get('/settings') // AuthMiddleware + AdminRoleMiddleware applied async getSettings(context: Context) { return context.send({ settings: {} }); } } ``` ### 4. Route-Level Middleware Applied to **specific routes**: ```typescript @Controller('/users') export class UserController { @Get({ path: '/' }) // No middleware async list(context: Context) { return context.send({ users: [] }); } @Post({ path: '/', middlewares: [AuthMiddleware, CreateUserValidator] }) async create(context: Context) { const data = await context.getBody(); return context.send({ created: true }); } @Delete({ path: '/:id', middlewares: [AuthMiddleware, AdminRoleMiddleware] }) async delete(context: Context) { return context.send({ deleted: true }); } } ``` ## Common Middleware Patterns ### Authentication Middleware ::: code-group ```typescript [Ergenecore] import { Middleware } from '@asenajs/asena/decorators'; import { MiddlewareService, type Context } from '@asenajs/ergenecore'; @Middleware() export class AuthMiddleware extends MiddlewareService { async handle(context: Context, next: () => Promise): Promise { const token = context.headers['authorization']?.replace('Bearer ', ''); if (!token) { return context.send({ error: 'No token provided' }, 401); } try { // Verify JWT token const payload = await this.verifyToken(token); context.setValue('user', payload); await next(); } catch (error) { return context.send({ error: 'Invalid token' }, 401); } } private async verifyToken(token: string) { // JWT verification logic return { id: 123, role: 'user' }; } } ``` ```typescript [Hono] import { Middleware } from '@asenajs/asena/decorators'; import { MiddlewareService, type Context } from '@asenajs/hono-adapter'; import { HTTPException } from 'hono/http-exception'; @Middleware() export class AuthMiddleware extends MiddlewareService { async handle(context: Context, next: () => Promise): Promise { const token = context.headers['authorization']?.replace('Bearer ', ''); if (!token) { throw new HTTPException(401, { message: 'No token provided' }); } try { // Verify JWT token const payload = await this.verifyToken(token); context.setValue('user', payload); await next(); } catch (error) { throw new HTTPException(401, { message: 'Invalid token' }); } } private async verifyToken(token: string) { // JWT verification logic return { id: 123, role: 'user' }; } } ``` ::: ### Role-Based Authorization ::: code-group ```typescript [Ergenecore] import { Middleware } from '@asenajs/asena/decorators'; import { MiddlewareService, type Context } from '@asenajs/ergenecore'; @Middleware() export class AdminRoleMiddleware extends MiddlewareService { async handle(context: Context, next: () => Promise): Promise { const user = context.getValue('user'); if (!user || user.role !== 'admin') { return context.send({ error: 'Forbidden' }, 403); } await next(); } } ``` ```typescript [Hono] import { Middleware } from '@asenajs/asena/decorators'; import { MiddlewareService, type Context } from '@asenajs/hono-adapter'; import { HTTPException } from 'hono/http-exception'; import type { Next } from 'hono'; @Middleware() export class AdminRoleMiddleware extends MiddlewareService { async handle(context: Context, next: Next): Promise { const user = context.getValue('user'); if (!user || user.role !== 'admin') { throw new HTTPException(403, { message: 'Forbidden' }); } await next(); } } ``` ::: ### Request Logging ```typescript @Middleware() export class RequestLoggerMiddleware extends MiddlewareService { async handle(context: Context, next: Next): Promise { const request = context.req; const start = Date.now(); console.log({ method: request.method, url: request.url, ip: request.headers['x-forwarded-for'] || 'unknown', timestamp: new Date().toISOString() }); await next(); const duration = Date.now() - start; console.log(`Request completed in ${duration}ms`); } } ``` ## Built-in Middleware ### CORS Middleware ```typescript import { Middleware } from '@asenajs/asena/decorators'; import { CorsMiddleware } from '@asenajs/ergenecore'; @Middleware() export class GlobalCors extends CorsMiddleware { constructor() { super({ origin: ['https://example.com', 'https://app.example.com'], credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE'], allowedHeaders: ['Content-Type', 'Authorization'], maxAge: 86400 }); } } ``` **Dynamic CORS:** ```typescript @Middleware() export class DynamicCors extends CorsMiddleware { constructor() { super({ origin: (origin: string) => { return origin.endsWith('.example.com'); }, credentials: true }); } } ``` ### Rate Limiter Middleware ```typescript import { RateLimiterMiddleware } from '@asenajs/ergenecore'; @Middleware() export class ApiRateLimiter extends RateLimiterMiddleware { constructor() { super({ capacity: 100, // 100 requests refillRate: 100 / 60, // per minute message: 'Rate limit exceeded' }); } } ``` **Advanced Rate Limiter:** ```typescript @Middleware() export class AdvancedRateLimiter extends RateLimiterMiddleware { constructor() { super({ capacity: 50, refillRate: 50 / 60, // Rate limit by user ID keyGenerator: (ctx) => ctx.getValue('user')?.id || 'anonymous', // Skip for admins skip: (ctx) => ctx.getValue('user')?.role === 'admin', // Expensive operations cost more cost: (ctx) => { if (ctx.req.url.includes('/search')) return 5; if (ctx.req.url.includes('/export')) return 10; return 1; } }); } } ``` ## Middleware with Dependency Injection Middleware can use dependency injection just like services: ::: code-group ```typescript [Ergenecore] import { Middleware } from '@asenajs/asena/decorators'; import { MiddlewareService, type Context } from '@asenajs/ergenecore'; import { Inject } from '@asenajs/asena/decorators/ioc'; @Middleware() export class AuthMiddleware extends MiddlewareService { @Inject(JwtService) private jwtService: JwtService; @Inject(UserService) private userService: UserService; async handle(context: Context, next: () => Promise): Promise { const token = context.req.headers['authorization']?.replace('Bearer ', ''); if (!token) { return context.send({ error: 'Unauthorized' }, 401); } try { const payload = await this.jwtService.verify(token); const user = await this.userService.findById(payload.id); context.setValue('user', user); await next(); } catch (error) { return context.send({ error: 'Invalid token' }, 401); } } } ``` ```typescript [Hono] import { Middleware } from '@asenajs/asena/decorators'; import { MiddlewareService, type Context } from '@asenajs/hono-adapter'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { HTTPException } from 'hono/http-exception'; @Middleware() export class AuthMiddleware extends MiddlewareService { @Inject(JwtService) private jwtService: JwtService; @Inject(UserService) private userService: UserService; async handle(context: Context, next: () => Promise): Promise { const token = context.headers['authorization']?.replace('Bearer ', ''); if (!token) { throw new HTTPException(401, { message: 'Unauthorized' }); } try { const payload = await this.jwtService.verify(token); const user = await this.userService.findById(payload.id); context.setValue('user', user); await next(); } catch (error) { throw new HTTPException(401, { message: 'Invalid token' }); } } } ``` ::: ## Middleware Execution Order Middleware executes in the order it's defined: ```text globalMiddlewares() ← array order, pattern-based entries included ↓ Controller Middleware ↓ Route Middleware ↓ Route Handler ↓ Route Middleware ↓ Controller Middleware ↓ globalMiddlewares() ``` ::: info Pattern-based entries are not a separate stage An entry with a `routes` filter is just an item in the `globalMiddlewares()` array. It runs in **array position**, interleaved with unfiltered entries - putting a pattern-based entry first makes it run first. Controller-level middleware always follows the whole global list, because the config is applied before controllers are registered. ::: **Example:** ```typescript // 1. Global @Config() export class AppConfig extends ConfigService { public globalMiddlewares() { return [ LoggerMiddleware, // Executes 1st { middleware: AuthMiddleware, routes: { include: ['/api/*'] } } // Executes 2nd ]; } } // 2. Controller-level @Controller({ path: '/api/users', middlewares: [CacheMiddleware] }) // Executes 3rd export class UserController { // 3. Route-level @Get({ path: '/:id', middlewares: [ValidationMiddleware] }) // Executes 4th async getUser(context: Context) { // Executes 5th (finally!) return context.send({ user: {} }); } } ``` ## Stopping Middleware Chain Return a `Response` (or `false`, or throw) to stop the chain: ::: danger Omitting `next()` is not portable Under **Hono**, a middleware that returns without calling `next()` ends the chain. Under **Ergenecore** it does not: when a middleware returns `void` or `true` without calling `next()`, the adapter continues to the next middleware on your behalf. Always signal explicitly - `return context.send(...)`, `return false`, or `throw` - so the same middleware behaves identically on both adapters. ::: ### Shorthand: `return false` Returning `false` short-circuits the chain with a plain `403 Forbidden` on both adapters - useful when no response body is needed: ```typescript @Middleware() export class IpAllowlistMiddleware extends MiddlewareService { async handle(context: Context, next: () => Promise): Promise { if (!this.isAllowed(context.getRequestIp())) { return false; // -> 403 Forbidden, handler never runs } await next(); } private isAllowed(ip: string | null) { return ip !== null; } } ``` ::: code-group ```typescript [Ergenecore] import { Middleware } from '@asenajs/asena/decorators'; import { MiddlewareService, type Context } from '@asenajs/ergenecore'; @Middleware() export class MaintenanceMiddleware extends MiddlewareService { async handle(context: Context, next: () => Promise): Promise { const isMaintenanceMode = process.env.MAINTENANCE === 'true'; if (isMaintenanceMode) { // Returning a Response stops the chain return context.send({ error: 'Service under maintenance' }, 503); } await next(); // Continue if not in maintenance mode } } ``` ```typescript [Hono] import { Middleware } from '@asenajs/asena/decorators'; import { MiddlewareService, type Context } from '@asenajs/hono-adapter'; import { HTTPException } from 'hono/http-exception'; @Middleware() export class MaintenanceMiddleware extends MiddlewareService { async handle(context: Context, next: () => Promise): Promise { const isMaintenanceMode = process.env.MAINTENANCE === 'true'; if (isMaintenanceMode) { // Returning a Response also works here; throwing routes through onError instead throw new HTTPException(503, { message: 'Service under maintenance' }); } await next(); // Continue if not in maintenance mode } } ``` ::: ## Best Practices ### 1. Use Context for Sharing Data ```typescript // ✅ Good: Store data in context @Middleware() export class AuthMiddleware extends MiddlewareService { async handle(context: Context, next: () => Promise) { context.setValue('user', user); await next(); } } // ❌ Bad: Global state let currentUser; // Don't! ``` ### 2. Always Await next() ```typescript // ✅ Good await next(); // ❌ Bad next(); // Missing await! ``` ### 3. Order Matters ```typescript // ✅ Good: Logger first, then auth public globalMiddlewares() { return [LoggerMiddleware, AuthMiddleware]; } // ❌ Bad: Auth before logger (auth logs won't be captured) public globalMiddlewares() { return [AuthMiddleware, LoggerMiddleware]; } ``` ## Related Documentation - [Controllers](/docs/concepts/controllers) - [Ergenecore Adapter](/docs/adapters/ergenecore) - [Validation](/docs/concepts/validation) - [Configuration](/docs/guides/configuration) --- **Next Steps:** - Learn about [Validation](/docs/concepts/validation) - Explore [Context API](/docs/concepts/context) - Understand [Configuration](/docs/guides/configuration) --- # Context API Section: Concepts Source: https://asena.sh/raw/concepts/context.md # Context API The Context API is the heart of request/response handling in Asena. It provides a **unified interface** that works consistently across all adapters (Ergenecore and Hono), allowing you to write adapter-agnostic code. ## What is Context? The Context object wraps the underlying HTTP request and response, providing convenient methods for: - Extracting route parameters, query strings, and request bodies - Sending JSON, HTML, or custom responses - Managing cookies (with signing support) - Storing per-request state - Handling WebSocket upgrades ::: tip Adapter-Agnostic Design The same Context API works identically in both Ergenecore and Hono adapters. Only the import path changes - your application code remains the same. ::: ## Quick Start Here's how to use Context in your controllers with both adapters: ::: code-group ```typescript [Ergenecore] import { Controller } from '@asenajs/asena/decorators'; import { Get, Post } from '@asenajs/asena/decorators/http'; import type { Context } from '@asenajs/ergenecore'; @Controller('/api') export class ApiController { @Get('/user/:id') async getUser(context: Context) { const id = context.getParam('id'); const format = await context.getQuery('format'); return context.send({ userId: id, format: format || 'json' }); } @Post('/user') async createUser(context: Context) { const body = await context.getBody<{ name: string; email: string }>(); return context.send({ created: true, user: body }, 201); } } ``` ```typescript [Hono] import { Controller } from '@asenajs/asena/decorators'; import { Get, Post } from '@asenajs/asena/decorators/http'; import type { Context } from '@asenajs/hono-adapter'; @Controller('/api') export class ApiController { @Get('/user/:id') async getUser(context: Context) { const id = context.getParam('id'); const format = await context.getQuery('format'); return context.send({ userId: id, format: format || 'json' }); } @Post('/user') async createUser(context: Context) { const body = await context.getBody<{ name: string; email: string }>(); return context.send({ created: true, user: body }, 201); } } ``` ::: ::: tip Notice the Difference? The **only difference** between the two examples is the import path for the `Context` type. The API is completely identical. ::: ## Core Properties ### `req` - Request Object Access the underlying request object for adapter-specific features. ```typescript @Get('/info') async getInfo(context: Context) { // Access native request const url = context.req.url; const method = context.req.method; return context.send({ url, method }); } ``` ### `res` - Response Object Access the underlying response object to set headers directly. ```typescript @Get('/download') async download(context: Context) { // Set custom headers context.res.headers.set('Content-Disposition', 'attachment; filename="data.json"'); context.res.headers.set('X-Custom-Header', 'value'); return context.send({ data: 'example' }); } ``` ### `headers` - Request Headers Get all request headers as a key-value object. ```typescript @Get('/headers') async showHeaders(context: Context) { const headers = context.headers; return context.send({ headers }); } ``` ## Request Data Methods ### Route Parameters Extract dynamic segments from the URL path using `getParam()`. ```typescript @Get('/posts/:postId/comments/:commentId') async getComment(context: Context) { const postId = context.getParam('postId'); const commentId = context.getParam('commentId'); return context.send({ postId, commentId }); } ``` ::: tip Type Safety Route parameters are always strings. Convert to numbers when needed: ```typescript const id = Number(context.getParam('id')); ``` ::: ### Query Parameters Extract query string values using `getQuery()` and `getQueryAll()`. ```typescript @Get('/search') async search(context: Context) { // Single value: ?q=asena const query = await context.getQuery('q'); // Multiple values: ?tags=node&tags=bun const tags = await context.getQueryAll('tags'); // Optional with default const page = await context.getQuery('page') || '1'; const limit = await context.getQuery('limit') || '10'; return context.send({ query, tags, page: Number(page), limit: Number(limit) }); } ``` ### Request Body Parse JSON request bodies with automatic type casting. ```typescript interface CreateUserDto { name: string; email: string; age: number; } @Post('/users') async createUser(context: Context) { // Type-safe body parsing const body = await context.getBody(); // body is now typed as CreateUserDto console.log(body.name, body.email, body.age); return context.send({ created: true, user: body }, 201); } ``` ::: warning Empty Body Handling **Ergenecore**: Empty request body returns `{}` (empty object) **Hono**: Empty request body may throw an error - always handle parsing errors ```typescript try { const body = await context.getBody(); } catch (error) { return context.send({ error: 'Invalid JSON' }, 400); } ``` ::: ### Request Headers Access specific headers using `req.headers` (via native request object). ::: tip `Hono` req.headers is a function that returns headers as values ```typescript const age:string = context.req.header().Age; ``` but in the other hand `ergenecore` is just a regular records ```typescript const server:string = context.req.headers.get("X-Server") ``` ::: Here is example ::: code-group ```typescript [ergenecore] @Get('/auth') async checkAuth(context: Context) { const token = context.req.headers.get('authorization'); const userAgent = context.req.headers.get('user-agent'); if (!token) { return context.send({ error: 'Unauthorized' }, 401); } return context.send({ token, userAgent }); } ``` ```typescript [hono] @Get('/auth') async checkAuth(context: Context) { const token = context.req.header().Authorization; const userAgent = context.req.header()["User-Agent"]; if (!token) { return context.send({ error: 'Unauthorized' }, 401); } return context.send({ token, userAgent }); } ``` ::: ### Form Data Parse multipart/form-data and URL-encoded forms. ```typescript @Post('/upload') async handleUpload(context: Context) { // Get form data const formData = await context.getFormData(); const name = formData.get('name'); const file = formData.get('file'); // File object return context.send({ name, fileName: file instanceof File ? file.name : null }); } ``` ### Binary Data Handle binary request bodies (ArrayBuffer, Blob). ```typescript @Post('/binary') async handleBinary(context: Context) { // Get as ArrayBuffer const buffer = await context.getArrayBuffer(); // Or as Blob const blob = await context.getBlob(); return context.send({ size: buffer.byteLength }); } ``` ## Response Methods ### JSON Response - `send()` Send JSON responses with automatic content-type headers. ```typescript @Get('/data') async getData(context: Context) { // Simple JSON response (200 OK) return context.send({ message: 'Success', data: [] }); } @Post('/create') async create(context: Context) { // JSON with custom status code return context.send({ created: true }, 201); } @Get('/error') async error(context: Context) { // Error response return context.send({ error: 'Not found' }, 404); } ``` ### Custom Headers Add custom headers to responses. ```typescript @Get('/with-headers') async withHeaders(context: Context) { return context.send( { data: 'example' }, { status: 200, headers: { 'X-Custom-Header': 'value', 'X-Request-ID': crypto.randomUUID() } } ); } ``` ### HTML Response - `html()` Send HTML content with proper content-type. ```typescript @Get('/page') async showPage(context: Context) { const html = ` Asena Page

Hello from Asena!

`; return context.html(html); } ``` ### Redirect - `redirect()` Redirect to another URL (302 Found by default). ```typescript @Get('/old-path') async oldPath(context: Context) { return context.redirect('/new-path'); } @Get('/login') async login(context: Context) { const isAuthenticated = context.getValue('authenticated'); if (isAuthenticated) { return context.redirect('/dashboard'); } return context.send({ message: 'Please login' }); } ``` ## Cookie Management ### Get Cookie - `getCookie()` Retrieve cookie values, with optional signature verification. ```typescript @Get('/check-session') async checkSession(context: Context) { // Get simple cookie const sessionId = await context.getCookie('session'); if (!sessionId) { return context.send({ error: 'No session' }, 401); } return context.send({ sessionId }); } ``` ### Signed Cookies Use signed cookies for tamper-proof data. ```typescript @Post('/login') async login(context: Context) { const body = await context.getBody<{ username: string }>(); // Set signed cookie await context.setCookie('userId', body.username, { secret: 'your-secret-key', extraOptions: { httpOnly: true, secure: true, maxAge: 3600 // 1 hour } }); return context.send({ message: 'Logged in' }); } @Get('/profile') async profile(context: Context) { // Verify signed cookie const userId = await context.getCookie('userId', 'your-secret-key'); if (!userId) { return context.send({ error: 'Invalid session' }, 401); } return context.send({ userId }); } ``` ### Set Cookie - `setCookie()` Set cookies with various options. ```typescript @Post('/preferences') async setPreferences(context: Context) { await context.setCookie('theme', 'dark', { extraOptions: { path: '/', maxAge: 86400 * 30, // 30 days httpOnly: false, // Accessible from JavaScript sameSite: 'lax' } }); return context.send({ message: 'Preferences saved' }); } ``` ### Delete Cookie - `deleteCookie()` Remove cookies by expiring them. ```typescript @Post('/logout') async logout(context: Context) { await context.deleteCookie('session'); await context.deleteCookie('userId'); return context.send({ message: 'Logged out' }); } ``` ## State Management Context provides in-memory state storage for sharing data between middlewares and handlers. ### Set Value - `setValue()` Store per-request values. ```typescript // In middleware @Middleware() export class AuthMiddleware extends MiddlewareService { async use(context: Context) { const token = context.req.headers.get('authorization'); const user = await verifyToken(token); // Store for later use context.setValue('user', user); } } ``` ### Get Value - `getValue()` Retrieve stored values in handlers. ```typescript @Get('/dashboard') async dashboard(context: Context) { // Retrieve value set by middleware const user = context.getValue('user'); return context.send({ user }); } ``` ### Type-Safe Variables with AsenaVariables For full type safety and IDE autocomplete, augment the `AsenaVariables` interface using TypeScript module augmentation: ```typescript // src/types/ContextState.ts declare module '@asenajs/asena/adapter' { interface AsenaVariables { user: User; requestId: string; } } interface User { id: string; name: string; } ``` Now `getValue()` and `setValue()` are fully type-checked: ```typescript @Get('/profile') async profile(context: Context) { // Type-safe: TypeScript knows this returns User const user = context.getValue('user'); // ^? User // Type-safe: TypeScript enforces correct value type context.setValue('requestId', crypto.randomUUID()); // ^? string (enforced) return context.send({ id: user.id, name: user.name }); } ``` ::: tip IDE Autocomplete With `AsenaVariables` augmented, your IDE will: - Autocomplete key names in `getValue()` and `setValue()` - Show the correct return type for each key - Flag type mismatches at compile time You can still use generic types for dynamic keys: `context.getValue('dynamicKey')` ::: ::: info Setup Create a declaration file (e.g., `src/types/ContextState.ts`) and make sure it's included in your TypeScript compilation. The module path must be exactly `'@asenajs/asena/adapter'`. ::: ## WebSocket Support Context provides WebSocket-specific methods for upgrade handling. ### Set WebSocket Value - `setWebSocketValue()` Store data before WebSocket upgrade. ```typescript import { Middleware } from '@asenajs/asena/decorators'; import type { Context, MiddlewareService } from '@asenajs/ergenecore'; @Middleware() export class WsAuthMiddleware implements MiddlewareService { async handle(context: Context, next: () => Promise): Promise { //.. Rest of code context.setWebSocketValue({ userId: '123', username: 'john_doe' }); await next(); } } ``` ### Get WebSocket Value - `getWebSocketValue()` ::: warning Socket data will automaticly injectining in `ws.data.values` by adapter. So you dont need to use this. ::: ## Streaming Context provides three streaming methods for sending data progressively to the client. All methods work identically across Ergenecore and Hono adapters. ### Server-Sent Events - `streamSSE()` Send real-time events to the client using the SSE protocol. Automatically sets `Content-Type: text/event-stream`, `Cache-Control: no-cache`, and `Connection: keep-alive`. ```typescript @Get('/events') async events(context: Context) { return context.streamSSE(async (stream) => { for (let i = 0; i < 5; i++) { await stream.writeSSE({ data: JSON.stringify({ count: i, time: Date.now() }), event: 'update', id: String(i), }); } }); } ``` #### SSEMessage Format ```typescript interface SSEMessage { data: string; // Event data (multi-line strings auto-split into separate data: lines) event?: string; // Event type name id?: string; // Event ID for reconnection retry?: number; // Reconnection time in milliseconds } ``` #### Error Handling ```typescript @Get('/events') async events(context: Context) { return context.streamSSE( async (stream) => { // Main stream logic await stream.writeSSE({ data: 'starting', event: 'status' }); await doWork(); await stream.writeSSE({ data: 'done', event: 'status' }); }, async (error, stream) => { // Error handler — send error event to client await stream.writeSSE({ data: JSON.stringify({ error: error.message }), event: 'error', }); }, ); } ``` #### Client Disconnect Detection ```typescript @Get('/live') async live(context: Context) { return context.streamSSE(async (stream) => { stream.onAbort(() => { console.log('Client disconnected'); // Clean up resources }); while (!stream.aborted) { await stream.writeSSE({ data: 'heartbeat', event: 'ping' }); await Bun.sleep(1000); } }); } ``` ### Text Stream - `streamText()` Stream plain text content. Sets `Content-Type: text/plain`. ```typescript @Get('/generate') async generate(context: Context) { return context.streamText(async (stream) => { const chunks = ['Hello', ' ', 'World', '!']; for (const chunk of chunks) { await stream.write(chunk); } }); } ``` ### Generic Stream - `stream()` Stream raw data without a predefined content type. Useful for binary data, CSV exports, or custom formats. ```typescript @Get('/export') async export(context: Context) { return context.stream(async (stream) => { await stream.writeln('name,email,age'); await stream.writeln('John,john@example.com,30'); await stream.writeln('Jane,jane@example.com,25'); }); } ``` #### Pipe a ReadableStream ```typescript @Get('/pipe') async pipe(context: Context) { return context.stream(async (stream) => { const source = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('piped content')); controller.close(); }, }); await stream.pipe(source); }); } ``` ### StreamWriter API All streaming methods provide a writer with these methods: | Method | Parameters | Description | |:-------|:-----------|:------------| | `write(input)` | `Uint8Array \| string` | Write raw data to the stream | | `writeln(input)` | `string` | Write a string followed by a newline | | `close()` | — | Close the stream normally | | `pipe(body)` | `ReadableStream` | Pipe a ReadableStream through the writer | | `onAbort(listener)` | `() => void` | Register a callback for client disconnection | | `aborted` | — | `boolean` — whether the client disconnected | | `closed` | — | `boolean` — whether the stream was closed normally | The SSE stream writer (`streamSSE`) additionally provides: | Method | Parameters | Description | |:-------|:-----------|:------------| | `writeSSE(message)` | `SSEMessage` | Write a formatted SSE message | ::: tip Auto-Close Streams are automatically closed after the callback completes. You don't need to call `stream.close()` manually unless you want to close early. ::: ## Advanced Methods ### Parse Body - `getParseBody()` Automatically detect and parse request body based on Content-Type. ```typescript @Post('/auto-parse') async autoParse(context: Context) { // Handles JSON, form-data, and URL-encoded automatically const body = await context.getParseBody(); return context.send({ parsed: body }); } ``` ### Array Buffer - `getArrayBuffer()` Get raw binary data as ArrayBuffer. ```typescript @Post('/process-image') async processImage(context: Context) { const buffer = await context.getArrayBuffer(); // Process binary data const processed = await processImageBuffer(buffer); return context.send({ size: buffer.byteLength, processed }); } ``` ### Blob - `getBlob()` Get request body as a Blob. ```typescript @Post('/upload-blob') async uploadBlob(context: Context) { const blob = await context.getBlob(); return context.send({ type: blob.type, size: blob.size }); } ``` ## Common Patterns ### Authentication Flow ```typescript // Middleware sets user data @Middleware() export class AuthMiddleware extends MiddlewareService { async use(context: Context) { const token = context.req.header().Authorization; if (!token) { throw new Error('Unauthorized'); } const user = await this.verifyToken(token); context.setValue('user', user); } } // Controller uses user data @Controller({ path: '/api', middlewares: [AuthMiddleware] }) export class ApiController { @Get('/profile') async getProfile(context: Context) { const user = context.getValue('user'); return context.send({ user }); } } ``` ### File Upload ```typescript @Post('/upload') async uploadFile(context: Context) { const formData = await context.getFormData(); const file = formData.get('file'); const description = formData.get('description'); if (!file || !(file instanceof File)) { return context.send({ error: 'No file provided' }, 400); } // Process file const buffer = await file.arrayBuffer(); const saved = await this.fileService.save(file.name, buffer); return context.send({ uploaded: true, fileName: file.name, size: file.size, description }, 201); } ``` ## Utility Methods ### Get Client IP - `getRequestIp()` Get the client's IP address. Lazily evaluated and cached — zero cost if never accessed. ```typescript @Get('/info') async info(context: Context) { const ip = context.getRequestIp(); return context.send({ ip }); } ``` ### Get All Queries - `getAllQueries()` Get all query parameters as a key-value object. Keys with multiple values return arrays. ```typescript // GET /search?q=asena&tag=bun&tag=typescript @Get('/search') async search(context: Context) { const queries = context.getAllQueries(); // { q: 'asena', tag: ['bun', 'typescript'] } return context.send(queries); } ``` ### Set Response Header - `setResponseHeader()` Set a response header that will be merged into the final response. Useful in middleware for adding headers that carry through to streaming responses. ```typescript @Get('/download') async download(context: Context) { context.setResponseHeader('Content-Disposition', 'attachment; filename="data.csv"'); context.setResponseHeader('X-Request-Id', crypto.randomUUID()); return context.send({ data: 'example' }); } ``` ## Adapter-Specific Features ### Ergenecore Adapter **Performance Optimizations:** - Lazy URL parsing (only when query params accessed) - Lazy state Map (only when setValue/getValue called) - Lazy IP resolution (only when getRequestIp() called) - Body caching (allows multiple getBody() calls) **Native Bun Features:** - Uses Bun's native cookie API - Direct access to Bun's Request/Response ```typescript import type { Context } from '@asenajs/ergenecore'; @Get('/native') async useNative(context: Context) { // Access Bun native request const bunRequest: Request = context.req; return context.send({ framework: 'Ergenecore' }); } ``` ### Hono Adapter **Rich Ecosystem:** - Full access to Hono's middleware ecosystem - `@Override` decorator for native Hono middleware - WebSocket via Hono's upgrade mechanism **Native Hono Context:** Access Hono-specific features via the wrapped context. ```typescript import type { Context } from '@asenajs/hono-adapter'; @Get('/native') async useNative(context: Context) { // Access Hono native methods const contentType = context.req.header('content-type'); return context.send({ framework: 'Hono' }); } ``` ## API Reference ### Request Methods | Method | Return Type | Description | |:-------|:------------|:------------| | `getParam(name)` | `string` | Get route parameter | | `getQuery(name)` | `Promise` | Get single query parameter | | `getQueryAll(name)` | `Promise` | Get all values of a query parameter | | `getAllQueries()` | `Record` | Get all query parameters as object | | `getBody()` | `Promise` | Parse JSON body with type | | `getParseBody()` | `Promise` | Auto-parse body by content-type | | `getFormData()` | `Promise` | Parse form data | | `getArrayBuffer()` | `Promise` | Get binary body | | `getBlob()` | `Promise` | Get body as Blob | | `getRequestIp()` | `string \| null` | Get client IP address (lazy, cached) | ### Response Methods | Method | Return Type | Description | |:-------|:------------|:------------| | `send(data, statusOrOptions?)` | `Response` | Send JSON/text response | | `html(data, statusOrOptions?)` | `Response` | Send HTML response | | `redirect(url)` | `Response` | Redirect to URL | | `setResponseHeader(key, value)` | `void` | Set a response header for middleware merging | ### Streaming Methods | Method | Return Type | Description | |:-------|:------------|:------------| | `stream(cb, onError?)` | `Response` | Generic binary/text stream | | `streamSSE(cb, onError?)` | `Response` | Server-Sent Events stream (`text/event-stream`) | | `streamText(cb, onError?)` | `Response` | Text stream (`text/plain`) | ### Cookie Methods | Method | Return Type | Description | |:-------|:------------|:------------| | `getCookie(name, secret?)` | `Promise` | Get cookie value | | `setCookie(name, value, options?)` | `Promise` | Set cookie | | `deleteCookie(name, options?)` | `Promise` | Delete cookie | ### State Methods | Method | Return Type | Description | |:-------|:------------|:------------| | `getValue(key)` | `AsenaVariables[K]` | Get context value (type-safe with [AsenaVariables](#type-safe-variables-with-asenavariables)) | | `setValue(key, value)` | `void` | Set context value (type-safe with [AsenaVariables](#type-safe-variables-with-asenavariables)) | | `getWebSocketValue()` | `T` | Get WebSocket data | | `setWebSocketValue(value)` | `void` | Set WebSocket data | ## Best Practices ::: tip Always Type Your Bodies Use TypeScript generics for type-safe request bodies: ```typescript interface CreateUserDto { name: string; email: string; } const body = await context.getBody(); // body.name and body.email are now type-safe ``` ::: ::: tip Use State for Middleware Communication Share data between middlewares and handlers using setValue/getValue: ```typescript // Middleware context.setValue('userId', extractedUserId); // Handler const userId = context.getValue('userId'); ``` ::: ::: tip Consistent Error Responses Use a consistent error format across your API: ```typescript return context.send({ error: 'Human-readable message', code: 'MACHINE_READABLE_CODE', details: {} // Optional }, statusCode); ``` ::: ::: warning Async Methods `getBody()`, `getQuery()`, `getQueryAll()`, `getFormData()`, `getParseBody()`, `getArrayBuffer()`, `getBlob()` and the cookie helpers return a `Promise`. Forgetting `await` does not raise a type error in every position - it silently yields the Promise object instead of the value: ```typescript // ❌ Wrong - `page` is a Promise, so `|| '1'` never applies and Number() gives NaN const page = context.getQuery('page') || '1'; // ✅ Correct const page = (await context.getQuery('page')) || '1'; ``` `getParam()`, `getAllQueries()`, `getValue()`, `setValue()` and `send()` are synchronous. ::: ## Related Documentation - [Controllers](/docs/concepts/controllers) - Using Context in controllers - [Middleware](/docs/concepts/middleware) - Context in middlewares - [Ergenecore Adapter](/docs/adapters/ergenecore) - Ergenecore-specific features - [Hono Adapter](/docs/adapters/hono) - Hono-specific features - [WebSocket](/docs/concepts/websocket) - WebSocket integration with Context - [Error Handling](/docs/guides/error-handling) - HttpException and error responses --- # Validation Section: Concepts Source: https://asena.sh/raw/concepts/validation.md # Validation Asena provides built-in validation support using [Zod](https://zod.dev). Validation ensures that incoming request data meets your requirements before reaching your route handlers. ::: info Adapter Support Both the **Ergenecore** and the **Hono** adapter ship validation. Each exports its own `ValidationService` and `ValidationSchemaWithHook`, so import them from the adapter you use. One behavioural difference remains: Ergenecore runs the `hook` **only when validation fails**, while the Hono adapter runs it on **every** validation attempt. Write hooks that check `result.success` and they behave the same on both. ::: ## Why Use Validation? - **Type Safety**: Catch type mismatches at runtime - **Security**: Prevent malicious or malformed input - **Better Error Messages**: Provide clear validation feedback - **Self-Documentation**: Schema serves as API documentation - **Automatic Error Handling**: 400 Bad Request responses automatically ## Quick Start ### 1. Create a Validator Extend `ValidationService` and implement the `json()` method: ```typescript import { Middleware } from '@asenajs/asena/decorators'; import { ValidationService } from '@asenajs/ergenecore'; import { z } from 'zod'; @Middleware({ validator: true }) export class CreateUserValidator extends ValidationService { json() { return z.object({ name: z.string().min(3).max(50), email: z.string().email(), age: z.number().min(18).max(120) }); } } ``` ### 2. Apply to a Route Use the `validator` option in route decorators: ```typescript import { Controller } from '@asenajs/asena/decorators'; import { Post } from '@asenajs/asena/decorators/http'; import type { Context } from '@asenajs/ergenecore'; @Controller('/users') export class UserController { @Post({ path: '/', validator: CreateUserValidator }) async create(context: Context) { const body = await context.getBody(); // body is guaranteed to be valid! return context.send({ created: true, user: body }, 201); } } ``` ::: tip Validation Happens Automatically The validator runs **before** your route handler. If validation fails, the handler is never called. ::: ## ValidationService API ### The `json()` Method The `json()` method defines the validation schema for request bodies: ```typescript @Middleware({ validator: true }) export class MyValidator extends ValidationService { json() { // Return a Zod schema return z.object({ // Your validation rules }); } } ``` **Method Signature:** ```typescript json(): ValidationSchema | Promise ``` - **Return Type**: A Zod schema (`z.ZodType`) - **Async Support**: Can be `async` for dynamic schemas ### Validating other request parts `json()` is the most common, but a validator can define any of these - each takes the same shape (a Zod schema, or `{ schema, hook }`) and validates a different part of the request: | Method | Validates | Runtime | |:-------|:----------|:--------| | `json()` | JSON request body | ✅ | | `form()` | `multipart/form-data` / URL-encoded body | ✅ | | `query()` | Query string | ✅ | | `param()` | Route parameters | ✅ | | `header()` | Request headers | ✅ | | `response()` | Response shape, **by status code** | ❌ documentation only | ```typescript @Middleware({ validator: true }) export class ListUsersValidator extends ValidationService { public query() { return z.object({ page: z.string().transform(Number).pipe(z.number().min(1)), }); } public param() { return z.object({ id: z.string().uuid() }); } } ``` ::: warning `response()` is never executed It exists so [`@asenajs/asena-openapi`](/docs/packages/openapi) can emit response schemas into the spec; the runtime ignores it. A consequence worth knowing: `@Middleware({ validator: true })` requires at least one of the five *runtime* methods. A validator class that defines **only** `response()` fails the decorator's check at import time and the process exits. ::: ### Basic Example ```typescript @Middleware({ validator: true }) export class ProductValidator extends ValidationService { json() { return z.object({ name: z.string(), price: z.number().positive(), category: z.enum(['electronics', 'clothing', 'food']), inStock: z.boolean() }); } } ``` ### Async Validation For database lookups or async checks: ```typescript @Middleware({ validator: true }) export class UniqueEmailValidator extends ValidationService { @Inject(UserRepository) private userRepo: UserRepository; async json() { return z.object({ email: z.string().email().refine(async (email) => { const exists = await this.userRepo.findByEmail(email); return !exists; }, 'Email already exists') }); } } ``` ## Validation Hooks Use `ValidationSchemaWithHook` to execute custom logic after validation: ::: code-group ```typescript [ergenecore] import { type ValidationSchemaWithHook, ValidationService } from '@asenajs/ergenecore'; @Middleware({ validator: true }) export class UserValidatorWithHook extends ValidationService { json(): ValidationSchemaWithHook { return { schema: z.object({ email: z.string().email(), password: z.string().min(8) }), hook: (result, context) => { // result: Zod SafeParseResult - { success, data } or { success, error } // context: Asena's Context wrapper if (!result.success) { // Return nothing to let the framework report the failure through onError return; } console.log('Validated user data:', result.data); // Store in context for later use context.setValue('validatedEmail', result.data.email); } }; } } ``` ```typescript [hono] import { type ValidationSchemaWithHook, ValidationService } from '@asenajs/hono-adapter'; @Middleware({ validator: true }) export class UserValidatorWithHook extends ValidationService { json(): ValidationSchemaWithHook { return { schema: z.object({ email: z.string().email(), password: z.string().min(8) }), hook: (result, context) => { // result: Zod SafeParseResult - { success, data } or { success, error } // context: Hono's NATIVE context, not Asena's wrapper - see the warning below if (!result.success) { // Return nothing to let the framework report the failure through onError return; } console.log('Validated user data:', result.data); // Hono's own state API - set()/get(), not setValue()/getValue() context.set('validatedEmail', result.data.email); } }; } } ``` ::: ::: warning The hook's `context` differs per adapter This is the one place where the two adapters are not interchangeable. - **Ergenecore** passes Asena's `Context` wrapper, so `setValue()` / `getValue()` / `send()` are available. - **Hono** types the hook as `@hono/zod-validator`'s `Hook`, which hands you **Hono's native context**. Use `set()` / `get()` to store state and `json()` to respond. The values you store are still readable from the handler's Asena context - `setValue` and Hono's `set` write to the same per-request store. ::: ### Hook Function Signature ```typescript hook: ( result: z.ZodSafeParseResult, context: Context ) => Response | void | Promise ``` | Parameter | Type | Description | |:----------|:-----|:------------| | `result` | `z.ZodSafeParseResult` | Zod's safe-parse result: `{ success: true, data }` or `{ success: false, error }` | | `context` | `Context` | The adapter's Context object | | **Returns** | `Response \| void` | Return a `Response` to answer the request yourself; return nothing to continue | ::: warning The hook receives the parse result, not the parsed data `result` is **not** the validated object - the data lives at `result.data`, and only when `result.success` is `true`. Always branch on `result.success` first. The return value is **not** a way to transform the payload. Returning a plain object does nothing; only a `Response` is honoured, and it short-circuits the request. ::: ### Hook Use Cases **1. Logging and Monitoring** ```typescript hook: (result, context) => { console.log('Validation attempted:', { endpoint: context.req.url, ok: result.success }); } ``` Returning nothing leaves the error contract untouched - a failure is still reported the normal way. **2. Storing Validated Data in Context** ```typescript hook: (result, context) => { if (!result.success) return; // Make validated data available to middlewares/handlers context.setValue('validatedUser', result.data); context.setValue('userEmail', result.data.email); } ``` **3. Answering the Request Yourself** ```typescript hook: (result, context) => { if (result.success) return; // A Response short-circuits everything - the handler never runs and // onError is never called for this request return context.send({ error: 'Invalid signup payload' }, 422); } ``` **4. Side Effects** ```typescript hook: async (result, context) => { if (!result.success) return; // Send notification, update cache, etc. await this.notificationService.sendAlert('New signup', result.data.email); } ``` ## Validation Error Responses When validation fails, Asena answers with **400 Bad Request**. ### Default response A `ValidationError` is always thrown. When nothing answers it - your application defines no global error handler, or its handler returns nothing - **both** adapters fall back to the same envelope, Zod's flattened error: ```json { "error": "Validation failed", "details": { "formErrors": [], "fieldErrors": { "email": ["Invalid email"], "age": ["Must be at least 18"] } }, "target": "json" } ``` `target` names the part of the request that failed - `json`, `query`, `form`, `param` or `header`. The failure is also logged like any other 4xx, at DEBUG (or INFO when your logger has no `debug`). Before 0.9.1 an application with no `onError` got this response from inside the validator, which never threw - so it reached neither `onError` nor the log, the one rejection that was invisible at every level. See [Error Handling](/docs/guides/error-handling#adapter-logging). ### Customizing the response Define `onError` in your `@Config` class and validation failures arrive there like any other error, so they can share your application's response envelope: ```typescript import { Config } from '@asenajs/asena/decorators'; import { isValidationError } from '@asenajs/asena/adapter'; import { ConfigService, type Context } from '@asenajs/hono-adapter'; @Config() export class ServerConfig extends ConfigService { public onError(error: Error, context: Context): Response { if (isValidationError(error)) { return context.send( { success: false, message: 'Validation failed', errors: error.issues // [{ path, message, code }] }, 400 ); } return context.send({ success: false, message: 'Internal Server Error' }, 500); } } ``` `isValidationError()` is exported from `@asenajs/asena/adapter` and works with both adapters. The error it narrows to also carries `target` and `cause` (the original `ZodError`) if you need more than `issues`. ::: tip Existing error handlers keep working The thrown error extends the adapter's HTTP exception type (`HTTPException` for Hono, `HttpException` for Ergenecore) and carries status **400**. An existing handler that branches on `instanceof HTTPException` and replies with `error.status` therefore keeps answering 400 - adopting this does not silently turn validation failures into 500s. ::: ::: warning A hook that returns a Response wins If the validator's `hook` returns a `Response`, that response is sent as-is and `onError` is never reached for that request. See [Validation Hooks](#validation-hooks). ::: ## Integration with Controllers ### Route-Level Validation ```typescript @Controller('/products') export class ProductController { @Post({ path: '/', validator: CreateProductValidator }) async create(context: Context) { // Body is already validated const product = await context.getBody(); return context.send(product, 201); } @Put({ path: '/:id', validator: UpdateProductValidator }) async update(context: Context) { const id = context.getParam('id'); const updates = await context.getBody(); return context.send({ updated: true }); } } ``` ### Combining with Middleware ```typescript @Post({ path: '/', middlewares: [AuthMiddleware, RateLimitMiddleware], validator: CreateUserValidator }) async create(context: Context) { // Ergenecore: AuthMiddleware -> RateLimitMiddleware -> CreateUserValidator -> handler // Hono: CreateUserValidator -> AuthMiddleware -> RateLimitMiddleware -> handler } ``` ::: danger Validators and route middleware run in a different order per adapter Ergenecore validates **after** the route's middleware chain; Hono registers validators **before** route middleware, so validation happens first. This matters when a middleware populates state the validator depends on - an `AuthMiddleware` that calls `context.setValue('user', …)` has not run yet on Hono. Global middleware is unaffected: it is top-level on both adapters and always runs first. ::: ## Zod Schema Definition Asena uses [Zod](https://zod.dev) for schema definition. The `json()` method returns a Zod schema: ```typescript json() { return z.object({ name: z.string(), email: z.string().email(), age: z.number().int().positive() }); } ``` ### Reusable Schemas You can extract and reuse common schemas across multiple validators: ```typescript // src/validators/schemas/common.ts import { z } from 'zod'; // Reusable schema definitions export const emailSchema = z.string().email().toLowerCase(); export const passwordSchema = z.string().min(8).regex(/[A-Z]/).regex(/[0-9]/); export const addressSchema = z.object({ street: z.string().min(1), city: z.string().min(1), zipCode: z.string().regex(/^\d{5}$/), country: z.string().length(2) }); export const paginationSchema = z.object({ page: z.number().int().positive().default(1), limit: z.number().int().min(1).max(100).default(10) }); ``` Then use them in your validators: ```typescript import { Middleware } from '@asenajs/asena/decorators'; import { ValidationService } from '@asenajs/ergenecore'; import { emailSchema, passwordSchema, addressSchema } from '../schemas/common'; import { z } from 'zod'; @Middleware({ validator: true }) export class CreateUserValidator extends ValidationService { json() { return z.object({ email: emailSchema, // Reuse common schema password: passwordSchema, // Reuse common schema shippingAddress: addressSchema // Reuse common schema }); } } @Middleware({ validator: true }) export class UpdateUserValidator extends ValidationService { json() { return z.object({ email: emailSchema.optional(), // Reuse and modify shippingAddress: addressSchema.partial() // All fields optional }); } } ``` ::: tip Benefits of Reusable Schemas - **Consistency**: Same validation rules across your app - **Maintainability**: Update once, apply everywhere - **Type Safety**: Share types between validators - **Composition**: Build complex schemas from simple ones ::: ::: info Zod API Documentation For complete schema documentation including strings, numbers, arrays, objects, unions, transformations, and refinements, see the [Zod API Documentation](https://zod.dev/api). ::: ## Asena-Specific Best Practices ### 1. Use Validators for All User Input ```typescript // ✅ Good: Validate all POST/PUT/PATCH requests @Post({ path: '/', validator: CreateUserValidator }) async create(context: Context) { } ``` ### 2. Keep Validators Focused ```typescript // ✅ Good: One validator per endpoint @Middleware({ validator: true }) export class CreateUserValidator extends ValidationService { json() { return z.object({ name: z.string(), email: z.string().email() }); } } ``` ### 3. Use Hooks for Context Integration ```typescript // ✅ Good: Use hooks to enrich context hook: (result, context) => { context.setValue('userId', result.id); context.setValue('userRole', result.role); } ``` ### 4. Leverage Dependency Injection ```typescript // ✅ Good: Inject services in validators @Middleware({ validator: true }) export class UniqueUsernameValidator extends ValidationService { @Inject(UserService) private userService: UserService; async json() { return z.object({ username: z.string().refine(async (name) => { return !(await this.userService.usernameExists(name)); }, 'Username already taken') }); } } ``` ## ValidationSchema Types ```typescript // Simple schema type ValidationSchema = z.ZodType; // Schema with hook interface ValidationSchemaWithHook { schema: ValidationSchema; hook?: (result: any, context: Context) => any; } ``` ## Complete Example Here's a real-world validator with hooks and service injection: ```typescript import { Middleware } from '@asenajs/asena/decorators'; import { ValidationService, type ValidationSchemaWithHook } from '@asenajs/ergenecore'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { z } from 'zod'; @Middleware({ validator: true }) export class RegisterUserValidator extends ValidationService { @Inject(UserService) private userService: UserService; @Inject(Logger) private logger: Logger; async json(): Promise { return { schema: z.object({ username: z.string() .min(3, 'Username must be at least 3 characters') .max(20) .regex(/^[a-zA-Z0-9_]+$/, 'Alphanumeric and underscore only') .refine(async (name) => { const exists = await this.userService.usernameExists(name); return !exists; }, 'Username already taken'), email: z.string() .email('Invalid email') .transform(v => v.toLowerCase()), password: z.string() .min(8, 'Password must be at least 8 characters') .regex(/[A-Z]/, 'Must contain uppercase letter') .regex(/[0-9]/, 'Must contain number'), confirmPassword: z.string(), terms: z.literal(true, { errorMap: () => ({ message: 'Must accept terms' }) }) }).refine((data) => data.password === data.confirmPassword, { message: 'Passwords do not match', path: ['confirmPassword'] }), // The hook receives Zod's SafeParseResult - the data lives at `result.data` // and only when `result.success` is true hook: (result, context) => { if (!result.success) { // Returning a Response overrides the adapter's default 400 envelope return context.send({ success: false, issues: result.error.issues }, 400); } this.logger.info('User registration validated', { username: result.data.username, email: result.data.email }); // Stash the parsed data for the handler to pick up with context.getValue() const { confirmPassword, ...userData } = result.data; context.setValue('registrationData', userData); } }; } } ``` ::: warning The hook cannot transform the payload The return value is a **Response override**, not a transformed body. Returning a plain object is silently ignored - the handler still receives the original parsed data. To pass a reshaped value along, write it to the context with `setValue()` as above. Note also that Ergenecore invokes the hook **only on failure**, while Hono invokes it on every attempt. Guard on `result.success` so the same hook works under both adapters. ::: ## Related Documentation - [Controllers](/docs/concepts/controllers) - Using validators in controllers - [Middleware](/docs/concepts/middleware) - Understanding middleware flow - [Ergenecore Adapter](/docs/adapters/ergenecore) - Ergenecore-specific features - [Context API](/docs/concepts/context) - Working with Context in hooks - [Zod Documentation](https://zod.dev) - Complete Zod schema guide --- **Next Steps:** - Learn about [Middleware](/docs/concepts/middleware) - Explore [Error Handling](/docs/guides/configuration) - Understand [Context API](/docs/concepts/context) --- # Static File Serving Section: Concepts Source: https://asena.sh/raw/concepts/static-files.md # Static File Serving Asena provides built-in support for serving static files through the `@StaticServe` decorator and `StaticServeService` base class. ::: danger BREAKING CHANGES IN v0.7.0 **This feature will undergo major changes in version 0.7.0.** The API and implementation described here may change significantly. If you're starting a new project, be prepared to update your static file serving implementation when v0.7.0 is released. ::: ## Quick Start ### 1. Create a Static Serve Middleware Create a middleware class that extends `StaticServeService` and configure the root directory: ::: code-group ```typescript [Ergenecore] import { StaticServe } from '@asenajs/asena/decorators'; import { StaticServeService, type Context } from '@asenajs/ergenecore'; import path from 'path'; @StaticServe({ root: path.join(process.cwd(), 'public') }) export class StaticServeMiddleware extends StaticServeService { public rewriteRequestPath(reqPath: string): string { // Remove /static prefix from path return reqPath.replace(/^\/static\/|^\/static/, ''); } public onFound(filePath: string, _c: Context): void { console.log(`File served: ${filePath}`); } public onNotFound(reqPath: string, c: Context): void { console.log(`File not found: ${reqPath}`); } } ``` ```typescript [Hono] import { StaticServe } from '@asenajs/asena/decorators'; import { StaticServeService, type Context } from '@asenajs/hono-adapter'; import path from 'path'; @StaticServe({ root: path.join(process.cwd(), 'public') }) export class StaticServeMiddleware extends StaticServeService { public rewriteRequestPath(reqPath: string): string { // Remove /static prefix from path return reqPath.replace(/^\/static\/|^\/static/, ''); } public onFound(filePath: string, _c: Context): void { console.log(`File served: ${filePath}`); } public onNotFound(reqPath: string, c: Context): void { console.log(`File not found: ${reqPath}`); } } ``` ::: ### 2. Create a Controller for Static Routes Create a controller that uses the middleware: ::: code-group ```typescript [Ergenecore] import { Controller } from '@asenajs/asena/decorators'; import { Get } from '@asenajs/asena/decorators/http'; import { StaticServeMiddleware } from './middlewares/StaticServeMiddleware'; @Controller({ path: '/static' }) export class StaticController { @Get({ path: '/*', staticServe: StaticServeMiddleware }) public static() {} } ``` ```typescript [Hono] import { Controller } from '@asenajs/asena/decorators'; import { Get } from '@asenajs/asena/decorators/http'; import { StaticServeMiddleware } from './middlewares/StaticServeMiddleware'; @Controller({ path: '/static' }) export class StaticController { @Get({ path: '/*', staticServe: StaticServeMiddleware }) public static() {} } ``` ::: ### 3. Create Your Public Directory Create a `public` directory in your project root and add your static files: ``` your-project/ ├── src/ │ ├── controllers/ │ │ └── StaticController.ts │ └── middlewares/ │ └── StaticServeMiddleware.ts └── public/ ├── index.html ├── style.css ├── script.js └── images/ └── logo.png ``` ### 4. Access Your Static Files Start your server and access files via the configured route: ```bash # Access HTML files curl http://localhost:3000/static/index.html # Access CSS files curl http://localhost:3000/static/style.css # Access images curl http://localhost:3000/static/images/logo.png ``` --- ## Configuration ### `@StaticServe` Options Configure the static serve middleware with the following options: ```typescript @StaticServe({ root: string; // Root directory for static files (required) }) ``` | Option | Type | Description | Example | |--------|------|-------------|---------| | `root` | `string` | Absolute path to the directory containing static files | `path.join(process.cwd(), 'public')` | **Example:** ```typescript import path from 'path'; // Serve from 'public' directory @StaticServe({ root: path.join(process.cwd(), 'public') }) // Serve from 'assets' directory @StaticServe({ root: path.join(process.cwd(), 'assets') }) // Serve from custom path @StaticServe({ root: '/var/www/static' }) ``` --- ## Lifecycle Hooks The `StaticServeService` base class provides three lifecycle hooks. **All three are optional** - override only the ones you need. ### `rewriteRequestPath(reqPath: string): string` Transform the incoming request path before looking up the file. Useful for removing route prefixes or implementing custom path logic. **Parameters:** - `reqPath: string` - The original request path **Returns:** `string` - The transformed path to use for file lookup **Example:** ```typescript public rewriteRequestPath(reqPath: string): string { console.log(`Original path: ${reqPath}`); // Remove /static prefix const rewritten = reqPath.replace(/^\/static\/|^\/static/, ''); console.log(`Rewritten path: ${rewritten}`); return rewritten; } ``` **Use Cases:** - Remove route prefixes (`/static/file.js` → `file.js`) - Add file extensions (`/page` → `/page.html`) - Normalize paths (`/foo//bar` → `/foo/bar`) --- ### `onFound(filePath: string, c: Context): void | Promise` Called when a file is successfully found and served. Useful for logging, analytics, or custom headers. **Parameters:** - `filePath: string` - The looked-up path. **Not absolute, and not the same across adapters:** Ergenecore passes the rewritten *request* path (no root prefix), Hono passes the root-joined path (e.g. `./public/index.html`). - `c: Context` - The request context **Example:** ```typescript public onFound(filePath: string, c: Context): void { console.log(`✅ File served: ${filePath}`); // Track analytics this.analytics.track('file_served', { path: filePath }); } ``` ::: danger Setting response headers here does not work on Ergenecore `c.setResponseHeader()` writes into the context's response object, which Ergenecore only consults for `send()`/`html()`. Static responses are built directly from the file, so the header is dropped. (On Hono it does work, because the wrapper appends to the live response.) Use the service's `extra` property instead - it is honoured by both adapters: ```typescript @StaticServe({ root: path.join(process.cwd(), 'public') }) export class PublicStaticServe extends StaticServeService { public extra = { cacheControl: 'public, max-age=31536000, immutable', headers: { 'X-Served-By': 'Asena Static Serve' }, precompressed: true, // prefer .br / .gz variants when present mimes: { '.ts': 'text/typescript' }, // override MIME detection }; } ``` ::: --- ### `onNotFound(reqPath: string, c: Context): void | Promise` Called when a requested file is not found. Useful for logging 404s or implementing fallback logic. **Parameters:** - `reqPath: string` - The requested path that wasn't found - `c: Context` - The request context **Example:** ```typescript public onNotFound(reqPath: string, c: Context): void { console.log(`❌ File not found: ${reqPath}`); // Log 404s this.logger.warn('Static file not found', { path: reqPath }); } ``` ::: warning `onNotFound` is observational Its return value is discarded and the adapter still answers `404`. To take the request over, mark the hook with `@Override()` - Ergenecore then skips its own 404 and lets the request fall through to your route handlers: ```typescript import { Override } from '@asenajs/asena/decorators'; @Override() public onNotFound(reqPath: string, c: Context): void { this.logger.warn('Static file not found', { path: reqPath }); } ``` ::: --- ## Examples ### Basic Static File Server Serve files from a public directory: ::: code-group ```typescript [Ergenecore] import { StaticServe } from '@asenajs/asena/decorators'; import { StaticServeService, type Context } from '@asenajs/ergenecore'; import path from 'path'; @StaticServe({ root: path.join(process.cwd(), 'public') }) export class StaticServeMiddleware extends StaticServeService { public rewriteRequestPath(reqPath: string): string { return reqPath.replace(/^\/static\/|^\/static/, ''); } public onFound(filePath: string, _c: Context): void { console.log(`Served: ${filePath}`); } public onNotFound(reqPath: string, _c: Context): void { console.log(`Not found: ${reqPath}`); } } ``` ```typescript [Hono] import { StaticServe } from '@asenajs/asena/decorators'; import { StaticServeService, type Context } from '@asenajs/hono-adapter'; import path from 'path'; @StaticServe({ root: path.join(process.cwd(), 'public') }) export class StaticServeMiddleware extends StaticServeService { public rewriteRequestPath(reqPath: string): string { return reqPath.replace(/^\/static\/|^\/static/, ''); } public onFound(filePath: string, _c: Context): void { console.log(`Served: ${filePath}`); } public onNotFound(reqPath: string, _c: Context): void { console.log(`Not found: ${reqPath}`); } } ``` ::: ```typescript // Controller @Controller({ path: '/static' }) export class StaticController { @Get({ path: '/*', staticServe: StaticServeMiddleware }) public static() {} } ``` **Usage:** ```bash # http://localhost:3000/static/index.html # Serves: public/index.html ``` --- ### SPA with Fallback to index.html Serve a Single Page Application with fallback routing: ::: code-group ```typescript [Ergenecore] import { StaticServe } from '@asenajs/asena/decorators'; import { StaticServeService, type Context } from '@asenajs/ergenecore'; import path from 'path'; import { existsSync } from 'fs'; @StaticServe({ root: path.join(process.cwd(), 'dist') }) export class SPAStaticServe extends StaticServeService { public rewriteRequestPath(reqPath: string): string { const cleanPath = reqPath.replace(/^\/|^/, ''); const fullPath = path.join(process.cwd(), 'dist', cleanPath); // If file doesn't exist and no extension, fallback to index.html if (!existsSync(fullPath) && !path.extname(cleanPath)) { return 'index.html'; } return cleanPath; } public onFound(filePath: string, c: Context): void { // Cache static assets if (filePath.match(/\.(js|css|png|jpg|svg)$/)) { c.setResponseHeader('Cache-Control', 'public, max-age=31536000'); } } public onNotFound(reqPath: string, c: Context): void { console.warn(`SPA: File not found - ${reqPath}`); } } ``` ```typescript [Hono] import { StaticServe } from '@asenajs/asena/decorators'; import { StaticServeService, type Context } from '@asenajs/hono-adapter'; import path from 'path'; import { existsSync } from 'fs'; @StaticServe({ root: path.join(process.cwd(), 'dist') }) export class SPAStaticServe extends StaticServeService { public rewriteRequestPath(reqPath: string): string { const cleanPath = reqPath.replace(/^\/|^/, ''); const fullPath = path.join(process.cwd(), 'dist', cleanPath); // If file doesn't exist and no extension, fallback to index.html if (!existsSync(fullPath) && !path.extname(cleanPath)) { return 'index.html'; } return cleanPath; } public onFound(filePath: string, c: Context): void { // Cache static assets if (filePath.match(/\.(js|css|png|jpg|svg)$/)) { c.setResponseHeader('Cache-Control', 'public, max-age=31536000'); } } public onNotFound(reqPath: string, c: Context): void { console.warn(`SPA: File not found - ${reqPath}`); } } ``` ::: ```typescript // Controller @Controller({ path: '/' }) export class AppController { @Get({ path: '/*', staticServe: SPAStaticServe }) public spa() {} } ``` **Usage:** ```bash # All these routes serve dist/index.html http://localhost:3000/ http://localhost:3000/about http://localhost:3000/users/123 # Static assets served directly http://localhost:3000/app.js # Serves: dist/app.js http://localhost:3000/style.css # Serves: dist/style.css ``` --- ### Multiple Static Directories Serve different directories from different routes: ::: code-group ```typescript [Ergenecore] import { StaticServe } from '@asenajs/asena/decorators'; import { StaticServeService, type Context } from '@asenajs/ergenecore'; import path from 'path'; // Public assets @StaticServe({ root: path.join(process.cwd(), 'public') }) export class PublicStaticServe extends StaticServeService { public rewriteRequestPath(reqPath: string): string { return reqPath.replace(/^\/assets\/|^\/assets/, ''); } } // Downloads @StaticServe({ root: path.join(process.cwd(), 'downloads') }) export class DownloadStaticServe extends StaticServeService { public rewriteRequestPath(reqPath: string): string { return reqPath.replace(/^\/downloads\/|^\/downloads/, ''); } public onFound(filePath: string, c: Context): void { // Force download const fileName = path.basename(filePath); c.setResponseHeader('Content-Disposition', `attachment; filename="${fileName}"`); } } // Images with custom headers @StaticServe({ root: path.join(process.cwd(), 'images') }) export class ImageStaticServe extends StaticServeService { public rewriteRequestPath(reqPath: string): string { return reqPath.replace(/^\/images\/|^\/images/, ''); } public onFound(filePath: string, c: Context): void { // Long cache for images c.setResponseHeader('Cache-Control', 'public, max-age=2592000'); // 30 days } } ``` ```typescript [Hono] import { StaticServe } from '@asenajs/asena/decorators'; import { StaticServeService, type Context } from '@asenajs/hono-adapter'; import path from 'path'; // Public assets @StaticServe({ root: path.join(process.cwd(), 'public') }) export class PublicStaticServe extends StaticServeService { public rewriteRequestPath(reqPath: string): string { return reqPath.replace(/^\/assets\/|^\/assets/, ''); } } // Downloads @StaticServe({ root: path.join(process.cwd(), 'downloads') }) export class DownloadStaticServe extends StaticServeService { public rewriteRequestPath(reqPath: string): string { return reqPath.replace(/^\/downloads\/|^\/downloads/, ''); } public onFound(filePath: string, c: Context): void { // Force download const fileName = path.basename(filePath); c.setResponseHeader('Content-Disposition', `attachment; filename="${fileName}"`); } } // Images with custom headers @StaticServe({ root: path.join(process.cwd(), 'images') }) export class ImageStaticServe extends StaticServeService { public rewriteRequestPath(reqPath: string): string { return reqPath.replace(/^\/images\/|^\/images/, ''); } public onFound(filePath: string, c: Context): void { // Long cache for images c.setResponseHeader('Cache-Control', 'public, max-age=2592000'); // 30 days } } ``` ::: ```typescript // Controllers @Controller({ path: '/assets' }) export class AssetsController { @Get({ path: '/*', staticServe: PublicStaticServe }) public assets() {} } @Controller({ path: '/downloads' }) export class DownloadsController { @Get({ path: '/*', staticServe: DownloadStaticServe }) public downloads() {} } @Controller({ path: '/images' }) export class ImagesController { @Get({ path: '/*', staticServe: ImageStaticServe }) public images() {} } ``` **Usage:** ```bash # Public assets http://localhost:3000/assets/logo.png # Downloads (forces download) http://localhost:3000/downloads/report.pdf # Images (with long cache) http://localhost:3000/images/banner.jpg ``` --- ## Best Practices ### 1. Use Absolute Paths for Root Directory Always use `path.join(process.cwd(), 'directory')` for the root directory: ```typescript // ✅ Good @StaticServe({ root: path.join(process.cwd(), 'public') }) // ❌ Bad - relative path may not work @StaticServe({ root: './public' }) ``` ### 2. Implement Security Checks Prevent directory traversal attacks in `rewriteRequestPath`: ```typescript public rewriteRequestPath(reqPath: string): string { let cleanPath = reqPath.replace(/^\/static\/|^\/static/, ''); // Remove directory traversal attempts cleanPath = cleanPath.replace(/\.\./g, ''); // Normalize path cleanPath = path.normalize(cleanPath); return cleanPath; } ``` ### 3. Set Appropriate Cache Headers Use `onFound` to set cache headers based on file type: ```typescript public onFound(filePath: string, c: Context): void { const ext = path.extname(filePath); // Long cache for immutable assets if (ext.match(/\.(js|css|woff2?|ttf|svg|png|jpg|gif)$/)) { c.setResponseHeader('Cache-Control', 'public, max-age=31536000, immutable'); } // Short cache for HTML else if (ext === '.html') { c.setResponseHeader('Cache-Control', 'public, max-age=300'); } // No cache for others else { c.setResponseHeader('Cache-Control', 'no-cache'); } } ``` ### 4. Log 404s for Monitoring Use `onNotFound` to track missing files: ```typescript public onNotFound(reqPath: string, c: Context): void { console.warn(`404: ${reqPath} - Referrer: ${c.req.header('referer')}`); // Track in monitoring service this.monitoring.track404(reqPath); } ``` ### 5. Keep Middleware Simple Avoid heavy logic in lifecycle hooks. Keep them fast and focused: ```typescript // ✅ Good - Simple and fast public rewriteRequestPath(reqPath: string): string { return reqPath.replace(/^\/static\//, ''); } // ❌ Bad - Too much logic public rewriteRequestPath(reqPath: string): string { // Multiple regex checks // Database lookups // Complex transformations // This slows down every request! } ``` ### 6. Be Prepared for v0.7.0 Changes ::: warning Since this API will change in v0.7.0, avoid building complex abstractions on top of the current implementation. Keep your static serving logic isolated and easy to refactor. ::: --- ## Common Use Cases ### Serve Build Output Serve production build from frontend frameworks: ```typescript // React/Vue/Svelte build output @StaticServe({ root: path.join(process.cwd(), 'dist') }) // Next.js static export @StaticServe({ root: path.join(process.cwd(), 'out') }) ``` ### Serve Documentation Serve generated documentation: ```typescript // VitePress docs @StaticServe({ root: path.join(process.cwd(), 'docs/.vitepress/dist') }) // Storybook @StaticServe({ root: path.join(process.cwd(), 'storybook-static') }) ``` ### Serve User Uploads Serve user-uploaded files: ```typescript @StaticServe({ root: path.join(process.cwd(), 'uploads') }) export class UploadsStaticServe extends StaticServeService { public onFound(filePath: string, c: Context): void { // Add security headers c.setResponseHeader('X-Content-Type-Options', 'nosniff'); c.setResponseHeader('Content-Disposition', 'inline'); } } ``` --- ## Troubleshooting ### Files Not Being Served **Check:** 1. Is the `root` path correct and absolute? 2. Is `rewriteRequestPath` removing the route prefix correctly? 3. Do the files exist in the specified directory? 4. Check console logs from `onFound` and `onNotFound` hooks ### MIME Type Issues Ensure proper file extensions and let the adapter handle MIME types automatically. ### 404 Errors for Existing Files Check that `rewriteRequestPath` is not over-transforming the path: ```typescript public rewriteRequestPath(reqPath: string): string { console.log('Original:', reqPath); const result = reqPath.replace(/^\/static\//, ''); console.log('Rewritten:', result); return result; } ``` --- ## Related - [Controllers](/docs/concepts/controllers.md) - Setting up controllers - [Middleware](/docs/concepts/middleware.md) - Understanding middleware - [Context](/docs/concepts/context.md) - Working with request context --- # WebSocket Section: Concepts Source: https://asena.sh/raw/concepts/websocket.md # WebSocket Asena provides built-in WebSocket support with namespace management, allowing you to create real-time, bidirectional communication between clients and your server. ## Creating a WebSocket Service Create a WebSocket service by extending `AsenaWebSocketService` and decorating it with `@WebSocket`: ```typescript import { WebSocket } from '@asenajs/asena/decorators'; import { AsenaWebSocketService, type Socket } from '@asenajs/asena/web-socket'; @WebSocket({ path: '/chat', name: 'ChatSocket' }) export class ChatSocket extends AsenaWebSocketService { protected async onOpen(ws: Socket): Promise { console.log('Client connected:', ws.id); ws.send('Welcome to chat!'); } protected async onMessage(ws: Socket, message: string): Promise { console.log('Received:', message); ws.send(`Echo: ${message}`); } protected async onClose(ws: Socket): Promise { console.log('Client disconnected:', ws.id); } } ``` ## WebSocket Lifecycle Methods ### onOpen(socket) Called when a client connects. ```typescript protected async onOpen(ws: Socket): Promise { console.log(`New connection: ${ws.id}`); ws.send(JSON.stringify({ type: 'welcome', message: 'Connected!' })); } ``` ### onMessage(socket, message) Called when a message is received. ```typescript protected async onMessage(ws: Socket, message: string): Promise { const data = JSON.parse(message); if (data.type === 'ping') { ws.send(JSON.stringify({ type: 'pong' })); } } ``` ### onClose(socket) Called when a client disconnects. ```typescript protected async onClose(ws: Socket): Promise { console.log(`Client disconnected: ${ws.id}`); // Cleanup logic } ``` ## Socket API ### ws.send(message) Send a message to the client. ```typescript ws.send('Hello client!'); ws.send(JSON.stringify({ type: 'update', data: { count: 42 } })); ``` ### ws.id Unique identifier for the socket connection. ```typescript console.log(`Socket ID: ${ws.id}`); ``` ### ws.data Custom data attached to the socket (typed). It holds 3 values. ```typescript export interface WebSocketData { values: T; id: string; path: string; } ``` Asena automatically assigns the `id` and `path` fields. The `values: T` field is reserved for user-managed data. You can update it through `context.setWebSocketValue`, and it will automatically sync to `socket.data.values` ```typescript interface UserData { userId: string; username: string; } @WebSocket({ path: '/chat', name: 'ChatSocket' }) export class ChatSocket extends AsenaWebSocketService { protected async onOpen(ws: Socket): Promise { console.log(`User connected: ${ws.data.values.username}`); } } ``` ## Built-in Room Management ::: tip Asena's Built-in Features Asena provides **automatic room management** with built-in pub/sub pattern. You don't need to manually manage `Map` or track connections yourself - Asena handles everything for you! **Key built-in features:** - `ws.subscribe(room)` - Automatically joins room and tracks membership - `ws.publish(room, data)` - Broadcasts to all room subscribers - `ws.unsubscribe(room)` - Leaves room with automatic cleanup - `this.sockets` - All connected sockets of this namespace (managed automatically) - `this.to(room, data)` - Broadcast from service level - `this.in(data)` - Broadcast to all connected clients ::: ::: warning Rooms are not enumerable Room membership lives in Bun's pub/sub topics, which cannot be listed. There is no `this.rooms` map and no `getSocketsByRoom()`. If you need a member count or roster, keep your own registry - add the socket in `onOpen`, remove it in `onClose`. ::: ### Subscribing to Rooms When a client connects, use `subscribe()` to join a room. Asena automatically tracks the socket in that room: ```typescript interface ChatData { username: string; room: string; } @WebSocket({ path: '/chat', name: 'ChatSocket' }) export class ChatSocket extends AsenaWebSocketService { protected async onOpen(ws: Socket): Promise { const room = ws.data.values.room || 'general'; const username = ws.data.values.username || 'Anonymous'; // Subscribe to room - Asena tracks this automatically ws.subscribe(room); // Welcome the user ws.send(JSON.stringify({ type: 'welcome', message: `Welcome to ${room}, ${username}!` })); // Notify others in the room using publish ws.publish(room, JSON.stringify({ type: 'user_joined', username, timestamp: new Date().toISOString() })); } } ``` ### Publishing Messages Use `ws.publish()` to broadcast messages to all subscribers of a room: ```typescript protected async onMessage(ws: Socket, message: string): Promise { const room = ws.data?.values.room || 'general'; const username = ws.data?.values.username; try { const data = JSON.parse(message); if (data.type === 'message') { // Broadcast to all subscribers in the room ws.publish(room, JSON.stringify({ type: 'message', username, message: data.message, timestamp: new Date().toISOString() })); } } catch (error) { ws.send(JSON.stringify({ type: 'error', message: 'Invalid message format' })); } } ``` ### Unsubscribing from Rooms When a client disconnects or leaves a room, use `unsubscribe()`: ```typescript protected async onClose(ws: Socket): Promise { const room = ws.data?.values.room || 'general'; const username = ws.data?.values.username; // Notify room before leaving ws.publish(room, JSON.stringify({ type: 'user_left', username })); // Unsubscribe - Asena handles cleanup automatically ws.unsubscribe(room); } ``` ### Counting Room Members Bun's pub/sub topics are write-only - you can publish to a room but not enumerate it. To report a member count, track it yourself: ```typescript @WebSocket({ path: '/ws/chat', name: 'ChatSocket' }) export class ChatSocket extends AsenaWebSocketService { private roomMembers = new Map>(); protected async onOpen(ws: Socket): Promise { const room = ws.data?.values.room || 'general'; ws.subscribe(room); if (!this.roomMembers.has(room)) this.roomMembers.set(room, new Set()); this.roomMembers.get(room).add(ws.id); } protected async onClose(ws: Socket): Promise { const room = ws.data?.values.room || 'general'; this.roomMembers.get(room)?.delete(ws.id); } protected async onMessage(ws: Socket, message: Buffer | string): Promise { const room = ws.data?.values.room || 'general'; ws.send(JSON.stringify({ type: 'room_info', totalUsers: this.roomMembers.get(room)?.size ?? 0 })); } } ``` ::: warning Single-pod only This registry lives in one process. In a multi-pod deployment each pod sees only its own sockets - use a shared store (Redis) if the count must be global. ::: ## Broadcasting ### Broadcast to All Clients Use the built-in `this.in()` method to broadcast to all connected clients: ```typescript @WebSocket({ path: '/notifications', name: 'NotificationSocket' }) export class NotificationSocket extends AsenaWebSocketService { // No need to manually track sockets - Asena does it for you! // Public method to broadcast notifications broadcastNotification(notification: any) { // Pass the object as-is - this.in()/this.to() serialize it for you this.in(notification); } // You can also access all sockets via this.sockets (built-in) getConnectedUsers() { return Array.from(this.sockets.keys()); } } ``` ### Broadcast to Specific Room Use `this.to(room, data)` to broadcast to a specific room from the service level: ```typescript @WebSocket({ path: '/chat', name: 'ChatSocket' }) export class ChatSocket extends AsenaWebSocketService<{ room: string }> { // Broadcast to a specific room notifyRoom(room: string, notification: any) { this.to(room, notification); } // Example: Admin sends announcement to a room sendAnnouncement(room: string, message: string) { this.to(room, { type: 'announcement', message, timestamp: new Date().toISOString() }); } } ``` ::: danger Do not pre-stringify for `this.to()` / `this.in()` These two serialize their payload internally. Passing a string that is already JSON gets encoded a second time, so the client receives `"{\"type\":\"announcement\"}"` - a quoted string, not an object. The socket-level methods behave the opposite way: `ws.send()` and `ws.publish()` pass the value through untouched, so those **do** take a string. ::: ### Private Messages Send a message to a specific user using their socket ID: ```typescript sendPrivateMessage(targetSocketId: string, message: string) { const targetSocket = this.sockets.get(targetSocketId); if (targetSocket) { targetSocket.send(JSON.stringify({ type: 'private_message', message })); } } ``` ## WebSocket Middleware Just like controllers, WebSocket services support middleware! This is the **recommended way** to handle authentication, logging, and rate limiting. ### Creating WebSocket Middleware ```typescript import { Middleware } from '@asenajs/asena/decorators'; import type { Context, MiddlewareService } from '@asenajs/ergenecore'; @Middleware() export class WsAuthMiddleware implements MiddlewareService { async handle(context: Context, next: () => Promise): Promise { // Check query params for token const url = new URL(context.req.url); const token = url.searchParams.get('token'); // Or check Authorization header const authHeader = context.req.headers.get('Authorization'); const tokenFromHeader = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null; const finalToken = token || tokenFromHeader; if (!finalToken) { return context.send({ error: 'Unauthorized' }, 401); } // Verify token (replace with your auth logic) if (finalToken !== 'valid-token') { return context.send({ error: 'Invalid token' }, 401); } // Pass user data to WebSocket using setWebSocketValue context.setWebSocketValue({ userId: '123', username: 'john_doe' }); await next(); } } ``` ::: tip context.setWebSocketValue() This is the key method! Use `context.setWebSocketValue()` in middleware to pass authenticated user data to your WebSocket service. The data will be available in `ws.data.values`. ::: ### Using Middleware in WebSocket ```typescript import { WebSocket } from '@asenajs/asena/decorators'; import { AsenaWebSocketService, type Socket } from '@asenajs/asena/web-socket'; import { WsAuthMiddleware } from '../middlewares/WsAuthMiddleware'; interface UserData { userId: string; username: string; } @WebSocket({ path: '/private', middlewares: [WsAuthMiddleware], // Add middleware here name: 'PrivateSocket' }) export class PrivateSocket extends AsenaWebSocketService { protected async onOpen(ws: Socket): Promise { // Access authenticated user data from middleware const { userId, username } = ws.data.values; console.log(`Authenticated user connected: ${username}`); ws.send(JSON.stringify({ type: 'authenticated', userId, username })); } protected async onMessage(ws: Socket, message: string): Promise { const { username } = ws.data.values; console.log(`Message from ${username}:`, message); } } ``` ### Multiple Middleware You can use multiple middleware, just like in controllers: ```typescript import { WsLoggingMiddleware } from '../middlewares/WsLoggingMiddleware'; import { WsAuthMiddleware } from '../middlewares/WsAuthMiddleware'; import { WsRateLimitMiddleware } from '../middlewares/WsRateLimitMiddleware'; @WebSocket({ path: '/admin', middlewares: [ WsLoggingMiddleware, // Runs first WsAuthMiddleware, // Then authentication WsRateLimitMiddleware // Finally rate limiting ], name: 'AdminSocket' }) export class AdminSocket extends AsenaWebSocketService { protected async onOpen(ws: Socket): Promise { // Only authenticated and rate-limited users reach here const userData = ws.data.values; ws.send(JSON.stringify({ type: 'admin-welcome', user: userData, permissions: ['read', 'write', 'delete'] })); } } ``` ::: info Middleware Execution Order Middleware executes in the order specified in the array, **before** the WebSocket connection is established. If any middleware returns a response or doesn't call `next()`, the connection is rejected. ::: ### Client-Side Example Connect with authentication: ```typescript // With query parameter const ws = new WebSocket('ws://localhost:3000/private?token=valid-token'); // Or with Authorization header (if your WebSocket client supports it) const ws = new WebSocket('ws://localhost:3000/private', { headers: { 'Authorization': 'Bearer valid-token' } }); ``` ## Using WebSocket in Services You can inject a WebSocket service into other services to send messages from anywhere in your application: ```typescript import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { ChatSocket } from './ChatSocket'; @Service('NotificationService') export class NotificationService { @Inject(ChatSocket) private chatSocket: ChatSocket; async sendSystemMessage(room: string, message: string) { // Broadcast from outside the WebSocket service this.chatSocket.to(room, JSON.stringify({ type: 'system_message', message, timestamp: new Date().toISOString() })); } async notifyAllUsers(message: string) { // Broadcast to all connected clients this.chatSocket.in(JSON.stringify({ type: 'notification', message })); } } ``` ::: tip Service Injection This is a powerful pattern! You can send WebSocket messages from controllers, services, or background jobs by injecting the WebSocket service. ::: ## Real-World Example: Notification System Here's a simple notification system showing how to use WebSocket with service injection: ### WebSocket Service ```typescript import { WebSocket } from '@asenajs/asena/decorators'; import { AsenaWebSocketService, type Socket } from '@asenajs/asena/web-socket'; interface NotificationData { userId: string; } @WebSocket({ path: '/ws/notifications', name: 'NotificationSocket' }) export class NotificationSocket extends AsenaWebSocketService { protected async onOpen(ws: Socket): Promise { const userId = ws.data?.values.userId; // Subscribe to user's personal notification channel ws.subscribe(`user:${userId}`); // Subscribe to global announcements ws.subscribe('announcements'); // Send welcome message ws.send(JSON.stringify({ type: 'connected', message: 'Connected to notification system' })); } protected async onClose(ws: Socket): Promise { const userId = ws.data?.values.userId; // Unsubscribe from channels - Asena handles cleanup ws.unsubscribe(`user:${userId}`); ws.unsubscribe('announcements'); } protected async onMessage(ws: Socket, message: string): Promise { try { const data = JSON.parse(message); if (data.type === 'ping') { ws.send(JSON.stringify({ type: 'pong' })); } } catch (error) { console.error('Invalid message format'); } } } ``` ### Using WebSocket from a Service Now you can send notifications from any service in your application: ```typescript import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { NotificationSocket } from '../websocket/NotificationSocket'; @Service('UserService') export class UserService { @Inject(NotificationSocket) private notificationSocket: NotificationSocket; async updateUserProfile(userId: string, data: any): Promise { // ... update user in database // Notify the user via WebSocket this.notificationSocket.to(`user:${userId}`, JSON.stringify({ type: 'profile_updated', message: 'Your profile has been updated', timestamp: new Date().toISOString() })); } async sendGlobalAnnouncement(message: string): Promise { // Broadcast to all users subscribed to announcements this.notificationSocket.to('announcements', JSON.stringify({ type: 'announcement', message, timestamp: new Date().toISOString() })); } } ``` ### Using from a Controller You can also trigger notifications from HTTP endpoints: ```typescript import { Controller } from '@asenajs/asena/decorators'; import { Post } from '@asenajs/asena/decorators/http'; import { Inject } from '@asenajs/asena/decorators/ioc'; import type { Context } from '@asenajs/ergenecore'; @Controller('/admin') export class AdminController { @Inject(NotificationSocket) private notificationSocket: NotificationSocket; @Post('/announcement') async sendAnnouncement(context: Context) { const { message } = await context.getBody<{ message: string }>(); // Broadcast to all connected clients this.notificationSocket.to('announcements', JSON.stringify({ type: 'announcement', message, timestamp: new Date().toISOString() })); return context.send({ success: true }); } } ``` ::: info Key Takeaways This example demonstrates: - **Built-in room management** with `subscribe()` / `unsubscribe()` - **Service injection** to send WebSocket messages from anywhere - **Multiple channels** (user-specific and global) - **Controller integration** for HTTP → WebSocket communication - Asena handles all socket/room tracking automatically ::: ## Error Handling ```typescript @WebSocket({ path: '/chat', name: 'ChatSocket' }) export class ChatSocket extends AsenaWebSocketService { protected async onMessage(ws: Socket, message: string): Promise { try { const data = JSON.parse(message); // Process message if (!data.type) { throw new Error('Message type is required'); } // Handle different message types switch (data.type) { case 'ping': ws.send(JSON.stringify({ type: 'pong' })); break; default: throw new Error(`Unknown message type: ${data.type}`); } } catch (error) { ws.send(JSON.stringify({ type: 'error', message: error instanceof Error ? error.message : 'Unknown error' })); } } } ``` ## Multi-Pod WebSocket When running multiple server instances (pods), WebSocket messages published on one pod won't reach clients connected to other pods by default. Asena solves this with the **WebSocket Transport** abstraction. ### How Transport Works By default, Asena uses `BunLocalTransport` which calls `server.publish()` directly — zero overhead for single-pod deployments. For multi-pod setups, you can plug in a transport (like `RedisTransport`) that relays messages across instances via a message broker. ### Setting Up RedisTransport Configure the transport in your `@Config` class: ```typescript import { Config } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { ConfigService } from '@asenajs/hono-adapter'; // or '@asenajs/ergenecore' import { RedisTransport } from '@asenajs/asena-redis'; @Config() export class AppConfig extends ConfigService { @Inject('AppRedis') private redis: AppRedis; public transport() { return new RedisTransport(this.redis); } } ``` Or without an existing Redis service: ```typescript public transport() { return new RedisTransport({ url: 'redis://localhost:6379' }); } ``` ### How It Works Each server instance gets a unique pod ID. When a WebSocket message is published: 1. The message is delivered locally via `server.publish()` 2. The message is sent to Redis pub/sub with the originating pod ID 3. Other pods receive the message and deliver it to their local sockets 4. Messages from the same pod are deduplicated automatically ::: tip No Code Changes Your WebSocket services, Ulak messaging, and room management work exactly the same with `RedisTransport`. The transport layer is transparent — just configure it in `@Config` and multi-pod support is enabled. ::: ::: info Custom Transports The `WebSocketTransport` interface (from `@asenajs/asena`) defines `publish()`, `init()`, and `destroy()` methods. You can implement custom transports for other message brokers like NATS or RabbitMQ. ::: For full `RedisTransport` reference and Redis service setup, see [Redis Package](/docs/packages/redis). ## Best Practices ### 1. Use Built-in Room Management ```typescript // ✅ Good: Use Asena's built-in subscribe/unsubscribe protected async onOpen(ws: Socket) { ws.subscribe('room-1'); ws.publish('room-1', 'Hello room!'); } protected async onClose(ws: Socket) { ws.unsubscribe('room-1'); } // ❌ Bad: Manually managing rooms with Map private rooms = new Map>(); // Don't do this! ``` ::: warning Avoid Manual Room Management Asena automatically tracks sockets in rooms when you use `subscribe()` and `unsubscribe()`. Manual Map-based room management can lead to memory leaks and synchronization issues. ::: ### 2. Use Broadcasting Methods ```typescript // ✅ Good: Use built-in broadcasting - pass objects, they are serialized for you this.to('room-1', { text: 'Message to room' }); // Broadcast to specific room this.in({ text: 'Message to all' }); // Broadcast to all clients // ✅ Good: Use publish from socket level - this one takes the raw payload ws.publish('room-1', 'Message from user'); // ❌ Bad: Manual iteration over sockets for (const socket of this.sockets.values()) { socket.send(message); // Inefficient and error-prone } ``` ### 3. Let Asena Handle Cleanup ```typescript // ✅ Good: Asena handles cleanup automatically protected async onClose(ws: Socket) { // Just unsubscribe from rooms ws.unsubscribe('room-1'); // Asena automatically: // - Removes socket from this.sockets // - Cleans up room references // - Handles connection termination } // ❌ Bad: Manual cleanup (unnecessary and error-prone) protected async onClose(ws: Socket) { this.sockets.delete(ws.id); // Asena does this! this.rooms.forEach(r => r.delete(ws)); // Asena does this too! } ``` ## Breaking Circular Dependencies with Ulak When building complex applications, you may encounter **circular dependency** issues when: 1. **WebSocket services need to inject business services** for domain logic 2. **Business services need to inject WebSocket services** to send real-time updates ::: danger Circular Dependency Problem ```typescript // ❌ This creates a circular dependency @WebSocket('/notifications') export class NotificationWebSocket extends AsenaWebSocketService<{}> { @Inject(UserService) // WebSocket needs service private userService: UserService; } @Service('UserService') export class UserService { @Inject(NotificationWebSocket) // ❌ Service needs WebSocket - CIRCULAR! private notificationWs: NotificationWebSocket; } ``` ::: ### The Solution: Ulak Message Broker **Ulak** (Turkish: Messenger/Courier) is Asena's centralized WebSocket message broker that breaks this circular dependency by acting as a mediator between services and WebSocket connections. ```typescript // ✅ No circular dependency with Ulak import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { ulak } from '@asenajs/asena/messaging'; import type { Ulak } from '@asenajs/asena/messaging'; @Service('UserService') export class UserService { // Inject scoped Ulak namespace instead of WebSocket service @Inject(ulak('/notifications')) private notifications: Ulak.NameSpace<'/notifications'>; async createUser(name: string, email: string) { const user = await this.saveUser(name, email); // Send messages to WebSocket clients without injecting the WebSocket service await this.notifications.broadcast({ type: 'user_created', user }); return user; } async notifyUser(userId: string, message: string) { // Send to specific room await this.notifications.to(`user:${userId}`, { type: 'notification', message }); } private async saveUser(name: string, email: string) { // Database logic return { id: '123', name, email }; } } ``` ::: tip When to Use Ulak Use **Ulak** when you need to: - Send WebSocket messages from services, controllers, or background jobs - Avoid circular dependencies between WebSocket handlers and domain services - Broadcast to multiple namespaces from a single service - Build scalable real-time features with clean separation of concerns **Continue using direct WebSocket injection** (this guide's approach) when: - You only need simple, one-way communication patterns - You're not facing circular dependency issues - Your WebSocket logic is self-contained ::: ### Ulak Key Features - **Three injection styles**: Scoped namespace (recommended), expression-based, or direct Ulak injection - **Unified API**: `broadcast()`, `to()`, `toSocket()`, `toMany()` for messaging - **Error handling**: Structured `UlakError` with specific error codes - **Bulk operations**: Send multiple messages efficiently with `bulkSend()` For complete documentation, see [Ulak - WebSocket Messaging System](/docs/concepts/ulak). ## Related Documentation - [Ulak - WebSocket Messaging System](/docs/concepts/ulak) - Break circular dependencies - [Ergenecore Adapter](/docs/adapters/ergenecore) - [Hono Adapter](/docs/adapters/hono) - [Services](/docs/concepts/services) - [Dependency Injection](/docs/concepts/dependency-injection) --- **Next Steps:** - Build a real-time application - Explore [Services](/docs/concepts/services) - Learn about [Middleware](/docs/concepts/middleware) --- # Event System Section: Concepts Source: https://asena.sh/raw/concepts/event-system.md # Event System Asena's Event System provides a decoupled, event-driven architecture for your application. It allows different parts of your application to communicate without tight coupling, making your code more maintainable and testable. ## Key Features - **🔥 Fire-and-Forget Pattern** - Events are emitted without waiting for handlers (Spring-like behavior) - **🎯 Wildcard Pattern Matching** - Support for flexible event patterns (`*`, `user.*`, `*.error`) - **🛡️ Error Isolation** - Handler errors don't affect other handlers or the emitter - **⚡ Async Support** - Both sync and async handlers with smart Promise detection - **🔗 IoC Integration** - Full dependency injection support for event services - **📦 Zero Dependencies** - Built with native TypeScript and Bun APIs - **⚙️ High Performance** - Optimized pattern matching and execution --- ## Quick Start ### 1. Create an Event Service ```typescript import { EventService } from '@asenajs/asena/decorators'; import { On } from '@asenajs/asena/event'; @EventService({ prefix: 'user' }) export class UserEventService { @On('created') handleUserCreated(eventName: string, data: any) { console.log('User created:', data); } @On('updated') handleUserUpdated(eventName: string, data: any) { console.log('User updated:', data); } // Wildcard: catches user.login.error, user.register.error, etc. @On('*.error') handleUserError(eventName: string, data: any) { console.error('User error:', eventName, data); } } ``` ### 2. Emit Events from Your Services ```typescript import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { emitter } from '@asenajs/asena/event'; import type { EventEmitter } from '@asenajs/asena/event'; @Service() export class UserService { @Inject(emitter()) private emitter!: EventEmitter; async createUser(name: string) { const user = { id: Math.random(), name }; // Save user to database... // Emit event (fire-and-forget, returns immediately) this.emitter.emit('user.created', user); return user; } } ``` ### 3. That's It! The event system automatically: - Scans for `@EventService` classes during bootstrap - Registers all `@On` handlers - Matches patterns when events are emitted - Executes handlers without blocking ::: tip Handlers run in the background. If a handler throws an error, it won't affect other handlers or your service code. ::: --- ## Core Features ### Fire-and-Forget Pattern Events are emitted **without waiting** for handlers to complete. This is similar to Spring's `@EventListener` behavior. ```typescript @Service() export class OrderService { @Inject(emitter()) private emitter!: EventEmitter; async createOrder(userId: string, items: any[]) { const order = { id: 123, userId, items }; console.log('1. Saving order...'); await this.saveToDatabase(order); console.log('2. Emitting event...'); this.emitter.emit('order.created', order); console.log('3. Event emitted (not waiting)'); console.log('4. Continuing...'); return order; } } @EventService() export class OrderEventService { @On('order.created') async sendConfirmationEmail(eventName: string, data: any) { // This runs in the background await Bun.sleep(3000); console.log('5. Email sent (after 3 seconds)'); } } // Output: // 1. Saving order... // 2. Emitting event... // 3. Event emitted (not waiting) // 4. Continuing... // ... 3 seconds later ... // 5. Email sent (after 3 seconds) ``` ::: info The `emit()` method returns immediately. Async handlers run in the background without blocking your code. ::: --- ### Wildcard Pattern Matching Use wildcards to match multiple events with a single handler: | Pattern | Matches | Examples | |---------|---------|----------| | `*` | All events | Any event | | `user.*` | All user events, at **any** depth | `user.created`, `user.profile.updated` | | `*.error` | All error events, at any depth | `user.error`, `db.pool.error` | | `user.*.created` | Nested patterns | `user.admin.created`, `user.guest.created` | ::: warning `*` matches one **or more** segments A wildcard is not limited to a single segment: `user.*` also matches `user.profile.updated`. It never matches **zero** segments though, so `download.*` does not match the bare event `download`. ::: ```typescript @EventService() export class GlobalEventService { // Log all events @On('*') logAllEvents(eventName: string) { console.log('Event:', eventName); } // Handle all error events @On('*.error') handleAllErrors(eventName: string, data: any) { console.error('Error event:', eventName, data); // Send to error tracking service } // Handle all user-related events @On('user.*') trackUserEvents(eventName: string, data: any) { console.log('User event:', eventName); // Track analytics } } ``` ::: warning Performance Note Wildcard patterns are slower than exact matches. Use them sparingly for cross-cutting concerns like logging and monitoring. ::: --- ### Error Isolation Errors in one handler don't affect other handlers or the emitter: ```typescript @EventService() export class TestService { @On('test.event') handler1() { console.log('Handler 1 - success'); } @On('test.event') handler2() { console.log('Handler 2 - will throw error'); throw new Error('Something went wrong!'); } @On('test.event') handler3() { console.log('Handler 3 - success'); } } // When 'test.event' is emitted: // Handler 1 executes successfully ✓ // Handler 2 throws error (caught and logged) ✗ // Handler 3 executes successfully ✓ ``` ::: tip Errors are automatically caught and logged. Your application continues running normally. ::: --- ## Decorators ### `@EventService(params?)` Marks a class as an event service containing event handlers. **Parameters:** - `params` (optional): Configuration object or prefix string - `prefix?: string` - Prefix for all handlers in this class - `name?: string` - Custom component name (defaults to class name) **Examples:** ```typescript // No prefix @EventService() class GlobalEventService { } // With prefix (object) @EventService({ prefix: 'user' }) class UserEventService { } // With prefix (shorthand) @EventService('order') class OrderEventService { } // With custom name @EventService({ prefix: 'payment', name: 'PaymentEvents' }) class PaymentEventService { } ``` --- ### `@On(params)` Marks a method as an event handler. **Parameters:** - `params`: Event pattern (string shorthand) or configuration object - `event: string` - Event pattern to match - `prefix?: boolean` - Whether the `@EventService` prefix is prepended (default `true`; set `false` for an absolute pattern) - `skip?: boolean` - Skip this handler (useful for debugging) ::: danger One `@On` per method Handler metadata is keyed by **method name**, so stacking several `@On` decorators on one method silently keeps only the last one applied (the topmost decorator). To handle several patterns, write several methods - or use a wildcard. ::: **Method Signature:** ```typescript (eventName: string, data?: any) => void | Promise ``` **Pattern Examples:** ```typescript @EventService({ prefix: 'user' }) class UserEventService { // Exact match: handles 'user.created' @On('created') handleCreated(eventName: string, data: any) { console.log('User created:', data); } // Wildcard: handles all 'user.*' events @On('*') logAllUserEvents(eventName: string, data: any) { console.log('User event:', eventName); } // Nested wildcard: handles 'user.*.error' @On('*.error') handleErrors(eventName: string, data: any) { console.error('Error:', eventName, data); } // Temporarily disabled (for debugging) @On({ event: 'deleted', skip: true }) handleDeleted(eventName: string, data: any) { // Not called } } ``` **Pattern Building with Prefix:** | Prefix | Event Pattern | Final Pattern | |--------|--------------|---------------| | `''` | `user.created` | `user.created` | | `user` | `created` | `user.created` | | `user` | `*.updated` | `user.*.updated` | | `user` | `*` | `user.*` | | `user` | `payment.done` + `prefix: false` | `payment.done` | > **Same rule as microservices:** the prefix is joined onto every `@On` pattern, and a handler opts out with `prefix: false`. Microservice [`@MessageController`](/docs/concepts/microservices) works identically for both `@MessagePattern` and `@EventPattern`, so the intuition carries over in both directions. Use `prefix: false` to listen on an absolute name from inside a prefixed service: ```typescript @EventService({ prefix: 'user' }) export class UserEventService { @On('created') // handles 'user.created' handleCreated(eventName: string, data: any) {} @On({ event: 'payment.completed', prefix: false }) // absolute handlePayment(eventName: string, data: any) {} } ``` --- ### `emitter()` Utility function for injecting EventEmitter. ```typescript import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { emitter } from '@asenajs/asena/event'; import type { EventEmitter } from '@asenajs/asena/event'; @Service() class UserService { @Inject(emitter()) private emitter!: EventEmitter; createUser(name: string) { this.emitter.emit('user.created', { name }); } } ``` --- ### `EventEmitter.emit()` Emits an event to all matching handlers. **Signature:** ```typescript emit(eventName: string, data?: any): boolean ``` **Parameters:** - `eventName: string` - Event name to emit - `data?: any` - Optional data to pass to handlers **Returns:** `boolean` - `true` if any handler matched, `false` otherwise **Examples:** ```typescript @Service() class UserService { @Inject(emitter()) private emitter!: EventEmitter; createUser(name: string) { const user = { id: 123, name }; // Emit with data this.emitter.emit('user.created', user); // Emit without data this.emitter.emit('app.started'); // Check if event was handled if (!this.emitter.emit('user.created', user)) { console.warn('No handlers registered for user.created'); } return user; } } ``` --- ## Event Patterns ### Naming Convention Use **dot-notation** for hierarchical event names: ```typescript // ✅ Good 'user.created' 'user.updated' 'user.deleted' 'order.placed' 'order.payment.completed' 'order.payment.failed' // ❌ Bad 'userCreated' 'user_created' 'CREATE_USER' ``` ### Pattern Organization Group related events using prefixes: ```typescript @EventService({ prefix: 'user' }) class UserEventService { @On('created') onCreated(event: string, data: any) {} // Handles 'user.created' @On('updated') onUpdated(event: string, data: any) {} // Handles 'user.updated' @On('deleted') onDeleted(event: string, data: any) {} // Handles 'user.deleted' } @EventService({ prefix: 'order' }) class OrderEventService { @On('placed') onPlaced(event: string, data: any) {} // Handles 'order.placed' @On('cancelled') onCancelled(event: string, data: any) {} // Handles 'order.cancelled' } ``` ### Wildcard Strategy Use wildcards for cross-cutting concerns: ```typescript @EventService() class MonitoringService { // Global error monitoring @On('*.error') handleAllErrors(eventName: string, data: any) { this.errorTracker.log(eventName, data); } // Critical events only @On('*.critical.*') handleCritical(eventName: string, data: any) { this.alertService.sendAlert(eventName, data); } // Audit logging for specific domain @On('user.*') auditUserEvents(eventName: string, data: any) { this.auditLog.save(eventName, data); } } ``` --- ## Advanced Usage ### Event Chaining Handlers can emit other events, creating event chains: ```typescript @EventService() class OrderEventService { @Inject(emitter()) private emitter!: EventEmitter; @On('order.placed') handleOrderPlaced(eventName: string, data: any) { console.log('Order placed:', data); // Chain: emit payment event this.emitter.emit('payment.process', { orderId: data.id }); } @On('payment.process') async handlePayment(eventName: string, data: any) { console.log('Processing payment:', data.orderId); // Chain: emit inventory event this.emitter.emit('inventory.reserve', data); } @On('inventory.reserve') handleInventory(eventName: string, data: any) { console.log('Reserving inventory:', data.orderId); // Final event this.emitter.emit('notification.send', { message: 'Order confirmed!', }); } } // Flow: order.placed → payment.process → inventory.reserve → notification.send ``` --- ### Multiple Listeners Multiple services can listen to the same event independently: ```typescript @EventService() class EmailService { @On('user.created') sendWelcomeEmail(eventName: string, data: any) { console.log('Sending welcome email to:', data.email); } } @EventService() class AnalyticsService { @On('user.created') trackUserCreation(eventName: string, data: any) { console.log('Tracking user creation:', data.id); } } @EventService() class NotificationService { @On('user.created') sendNotification(eventName: string, data: any) { console.log('Sending notification:', data.name); } } // When 'user.created' is emitted: // → All 3 handlers are called independently // → Errors in one handler don't affect others // → Execution order is NOT guaranteed ``` ::: warning Don't rely on handler execution order. If you need sequential operations, use event chaining instead. ::: --- ### Dependency Injection in Event Services Event services support full dependency injection: ```typescript @Service() class DatabaseService { save(data: any) { console.log('Saving to database:', data); } } @Service() class LoggerService { log(message: string) { console.log('[Logger]', message); } } @EventService({ prefix: 'user' }) class UserEventService { @Inject('DatabaseService') private db!: DatabaseService; @Inject('LoggerService') private logger!: LoggerService; @Inject(emitter()) private emitter!: EventEmitter; @On('created') async handleUserCreated(eventName: string, data: any) { // Use injected dependencies this.logger.log(`User created: ${data.name}`); this.db.save(data); // Emit another event this.emitter.emit('email.send', { to: data.email }); } } ``` --- ## Examples ### User Registration Flow ```typescript @EventService({ prefix: 'user' }) export class UserEventService { @Inject('EmailService') private emailService!: EmailService; @Inject('AnalyticsService') private analytics!: AnalyticsService; @On('registered') async handleUserRegistered(eventName: string, data: any) { console.log('User registered:', data.email); // Send welcome email await this.emailService.sendWelcomeEmail(data.email); // Track analytics this.analytics.track('user_registered', { userId: data.id, source: data.source, }); } } @Service() export class AuthService { @Inject(emitter()) private emitter!: EventEmitter; async register(email: string, password: string) { // Create user const user = await this.createUser(email, password); // Emit event (fire-and-forget) this.emitter.emit('user.registered', { id: user.id, email: user.email, source: 'web', }); return user; } } ``` --- ### Order Processing Pipeline ```typescript @EventService({ prefix: 'order' }) export class OrderEventService { @Inject(emitter()) private emitter!: EventEmitter; @Inject('PaymentService') private payment!: PaymentService; @Inject('InventoryService') private inventory!: InventoryService; @On('placed') handleOrderPlaced(eventName: string, data: any) { console.log('Order placed:', data.orderId); this.emitter.emit('order.payment.process', data); } @On('payment.process') async handlePaymentProcess(eventName: string, data: any) { try { await this.payment.charge(data.orderId); this.emitter.emit('order.payment.success', data); } catch (error) { this.emitter.emit('order.payment.failed', { ...data, error: error.message, }); } } @On('payment.success') handlePaymentSuccess(eventName: string, data: any) { console.log('Payment successful:', data.orderId); this.emitter.emit('order.inventory.reserve', data); } @On('payment.failed') handlePaymentFailed(eventName: string, data: any) { console.error('Payment failed:', data.orderId); this.emitter.emit('notification.send', { type: 'payment_failed', orderId: data.orderId, }); } @On('inventory.reserve') async handleInventoryReserve(eventName: string, data: any) { await this.inventory.reserve(data.orderId); this.emitter.emit('order.completed', data); } @On('completed') handleOrderCompleted(eventName: string, data: any) { console.log('Order completed:', data.orderId); this.emitter.emit('notification.send', { type: 'order_completed', orderId: data.orderId, }); } } // Flow: // order.placed // → order.payment.process // → order.payment.success // → order.inventory.reserve // → order.completed // → notification.send ``` --- ### Error Monitoring ```typescript @EventService() export class ErrorMonitoringService { @Inject('LoggerService') private logger!: LoggerService; @Inject('AlertService') private alerts!: AlertService; // Monitor all error events @On('*.error') handleAllErrors(eventName: string, data: any) { this.logger.error(`Error event: ${eventName}`, data); } // Critical errors only @On('*.critical.error') handleCriticalErrors(eventName: string, data: any) { this.logger.error(`CRITICAL: ${eventName}`, data); // Alert team this.alerts.sendAlert({ level: 'critical', event: eventName, data, }); } // Database errors @On('db.*.error') handleDatabaseErrors(eventName: string, data: any) { this.logger.error(`Database error: ${eventName}`, data); // Track DB error metrics } } ``` --- ### Audit Logging ```typescript @EventService() export class AuditService { @Inject('DatabaseService') private db!: DatabaseService; // Log all user events @On('user.*') logUserEvents(eventName: string, data: any) { this.db.save('audit_logs', { event: eventName, userId: data.id || data.userId, timestamp: new Date(), data, }); } // Log all admin actions @On('admin.*') logAdminActions(eventName: string, data: any) { this.db.save('admin_audit_logs', { event: eventName, adminId: data.adminId, action: eventName.split('.')[1], timestamp: new Date(), data, }); } } ``` --- ### Real-time Notifications ```typescript @EventService() export class NotificationService { @Inject('WebSocketService') private ws!: WebSocketService; @Inject('EmailService') private email!: EmailService; // One @On per method - see the warning under "Handler Registration" @On('user.created') notifyUser(eventName: string, data: any) { // Real-time notification via WebSocket this.ws.sendToUser(data.userId, { type: 'notification', event: eventName, message: this.formatMessage(eventName, data), }); } // Email notifications for critical events @On('*.critical.*') sendEmailNotification(eventName: string, data: any) { this.email.send({ to: data.email || data.userEmail, subject: `Critical Event: ${eventName}`, body: JSON.stringify(data, null, 2), }); } private formatMessage(eventName: string, data: any): string { switch (eventName) { case 'user.created': return 'Welcome to our platform!'; case 'order.completed': return `Order #${data.orderId} completed`; case 'payment.success': return 'Payment successful'; default: return eventName; } } } ``` --- ## Best Practices ### 1. Use Dot-Notation for Event Names ```typescript // ✅ Good 'user.created' 'order.payment.completed' 'notification.sent' // ❌ Bad 'userCreated' 'user_created' 'USER_CREATED' ``` ### 2. Organize Events by Domain ```typescript // ✅ Good: Organized by domain with prefix @EventService({ prefix: 'user' }) class UserEventService { } @EventService({ prefix: 'order' }) class OrderEventService { } @EventService({ prefix: 'payment' }) class PaymentEventService { } ``` ### 3. Keep Handlers Lightweight Event handlers should be fast and non-blocking: ```typescript // ✅ Good - Quick operation @On('user.created') handleUserCreated(eventName: string, data: any) { this.logger.log('User created:', data.id); this.emitter.emit('email.send', data); } // ❌ Bad - Slow blocking operation @On('user.created') async handleUserCreated(eventName: string, data: any) { // Don't do heavy work in event handlers await this.sendEmail(data.email); await this.generateReport(data.id); await this.updateAnalytics(data); } // ✅ Better - Emit separate events for heavy work @On('user.created') handleUserCreated(eventName: string, data: any) { this.emitter.emit('email.send', data); this.emitter.emit('report.generate', data); this.emitter.emit('analytics.update', data); } ``` ### 4. Use Wildcards Sparingly Wildcard patterns are slower than exact matches: ```typescript // ✅ Good - Specific patterns, one per method @On('user.created') onCreated(event: string, data: any) {} @On('user.updated') onUpdated(event: string, data: any) {} // ⚠️ Use with caution - Matches all user events @On('user.*') onAnyUserEvent(event: string, data: any) {} // ⚠️ Use with extreme caution - Matches ALL events @On('*') ``` ::: warning Only use wildcards for cross-cutting concerns like logging, monitoring, and error handling. ::: ### 5. Don't Rely on Handler Order Handler execution order is not guaranteed: ```typescript // ❌ Bad - Assuming order @On('user.created') handler1() { /* Step 1 */ } @On('user.created') handler2() { /* Step 2 - assumes handler1 completed */ } // ✅ Good - Use event chaining for ordered operations @On('user.created') handler1(eventName: string, data: any) { // Do step 1 this.emitter.emit('user.created.step2', data); } @On('user.created.step2') handler2(eventName: string, data: any) { // Do step 2 } ``` ### 6. Return Values are Ignored ```typescript // ⚠️ Return value is ignored @On('user.created') handleUserCreated(eventName: string, data: any) { return { success: true }; // This is ignored } // ✅ Use events or service return values for responses @Service() class UserService { @Inject(emitter()) private emitter!: EventEmitter; async createUser(name: string) { const user = { id: 123, name }; // Emit event this.emitter.emit('user.created', user); // Return value to caller return user; } } ``` ### 7. Use Descriptive Event Data Pass meaningful data to handlers: ```typescript // ✅ Good - Clear, descriptive data this.emitter.emit('user.created', { id: user.id, name: user.name, email: user.email, createdAt: new Date(), }); // ❌ Bad - Unclear data this.emitter.emit('user.created', user.id); // ❌ Bad - Too much data this.emitter.emit('user.created', { ...user, ...user.profile, ...user.settings, database: this.db, // Never pass services }); ``` --- ## Troubleshooting ### Events Not Being Handled **Check:** 1. Is your class decorated with `@EventService()`? 2. Are your methods decorated with `@On()`? 3. Is the pattern correct? (exact match or wildcard) 4. Is `skip: true` set on the handler? 5. Check console for errors during handler execution ### Performance Issues **Solutions:** 1. Use exact patterns instead of wildcards when possible 2. Keep handlers lightweight 3. Avoid heavy computations in handlers 4. Use event chaining for complex workflows ### Events Firing Out of Order **Remember:** Handler execution order is not guaranteed. Use event chaining for ordered operations. ### Async Handlers Not Completing **Remember:** Fire-and-forget pattern means `emit()` doesn't wait for async handlers. This is by design. --- ## Related - [Dependency Injection](/docs/concepts/dependency-injection.md) - Understanding DI in Asena - [Inheritance](/docs/concepts/inheritance.md) - Sharing `@On` handlers through a base class - [Services](/docs/concepts/services.md) - Creating services - [Ulak](/docs/concepts/ulak.md) - WebSocket messaging system --- # Microservices Section: Concepts Source: https://asena.sh/raw/concepts/microservices.md # Microservices Asena's microservice layer lets independent Asena services communicate through a message broker — supporting both **orchestration** (request/response) and **choreography** (fire-and-forget events). The transport is pluggable: the core defines a broker-agnostic SPI, and transport packages ([Redis Streams](/docs/packages/redis) and [Kafka](/docs/packages/kafka) today, more in the future) implement it. ## Key Features - **🎯 Pattern-Based Handlers** - `@MessagePattern` (RPC) and `@EventPattern` (events) on `@MessageController` classes - **🔌 Broker-Agnostic SPI** - Swap transports without touching business logic - **📮 Ulak Client API** - `ulak.messages('order')` scoped injection, `send()`/`emit()` everywhere - **🕶️ Headless Mode** - Run a service without an HTTP server, driven purely by messages - **🏭 Production Semantics** - At-least-once events with retry + DLQ, graceful drain, reconnect (Redis Streams, Kafka) - **🧵 Multi-Broker Support** - Bind different controllers to different named transports - **🔭 Distributed Tracing** - OpenTelemetry propagation via messaging interceptors - **📦 Zero Dependencies** - The core SPI is plain TypeScript; brokers live in separate packages --- ## Quick Start ### 1. Create a Message Controller ```typescript import { MessageController } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { MessagePattern, EventPattern, type MessageContext } from '@asenajs/asena/microservice'; @MessageController('order') // prefix - applied to every handler below export class OrderHandler { @Inject('OrderService') private orderService: OrderService; @Inject('SearchIndexService') private searchIndex: SearchIndexService; // Request/Response (orchestration): return value is the reply @MessagePattern('create') // handles 'order.create' async create(data: CreateOrderDto, context: MessageContext) { return await this.orderService.create(data); } // Fire-and-forget event (choreography): wildcards allowed @EventPattern('created') // handles 'order.created' async onCreated(event: OrderEvent, context: MessageContext) { await this.searchIndex.update(event); } // Another service's event vocabulary - opt out of the prefix @EventPattern({ pattern: 'payment.completed', prefix: false }) async onPaymentCompleted(event: PaymentEvent, context: MessageContext) { await this.orderService.markPaid(event.orderId); } } ``` > **Prefix rule:** the controller prefix is joined onto **every** `@MessagePattern` and `@EventPattern` in the class (`order` + `create` → `order.create`, `order` + `created` → `order.created`). A handler opts out with `prefix: false` and is then registered verbatim. This is the same rule the client side uses: `ulak.messages('order').emit('created')` publishes `order.created`, and `@MessageController('order') @EventPattern('created')` subscribes to `order.created`. Sender and listener always agree. Use `prefix: false` when the name is **not yours**: another service's events, a [Kafka external topic](/docs/packages/kafka#external-topics-interop), or a global catch-all. ::: warning Migrating from 0.7 In 0.7 the prefix was applied to `@MessagePattern` only, and `@EventPattern` was always absolute. Every `@EventPattern` on a **prefixed** controller now changes its subscription name and must either be rewritten relative to the prefix or given `prefix: false`. - `@MessageController('order') @EventPattern('payment.completed')` → now subscribes to `order.payment.completed`. Add `prefix: false` to keep the old behavior. - `@EventPattern('*')` under a prefix is **no longer a global catch-all** — it becomes `order.*`. Audit and logging listeners need `prefix: false`. - A wildcard controller prefix (`@MessageController('order.*')`) is now a boot error. - Controllers with no prefix are unaffected. Asena prints the resolved patterns for every controller at boot, so you can see exactly what each handler subscribes to: ``` [Microservice] OrderHandler → "default" (prefix "order") msg: order.create | evt: order.created, payment.completed ``` ::: ### 2. Configure a Transport ```typescript import { Config } from '@asenajs/asena/decorators'; import { RedisMicroserviceTransport } from '@asenajs/asena-redis'; @Config() export class AppConfig extends ConfigService { transport() { return { microservice: new RedisMicroserviceTransport( { url: 'redis://localhost:6379' }, { serviceName: 'order-service' }, // serviceName REQUIRED: consumer group identity ), }; } } ``` The same shape works for [Kafka](/docs/packages/kafka) — only the transport line changes: ```typescript import { KafkaMicroserviceTransport } from '@asenajs/asena-kafka'; microservice: new KafkaMicroserviceTransport( { brokers: ['localhost:9092'] }, { serviceName: 'order-service' }, ), ``` ### 3. Send Messages from Any Service ```typescript import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { ulak, type Ulak } from '@asenajs/asena/messaging'; @Service() export class CheckoutService { @Inject(ulak.messages('order')) private orders: Ulak.Messages<'order'>; async checkout(dto: CheckoutDto) { // RPC - awaits the remote handler's reply // `send` defaults to `unknown` - name the reply type to use it const order = await this.orders.send<{ id: string }>('create', dto); // → 'order.create' // Event - fire-and-forget await this.orders.emit('created', { id: order.id }); // → 'order.created' } } ``` The full `Ulak` instance can also be injected for absolute patterns: ```typescript @Inject(ICoreServiceNames.__ULAK__) private ulak: Ulak; await this.ulak.send('inventory.reserve', { sku: 'A1' }); await this.ulak.emit('audit.recorded', entry); ``` --- ## Headless Mode An Asena service does not need an HTTP server. Omit the adapter and the service lives purely on microservice messages, events and schedules: ```typescript import { AsenaServerFactory } from '@asenajs/asena'; const server = await AsenaServerFactory.create({ headless: true, // explicit opt-in - omitting the adapter alone throws logger, health: { port: 9090 }, // optional K8s probe endpoint }); await server.start(); // no HTTP port is opened ``` What still runs in headless mode: configs, the microservice layer, the in-process event system, scheduled tasks. HTTP-only components (`@Controller`, `@WebSocket`, `@FrontendController`) are ignored with a warning. ### Health Endpoint With `health: { port }`, a minimal `Bun.serve` endpoint reports process liveness and per-transport connection state — built for Kubernetes probes: ```jsonc // GET :9090/healthz → 200 while all transports are connected { "status": "up", "uptime": 123, "transports": { "default": "connected" } } // → 503 with "status": "degraded" when any transport is disconnected ``` --- ## Delivery Semantics Understanding the guarantees is essential for production use. With the broker transports: | Aspect | Events (`@EventPattern`) | RPC (`@MessagePattern`) | |---|---|---| | Guarantee | **At-least-once** (consumer groups + ack/commit) | At-most-once per attempt | | Handler error | No ack → broker redelivers up to `maxRetries`, then **DLQ** | **Final**: `ok:false` reply + ack — no broker retry | | Service offline | Messages wait in the stream/topic | Caller times out (`UlakErrorCode.TIMEOUT`) | | Crash mid-handling | Another replica takes over (Redis: XCLAIM; Kafka: rebalance) | Request rescued only if the caller hasn't timed out | Broker-specific mechanics (retry latency, ordering, offline tolerance) are documented on each transport's package page: [Redis](/docs/packages/redis#delivery-guarantees), [Kafka](/docs/packages/kafka#delivery-guarantees). ### Idempotent Handlers At-least-once means **duplicate delivery is normal** — a crash after handling but before ACK causes redelivery. Event handlers must be idempotent. Use `context.messageId` (stable across redeliveries) for deduplication: ```typescript @EventPattern('payment.completed') async onPayment(event: PaymentEvent, context: MessageContext) { if (await this.processedStore.has(context.messageId)) return; // duplicate await this.orderService.markPaid(event.orderId); await this.processedStore.add(context.messageId); } ``` `context.attempt > 1` tells you the delivery is a retry. ### Operational Rules - **Handler duration must stay below `claimIdleMs`** (default 60s) — otherwise the sweep assumes the replica crashed and a second replica processes the same entry concurrently. - **`maxStreamLength` bounds memory but also offline tolerance** — events older than the trim window are lost for services that stay down too long. Size it against your worst-case deployment gap. - **Monitor the DLQ stream** (`asena:ms:dlq`) — poison events land there after `maxRetries`, with `origin_stream`, `origin_group` and `delivery_count` fields. - **`@PostConstruct` cannot send messages** — transports are wired during application setup (after component init), so `ulak.send/emit` in `@PostConstruct` throws `NO_TRANSPORT`. --- ## Multiple Named Transports Different parts of one project can use different brokers: ```typescript transport() { return { microservice: { default: new RedisMicroserviceTransport({ url }, { serviceName: 'order-service' }), analytics: new KafkaMicroserviceTransport({ brokers }, { serviceName: 'order-service' }), }, }; } ``` ```typescript // Handler side - bind the controller to a named transport @MessageController({ prefix: 'metrics', transport: 'analytics' }) export class AnalyticsHandler { ... } // Client side - select the transport in the injection helper @Inject(ulak.messages('metrics', { transport: 'analytics' })) private metrics: Ulak.Messages<'metrics'>; ``` A single (unnamed) transport is registered under the name `default`. Binding a controller to an unknown transport name fails at boot with the list of configured names. A proven real-world shape for this is the **cross-broker bridge**: keep Redis as `default` so every existing prefix-only controller works unchanged, add Kafka as a named transport, and let one deliberately transport-aware controller consume Kafka-side events and re-emit them onto the default side via `Ulak` (headers copied along, so tracing metadata survives the hop). Services without Kafka handlers boot their Kafka transport client-only — zero handlers means no consumer group. The health endpoint reports each named transport separately (`transports: { default: 'connected', kafka: 'connected' }`), so a partial broker outage degrades to 503 while the other broker keeps flowing. See the [Kafka package docs](/docs/packages/kafka) for the Kafka side of such a setup. --- ## Graceful Shutdown `server.stop()` drains the microservice layer before closing: 1. Consuming stops (no new messages are read) 2. In-flight handlers get `drainTimeout` (default 10s) to finish — completed ones ACK, unfinished ones stay pending for another replica 3. Pending `send()` calls are rejected 4. The consumer's group registration is removed **only if it has no pending entries** — otherwise the entries stay pending for another replica's sweep to claim, and the leftover consumer name is garbage-collected by the sweep once drained 5. Connections close > **`handlerTimeout` is not cancellation:** when a handler exceeds `handlerTimeout`, the dispatch is rejected (the entry stays un-ACKed for redelivery) but the handler function itself keeps running until it settles on its own. --- ## InMemoryTransport (Development & Testing) The core ships a zero-dependency loopback transport — ideal for development before a broker exists and for integration tests: ```typescript import { InMemoryTransport } from '@asenajs/asena/microservice'; @Config() export class DevConfig extends ConfigService { transport() { return { microservice: new InMemoryTransport() }; } } ``` Messages never leave the process; handlers registered in the same app receive them synchronously. > **Behavioral difference from broker transports:** in-memory `emit()` awaits its handlers, so when it returns the handlers have already run. With Redis, delivery is asynchronous — a test that relies on the event being handled "immediately after emit" passes in development but races in production. Await an observable effect instead of the emit itself. All handlers of one emit observe the **same** `messageId`, matching Redis semantics. --- ## Distributed Tracing Wire OpenTelemetry through messaging interceptors — producer and consumer spans join into one distributed trace across services: ```typescript import { otelMessaging } from '@asenajs/asena-otel'; transport() { return { microservice: new RedisMicroserviceTransport({ url }, { serviceName: 'order-service' }), interceptors: [otelMessaging({ system: 'redis' })], }; } ``` See the [OpenTelemetry package docs](/docs/packages/opentelemetry) for details. --- ## Error Handling Microservice errors surface as `UlakError` with structured codes: | Code | Meaning | |---|---| | `NO_TRANSPORT` | `send`/`emit` called but no microservice transport configured | | `TRANSPORT_NOT_FOUND` | Named transport does not exist (message lists available names) | | `TIMEOUT` | No reply arrived within the caller's timeout (`send`) | | `REMOTE_ERROR` | The remote handler threw **or exceeded `handlerTimeout`** — the responder replies `ok: false`, so the caller sees `REMOTE_ERROR`, not `TIMEOUT` | | `SEND_FAILED` | Broker publish failed / no handler registered (InMemoryTransport) | ```typescript import { UlakError, UlakErrorCode } from '@asenajs/asena/messaging'; try { const res = await this.orders.send('create', dto, { timeout: 5000 }); } catch (error) { if (error instanceof UlakError && error.code === UlakErrorCode.TIMEOUT) { // decide: retry (make sure the handler is idempotent!) or fail the request } } ``` --- ## Writing a Custom Transport Implement the `MicroserviceTransport` SPI to integrate any broker: ```typescript import type { MicroserviceTransport } from '@asenajs/asena/microservice'; export class MyBrokerTransport implements MicroserviceTransport { readonly name = 'my-broker'; get isConnected(): boolean { ... } async init() { ... } // connect; send/emit usable after this registerMessageHandler(pattern, handler) { ... } // exact patterns (RPC) registerEventHandler(pattern, handler) { ... } // absolute patterns, wildcards allowed async listen() { ... } // consume ONLY sources with handlers async send(pattern, data?, options?) { ... } // request/response with correlation async emit(pattern, data?, options?) { ... } // fire-and-forget async destroy(options?) { ... } // graceful drain, then release } ``` Contract notes: - `init()` must make `send`/`emit` usable **before** `listen()` (client-only mode) - With zero registered handlers, `listen()` must not start any consumer - Wildcard matching can reuse `matchesEventPattern` from `@asenajs/asena/event` --- ## Comparison with the In-Process Event System | | [Event System](/docs/concepts/event-system) | Microservices | |---|---|---| | Scope | Single process | Across services | | Delivery | Synchronous dispatch, no persistence | Broker-backed, at-least-once (events) | | API | `EventEmitter.emit()` / `@On` | `ulak.send/emit` / `@MessagePattern` / `@EventPattern` | | Use for | Domain events inside one app | Service-to-service communication | They are deliberately separate: hiding the local-vs-remote distinction creates false expectations about delivery guarantees. ## Related - [Inheritance](/docs/concepts/inheritance) - Sharing `@MessagePattern` and `@EventPattern` handlers through a base class, and why an inherited `@EventPattern` opens a real subscription --- # Ulak - WebSocket Messaging System Section: Concepts Source: https://asena.sh/raw/concepts/ulak.md # Ulak - WebSocket Messaging System **Ulak** (Turkish: Messenger/Courier) is Asena's centralized WebSocket message broker that solves the circular dependency problem between services and WebSocket handlers. It provides a unified API for sending messages to WebSocket clients from anywhere in your application. ::: tip Ulak Also Speaks Microservice Beyond WebSockets, Ulak is the client API for Asena's [microservice layer](/docs/concepts/microservices): `ulak.send()`/`ulak.emit()` reach other services through the configured transport, and `@Inject(ulak.messages('order'))` injects a pattern-scoped messaging view. The WebSocket API on this page is unchanged. ::: ## The Problem In traditional architectures, you might face circular dependency issues when: 1. WebSocket handlers need to inject domain services for business logic 2. Domain services need to inject WebSocket handlers to send real-time updates ```typescript // ❌ This creates a circular dependency @WebSocket('/notifications') export class NotificationWebSocket extends AsenaWebSocketService<{}> { @Inject(UserService) // WebSocket needs service private userService: UserService; } @Service('UserService') export class UserService { @Inject(NotificationWebSocket) // ❌ Service needs WebSocket - CIRCULAR! private notificationWs: NotificationWebSocket; } ``` ## The Solution Ulak acts as a mediator between your services and WebSocket connections: ``` ┌───────────────────────────────────────────┐ │ Application Layer │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ Service1 │ │ Service2 │ │ Service3 │ │ │ │ Inject │ │ Inject │ │ Inject │ │ │ │ Ulak │ │ Ulak │ │ Ulak │ │ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │ └────────────┬┴──────────────┘ │ └────────────────────┼──────────────────────┘ ▼ ┌─────────────────┐ │ Ulak │ │ (Message Broker)│ └────────┬────────┘ │ ┌───────────┼───────────┐ ▼ ▼ ▼ WebSocket1 WebSocket2 WebSocket3 ``` ## Getting Started ### Basic Usage Inject a scoped Ulak namespace in your service: ```typescript import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { ulak, type Ulak } from '@asenajs/asena/messaging'; @Service('UserService') export class UserService { // Inject scoped namespace - most ergonomic API @Inject(ulak('/notifications')) private notifications: Ulak.NameSpace<'/notifications'>; async createUser(name: string, email: string) { const user = await this.saveUser(name, email); // Broadcast to all connected clients await this.notifications.broadcast({ type: 'user_created', user }); return user; } async notifyUser(userId: string, message: string) { // Send to specific room await this.notifications.to(`user:${userId}`, { type: 'notification', message }); } private async saveUser(name: string, email: string) { // Database logic return { id: '123', name, email }; } } ``` ### WebSocket Handler Your WebSocket handlers continue to work as before: ```typescript import { WebSocket } from '@asenajs/asena/decorators'; import { AsenaWebSocketService, type Socket } from '@asenajs/asena/web-socket'; import { Inject } from '@asenajs/asena/decorators/ioc'; @WebSocket('/notifications') export class NotificationWebSocket extends AsenaWebSocketService<{ userId: string }> { // ✅ No circular dependency! @Inject(UserService) private userService: UserService; protected async onOpen(socket: Socket<{ userId: string }>) { // Subscribe user to their personal room socket.subscribe(`user:${socket.data.values.userId}`); console.log(`User ${socket.data.values.userId} connected`); } protected async onMessage(socket: Socket<{ userId: string }>, message: string) { const data = JSON.parse(message); // Use service for business logic await this.userService.handleNotification(socket.data.values.userId, data); } } ``` ## Three Ways to Use Ulak ### 1. Scoped Namespace (Recommended) The most ergonomic API - no namespace repetition: ```typescript import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { ulak, type Ulak } from '@asenajs/asena/messaging'; @Service('ChatService') export class ChatService { // ✅ Recommended: Clean, type-safe, no repetition @Inject(ulak('/chat')) private chat: Ulak.NameSpace<'/chat'>; async sendMessage(roomId: string, message: string) { // No need to specify namespace again await this.chat.to(roomId, { message }); } async broadcastAnnouncement(text: string) { await this.chat.broadcast({ type: 'announcement', text }); } } ``` ### 2. Expression-Based Injection For advanced scenarios with transformations: ```typescript import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { type Ulak } from '@asenajs/asena/messaging'; import { ICoreServiceNames } from '@asenajs/asena/ioc/types'; @Service('NotificationService') export class NotificationService { // ✅ Good: Explicit transformation @Inject(ICoreServiceNames.__ULAK__, (ulak: Ulak) => ulak.namespace('/notifications')) private notifications: Ulak.NameSpace<'/notifications'>; async notifyUser(userId: string, data: any) { await this.notifications.to(`user:${userId}`, data); } } ``` ### 3. Direct Ulak Injection For working with multiple or dynamic namespaces: ```typescript import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { type Ulak } from '@asenajs/asena/messaging'; import { ICoreServiceNames } from '@asenajs/asena/ioc/types'; @Service('MultiChannelService') export class MultiChannelService { // ✅ Fallback: Useful for multiple namespaces @Inject(ICoreServiceNames.__ULAK__) private ulak: Ulak; async broadcastToAll(message: string) { // Must specify namespace each time await this.ulak.broadcast('/notifications', { message }); await this.ulak.broadcast('/chat', { message }); await this.ulak.broadcast('/dashboard', { message }); } } ``` ### Multiple Scoped Namespaces When you need to work with multiple namespaces regularly: ```typescript import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { ulak, type Ulak } from '@asenajs/asena/messaging'; @Service('AdminService') export class AdminService { @Inject(ulak('/notifications')) private notifications: Ulak.NameSpace<'/notifications'>; @Inject(ulak('/admin-alerts')) private adminAlerts: Ulak.NameSpace<'/admin-alerts'>; @Inject(ulak('/metrics')) private metrics: Ulak.NameSpace<'/metrics'>; async broadcastSystemMessage(message: string) { // Each namespace has its own clean API await this.notifications.broadcast({ type: 'system', message }); await this.adminAlerts.broadcast({ type: 'system_broadcast', message }); } async publishMetric(metric: any) { await this.metrics.broadcast(metric); } } ``` ## API Reference ### Ulak Core API #### `broadcast(namespace: string, data: any): Promise` Broadcast message to all clients in a namespace: ```typescript await ulak.broadcast('/chat', { type: 'announcement', text: 'Server maintenance in 5 minutes' }); ``` #### `to(namespace: string, room: string, data: any): Promise` Send message to specific room: ```typescript await ulak.to('/chat', 'room-123', { type: 'message', text: 'Hello room!' }); ``` #### `toSocket(namespace: string, socketId: string, data: any): Promise` Send message to specific socket: ```typescript await ulak.toSocket('/notifications', 'socket-abc', { type: 'direct_message', text: 'Hello!' }); ``` #### `toMany(namespace: string, rooms: string[], data: any): Promise` Send to multiple rooms at once (parallel execution): ```typescript await ulak.toMany('/chat', ['room-1', 'room-2', 'room-3'], { type: 'update', data: { count: 42 } }); ``` ::: warning `toMany()` and `broadcastAll()` never reject Both use `Promise.allSettled` internally and only log the failures they see, so wrapping them in `try/catch` catches nothing. `bulkSend()` behaves the same way - it resolves with a `BulkResult` count instead of throwing. The single-target methods (`broadcast`, `to`, `toSocket`) **do** throw `UlakError`. ::: #### `broadcastAll(data: any): Promise` Broadcast to all namespaces: ```typescript await ulak.broadcastAll({ type: 'system_shutdown', message: 'Server shutting down' }); ``` #### `bulkSend(operations: BulkOperation[]): Promise` Execute multiple operations in bulk: ```typescript const result = await ulak.bulkSend([ { type: 'broadcast', namespace: '/chat', data: { msg: '1' } }, { type: 'room', namespace: '/chat', room: 'room-1', data: { msg: '2' } }, { type: 'socket', namespace: '/chat', socketId: 'socket-1', data: { msg: '3' } } ]); console.log(`${result.succeeded} succeeded, ${result.failed} failed`); ``` #### `namespace(path: T): Ulak.NameSpace` Get scoped namespace interface: ```typescript const chat = ulak.namespace('/chat'); await chat.broadcast({ message: 'Hello' }); ``` #### `getNamespaces(): string[]` Get all registered namespace paths: ```typescript const namespaces = ulak.getNamespaces(); // ['/chat', '/notifications', '/dashboard'] ``` #### `hasNamespace(namespace: string): boolean` Check if namespace exists: ```typescript if (ulak.hasNamespace('/chat')) { await ulak.broadcast('/chat', { message: 'Hello' }); } ``` #### `getSocketCount(namespace: string): number` Get active socket count for namespace: ```typescript const count = ulak.getSocketCount('/chat'); console.log(`${count} clients connected to chat`); ``` ### Scoped Namespace API When using `ulak()` helper or `namespace()` method, you get a scoped API: #### `broadcast(data: any): Promise` ```typescript await chat.broadcast({ message: 'Hello everyone!' }); ``` #### `to(room: string, data: any): Promise` ```typescript await chat.to('room-123', { message: 'Hello room!' }); ``` #### `toSocket(socketId: string, data: any): Promise` ```typescript await chat.toSocket('socket-abc', { message: 'Hello!' }); ``` #### `toMany(rooms: string[], data: any): Promise` ```typescript await chat.toMany(['room-1', 'room-2'], { update: true }); ``` #### `getSocketCount(): number` ```typescript const count = chat.getSocketCount(); ``` #### `path: string` ```typescript console.log(chat.path); // '/chat' ``` ## Error Handling Ulak throws structured errors with specific error codes: ```typescript import { UlakError, UlakErrorCode } from '@asenajs/asena/messaging'; try { await ulak.broadcast('/non-existent', { message: 'test' }); } catch (error) { if (error instanceof UlakError) { console.error(`Error code: ${error.code}`); console.error(`Namespace: ${error.namespace}`); console.error(`Message: ${error.message}`); console.error(`Cause:`, error.cause); } } ``` ### Error Codes - `NAMESPACE_NOT_FOUND` - Namespace doesn't exist - `NAMESPACE_ALREADY_EXISTS` - Namespace already registered - `INVALID_NAMESPACE` - Invalid namespace format - `INVALID_MESSAGE` - Invalid message data - `SEND_FAILED` - Failed to send message - `BROADCAST_FAILED` - Failed to broadcast - `SOCKET_NOT_FOUND` - Socket ID not found - `SERVICE_NOT_INITIALIZED` - Ulak not initialized Microservice messaging (`send()` / `emit()` / `messages()`) adds four more: - `NO_TRANSPORT` - No microservice transport is configured - `TRANSPORT_NOT_FOUND` - The named transport does not exist - `TIMEOUT` - An RPC `send()` exceeded its reply timeout - `REMOTE_ERROR` - The remote handler answered with an error ### Error Handling Best Practices ```typescript @Service('ChatService') export class ChatService { @Inject(ulak('/chat')) private chat: Ulak.NameSpace<'/chat'>; async sendMessage(roomId: string, message: string) { try { await this.chat.to(roomId, { message }); } catch (error) { if (error instanceof UlakError) { if (error.code === UlakErrorCode.NAMESPACE_NOT_FOUND) { console.error('Chat namespace not registered'); } else if (error.code === UlakErrorCode.SEND_FAILED) { console.error('Failed to send message, retrying...'); // Implement retry logic } } throw error; // Re-throw for logging } } } ``` ## Lifecycle Management ### Unregistering Namespaces Clean up when a namespace is no longer needed: ```typescript ulak.unregisterNamespace('/old-chat'); ``` ### Disposing Ulak `dispose()` clears every registered namespace and drops the transport reference: ```typescript ulak.dispose(); ``` ::: warning It is not called for you `AsenaServer.stop()` stops the cron runner, the health server, the adapter and the microservice transports - it does **not** call `ulak.dispose()`. Call it yourself if you need the cleanup (long-lived test processes, hot-reload loops); a normal process exit does not need it. ::: ## Best Practices ### 1. Use Scoped Namespaces Prefer `ulak()` helper for cleaner code: ```typescript // ✅ Recommended @Inject(ulak('/chat')) private chat: Ulak.NameSpace<'/chat'>; // ❌ Avoid repetition @Inject(ICoreServiceNames.__ULAK__) private ulak: Ulak; await this.ulak.broadcast('/chat', data); // Repetitive ``` ### 2. Handle Errors Gracefully Always wrap Ulak calls in try-catch: ```typescript async notifyUser(userId: string, data: any) { try { await this.notifications.to(`user:${userId}`, data); } catch (error) { this.logger.error('Failed to notify user', { userId, error }); // Don't let notification failures crash the application } } ``` ### 3. Use Batch Operations For multiple operations, use batch methods: ```typescript // ✅ Good: Parallel execution await this.chat.toMany(['room-1', 'room-2', 'room-3'], data); // ❌ Bad: Sequential execution await this.chat.to('room-1', data); await this.chat.to('room-2', data); await this.chat.to('room-3', data); ``` ### 5. Type Your Messages Use TypeScript interfaces for message types: ```typescript import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { ulak, type Ulak } from '@asenajs/asena/messaging'; interface ChatMessage { type: 'message' | 'announcement' | 'system'; text: string; userId?: string; timestamp: number; } @Service('ChatService') export class ChatService { @Inject(ulak('/chat')) private chat: Ulak.NameSpace<'/chat'>; async sendMessage(room: string, message: ChatMessage) { await this.chat.to(room, message); } } ``` ## Testing Services with `ulak()` injections are testable with [`mockComponent`](/docs/testing/mock-component) — no running WebSocket broker required. The injected namespace is replaced by a deep mock whose methods are assertable Bun mocks: ```typescript import { expect, test } from 'bun:test'; import { mockComponent } from '@asenajs/asena/test'; test('sendMessage targets the room', async () => { const { instance, mocks } = mockComponent(ChatService); await instance.sendMessage('room-1', { type: 'message', text: 'Hi', timestamp: 0 }); expect(mocks.chat.to).toHaveBeenCalledWith('room-1', { type: 'message', text: 'Hi', timestamp: 0 }); }); ``` For a fully typed override, use `createTestUlakStub`: ```typescript import { createTestUlakStub, mockComponent } from '@asenajs/asena/test'; const chat = createTestUlakStub('/chat'); const { instance } = mockComponent(ChatService, { overrides: { chat } }); ``` See [Testing Services with Ulak Injections](/docs/testing/mock-component#testing-services-with-ulak-injections) for the full guide. ## Advanced Examples ### Real-Time Notifications System ```typescript import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { ulak, type Ulak } from '@asenajs/asena/messaging'; @Service('NotificationService') export class NotificationService { @Inject(ulak('/notifications')) private notifications: Ulak.NameSpace<'/notifications'>; async notifyUserFollowers(userId: string, event: string) { const followers = await this.getFollowers(userId); // Send to multiple users at once await this.notifications.toMany( followers.map(f => `user:${f.id}`), { type: 'follower_activity', event, userId } ); } async notifyAdmins(alert: any) { await this.notifications.to('admin-room', { type: 'admin_alert', ...alert, timestamp: Date.now() }); } private async getFollowers(userId: string): Promise<{ id: string }[]> { // Database logic return []; } } ``` ### Multi-Namespace Dashboard ```typescript import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { ulak, type Ulak } from '@asenajs/asena/messaging'; @Service('DashboardService') export class DashboardService { @Inject(ulak('/dashboard')) private dashboard: Ulak.NameSpace<'/dashboard'>; @Inject(ulak('/metrics')) private metrics: Ulak.NameSpace<'/metrics'>; async updateDashboard(data: any) { // Update dashboard await this.dashboard.broadcast({ type: 'dashboard_update', data }); // Track metrics await this.metrics.broadcast({ type: 'metric', name: 'dashboard_update', value: 1, timestamp: Date.now() }); } } ``` ### Background Job Notifications ```typescript import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { ulak, type Ulak } from '@asenajs/asena/messaging'; @Service('JobService') export class JobService { @Inject(ulak('/jobs')) private jobs: Ulak.NameSpace<'/jobs'>; async runLongJob(userId: string) { const jobId = crypto.randomUUID(); // Notify job started await this.jobs.to(`user:${userId}`, { type: 'job_started', jobId }); try { // Run long operation await this.performLongOperation(); // Notify completion await this.jobs.to(`user:${userId}`, { type: 'job_completed', jobId, result: 'success' }); } catch (error) { // Notify failure await this.jobs.to(`user:${userId}`, { type: 'job_failed', jobId, error: error.message }); } } private async performLongOperation() { // Long-running task } } ``` ## Migration Guide ### From Direct WebSocket Injection If you were trying to inject WebSocket services directly (causing circular dependencies): **Before:** ```typescript @Service('UserService') export class UserService { @Inject(NotificationWebSocket) // ❌ Circular dependency private notificationWs: NotificationWebSocket; async createUser(name: string) { const user = await this.saveUser(name); this.notificationWs.in({ type: 'user_created', user }); } } ``` **After:** ```typescript @Service('UserService') export class UserService { @Inject(ulak('/notifications')) // ✅ No circular dependency private notifications: Ulak.NameSpace<'/notifications'>; async createUser(name: string) { const user = await this.saveUser(name); await this.notifications.broadcast({ type: 'user_created', user }); } } ``` ## Type Definitions ```typescript // Ulak core class class Ulak { broadcast(namespace: string, data: any): Promise; to(namespace: string, room: string, data: any): Promise; toSocket(namespace: string, socketId: string, data: any): Promise; toMany(namespace: string, rooms: string[], data: any): Promise; broadcastAll(data: any): Promise; bulkSend(operations: BulkOperation[]): Promise; namespace(path: T): Ulak.NameSpace; getNamespaces(): string[]; hasNamespace(namespace: string): boolean; getSocketCount(namespace: string): number; unregisterNamespace(path: string): void; dispose(): void; } // Scoped namespace interface namespace Ulak { interface NameSpace { readonly path: T; broadcast(data: any): Promise; to(room: string, data: any): Promise; toSocket(socketId: string, data: any): Promise; toMany(rooms: string[], data: any): Promise; getSocketCount(): number; } } // Helper function function ulak(namespace: T): readonly [string, (ulak: Ulak) => Ulak.NameSpace]; // Error class class UlakError extends Error { code: UlakErrorCode; namespace?: string; cause?: Error; } // Bulk operation types type BulkOperationType = 'broadcast' | 'room' | 'socket'; interface BulkOperation { type: BulkOperationType; namespace: string; room?: string; socketId?: string; data: any; } interface BulkResult { total: number; succeeded: number; failed: number; results: PromiseSettledResult[]; } ``` ## See Also - [WebSocket](/docs/concepts/websocket.md) - Basic WebSocket usage - [Microservices](/docs/concepts/microservices.md) - Ulak's send/emit API for service-to-service messaging - [Dependency Injection](/docs/concepts/dependency-injection.md) - Understanding DI in Asena - [Services](/docs/concepts/services.md) - Creating services - [MockComponent](/docs/testing/mock-component.md) - Testing services with Ulak injections --- # Scheduled Tasks Section: Concepts Source: https://asena.sh/raw/concepts/scheduled-tasks.md # Scheduled Tasks Asena provides built-in cron-based task scheduling using Bun's native `Bun.cron.parse()` for expression validation. Decorate a class with `@Schedule` and implement the `AsenaSchedule` interface to create scheduled tasks that run automatically. ## Quick Start ```typescript import { Schedule } from '@asenajs/asena/decorators'; import type { AsenaSchedule } from '@asenajs/asena/schedule'; @Schedule({ cron: '0 3 * * *' }) // Every day at 3:00 AM export class DatabaseCleanup implements AsenaSchedule { public async execute() { console.log('Running database cleanup...'); // cleanup logic here } } ``` Asena automatically discovers and registers the scheduled task during bootstrap. The `execute()` method runs on the specified cron schedule. ## @Schedule Decorator The `@Schedule` decorator marks a class as a scheduled task component. ```typescript import { Schedule } from '@asenajs/asena/decorators'; @Schedule({ cron: '*/5 * * * *', // Required: cron expression name: 'MyTask', // Optional: component name for IoC }) ``` ### Cron Expression Format Uses 5-field cron format: ``` ┌───────────── minute (0-59) │ ┌───────────── hour (0-23) │ │ ┌───────────── day of month (1-31) │ │ │ ┌───────────── month (1-12) │ │ │ │ ┌───────────── day of week (0-6, Sun-Sat) │ │ │ │ │ * * * * * ``` | Field | Values | Special Characters | |:------|:-------|:-------------------| | Minute | 0-59 | `*` `,` `-` `/` | | Hour | 0-23 | `*` `,` `-` `/` | | Day of Month | 1-31 | `*` `,` `-` `/` | | Month | 1-12 | `*` `,` `-` `/` | | Day of Week | 0-6 (Sun=0) or MON-SUN | `*` `,` `-` `/` | **Common patterns:** | Expression | Description | |:-----------|:------------| | `* * * * *` | Every minute | | `*/5 * * * *` | Every 5 minutes | | `0 * * * *` | Every hour | | `0 9 * * *` | Daily at 9:00 AM | | `0 9 * * MON-FRI` | Weekdays at 9:00 AM | | `0 0 1 * *` | First day of every month at midnight | | `0 0 * * 0` | Every Sunday at midnight | ::: tip Cron Validation Asena validates cron expressions with Bun's native `Bun.cron.parse()` **inside the `@Schedule` decorator**, i.e. when the module is imported - before `AsenaServerFactory.create()` is ever reached. An invalid expression throws immediately, so the server can never start with a misconfigured schedule. `Bun.cron.parse()` also accepts the usual nicknames (`@hourly`, `@daily`, `@weekly`, `@monthly`, `@yearly`) in place of the 5-field form. ::: ## AsenaSchedule Interface Your scheduled task class must implement the `AsenaSchedule` interface: ```typescript import type { AsenaSchedule } from '@asenajs/asena/schedule'; export interface AsenaSchedule { execute(): Promise | void; } ``` ### Error Handling If `execute()` throws an error, it is caught and logged by the framework. The schedule continues running — a single failure does not stop future executions. ```typescript @Schedule({ cron: '*/10 * * * *' }) export class ReportTask implements AsenaSchedule { public async execute() { try { await generateReport(); } catch (error) { // Error is also caught by the framework, // but you can add custom handling here await notifyAdmin(error); } } } ``` ## Dependency Injection Scheduled task classes are full IoC components. You can inject services just like in controllers or services: ```typescript import { Schedule } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import type { AsenaSchedule } from '@asenajs/asena/schedule'; @Schedule({ cron: '0 */6 * * *' }) // Every 6 hours export class CacheWarmup implements AsenaSchedule { @Inject('ProductService') private productService: ProductService; @Inject('AppRedis') private redis: AppRedis; public async execute() { const products = await this.productService.getPopularProducts(); for (const product of products) { await this.redis.set( `product:${product.id}`, JSON.stringify(product), 3600, // 1 hour TTL ); } } } ``` ## CronRunner Service `CronRunner` is a core framework service that manages all scheduled tasks. You can inject it to monitor and interact with registered jobs. ```typescript import { Controller } from '@asenajs/asena/decorators'; import { Get } from '@asenajs/asena/decorators/http'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { ICoreServiceNames } from '@asenajs/asena/ioc/types'; import type { CronRunner } from '@asenajs/asena/schedule'; import type { Context } from '@asenajs/hono-adapter'; // or '@asenajs/ergenecore' @Controller('/api/cron') export class CronController { @Inject(ICoreServiceNames.CRON_RUNNER) private cronRunner: CronRunner; @Get('/status') public async status(context: Context) { return context.send({ jobNames: this.cronRunner.getJobNames(), jobCount: this.cronRunner.jobCount, hasRunningJobs: this.cronRunner.hasRunningJobs, }); } } ``` ### CronRunner API | Property/Method | Type | Description | |:-----------------|:-----|:------------| | `getJobNames()` | `string[]` | Get all registered job names | | `registerJob(name, cron, fn)` | `void` | Register a job programmatically | | `startAll()` / `stopAll()` | `void` | Start or stop every registered job | | `clearJobs()` | `void` | Stop and drop all registered jobs | | `jobCount` | `number` | Number of registered jobs | | `hasRunningJobs` | `boolean` | Whether any job is **scheduled** (started and not stopped) - not whether one is executing right now | ::: info Lifecycle CronRunner starts all registered jobs when the server is ready and stops them during graceful shutdown. You don't need to manage the start/stop lifecycle manually. ::: ## Real-World Examples ### Database Cleanup ```typescript import { Schedule } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import type { AsenaSchedule } from '@asenajs/asena/schedule'; @Schedule({ cron: '0 2 * * *' }) // Daily at 2:00 AM export class SessionCleanup implements AsenaSchedule { @Inject('SessionRepository') private sessionRepo: SessionRepository; public async execute() { const expiredCount = await this.sessionRepo.deleteExpired(); console.log(`Cleaned up ${expiredCount} expired sessions`); } } ``` ### Health Check Reporter ```typescript @Schedule({ cron: '*/5 * * * *' }) // Every 5 minutes export class HealthReporter implements AsenaSchedule { @Inject('AppRedis') private redis: AppRedis; @Inject('NotificationService') private notifications: NotificationService; public async execute() { const redisOk = await this.redis.testConnection(); if (!redisOk) { await this.notifications.alert('Redis connection lost'); } } } ``` ## Best Practices ### 1. Keep execute() Fast ```typescript // ✅ Good: Offload heavy work to services @Schedule({ cron: '0 * * * *' }) export class ReportSchedule implements AsenaSchedule { @Inject('ReportService') private reportService: ReportService; async execute() { await this.reportService.generateHourlyReport(); } } // ❌ Bad: Heavy logic directly in execute() @Schedule({ cron: '0 * * * *' }) export class ReportSchedule implements AsenaSchedule { async execute() { const data = await fetchAllRecords(); // complex logic here await processData(data); await sendEmails(data); // ...hundreds of lines } } ``` ### 2. Use CronRunner for Monitoring ```typescript // ✅ Good: Expose schedule status via health endpoint @Get('/health') async health(context: Context) { return context.send({ scheduledJobs: this.cronRunner.jobCount, running: this.cronRunner.hasRunningJobs, }); } ``` ### 3. Handle Errors Gracefully ```typescript // ✅ Good: Catch and handle errors in execute() async execute() { try { await this.cleanup(); } catch (error) { await this.logger.error('Cleanup failed', error); // Schedule continues running even if this execution fails } } ``` ## Related Documentation - [Services](/docs/concepts/services) - Business logic layer - [Dependency Injection](/docs/concepts/dependency-injection) - IoC container - [Configuration](/docs/guides/configuration) - Server configuration --- **Next Steps:** - Learn about [Services](/docs/concepts/services) - Explore [Dependency Injection](/docs/concepts/dependency-injection) - Set up [Redis](/docs/packages/redis) for cache warming tasks --- # Frontend Controller Section: Concepts Source: https://asena.sh/raw/concepts/frontend-controller.md # Frontend Controller Asena's `@FrontendController` lets you serve HTML pages using Bun's native HTML import feature. Routes are registered directly with `Bun.serve()` and bypass the middleware chain entirely, providing zero-overhead static page serving. ## Quick Start ```typescript import { FrontendController } from '@asenajs/asena/decorators'; import { Page } from '@asenajs/asena/decorators/http'; @FrontendController('/ui') export class AppFrontendController { @Page('/') public home() { return import('./pages/home.html'); } } ``` With this setup, visiting `/ui` serves the `home.html` page directly through Bun's native HTML bundler. ::: info Bun HTML Imports Bun supports importing `.html` files natively. The `import()` expression returns an HTMLBundle that Bun can serve with automatic bundling of linked CSS, JS, and other assets. See [Bun HTML Imports](https://bun.sh/docs/bundler/html) for details. ::: ## @FrontendController Decorator Marks a class as a frontend page controller. ```typescript import { FrontendController } from '@asenajs/asena/decorators'; // String shorthand @FrontendController('/ui') // Full options @FrontendController({ path: '/ui', name: 'DashboardFrontend', // Optional: component name for IoC }) ``` | Parameter | Type | Required | Description | |:----------|:-----|:---------|:------------| | `path` | `string` | Yes | Base URL path for all pages | | `name` | `string` | No | Component name for IoC registration | ## @Page Decorator Defines a page route within a `@FrontendController`. Each `@Page` method returns a Bun HTML import. ```typescript import { Page } from '@asenajs/asena/decorators/http'; @Page('/') // Serves at {basePath}/ @Page('/settings') // Serves at {basePath}/settings @Page('/about') // Serves at {basePath}/about ``` The method decorated with `@Page` must return the result of an HTML `import()`: ```typescript @Page('/dashboard') public dashboard() { return import('./pages/dashboard.html'); } ``` ## How It Works 1. `@FrontendController('/ui')` stores the base path in component metadata 2. `@Page('/')` collects sub-routes for each page method 3. During bootstrap, Asena registers these routes directly with `Bun.serve()`'s `routes` option 4. Requests are served by Bun's native HTTP server — no middleware, no adapter overhead ::: warning Middleware Bypass Frontend Controller routes bypass the entire middleware chain. This means: - No CORS middleware - No authentication middleware - No rate limiting - No logging middleware This is by design — HTML pages are served as static assets with zero overhead. If you need middleware processing for your routes, use a regular `@Controller` instead. ::: ## Multiple Pages Serve multiple pages from a single controller: ```typescript import { FrontendController } from '@asenajs/asena/decorators'; import { Page } from '@asenajs/asena/decorators/http'; @FrontendController('/app') export class DashboardController { @Page('/') public home() { return import('./pages/home.html'); } @Page('/settings') public settings() { return import('./pages/settings.html'); } @Page('/about') public about() { return import('./pages/about.html'); } } ``` This registers: - `/app` → `home.html` - `/app/settings` → `settings.html` - `/app/about` → `about.html` ## HTML File Structure Your HTML files are standard HTML with Bun's bundling support: ```html My App

Welcome to My App

``` Bun automatically bundles linked CSS, JavaScript, and TypeScript files. ## FrontendController vs Controller | Feature | `@FrontendController` | `@Controller` | |:--------|:---------------------|:--------------| | **Purpose** | Serve HTML pages | Handle API requests | | **Middleware** | No (bypassed) | Yes (full chain) | | **Served by** | `Bun.serve()` routes directly | HTTP adapter (Hono/Ergenecore) | | **Performance** | Zero overhead | Adapter overhead | | **Use case** | Static pages, SPAs | REST APIs, dynamic content | ## Production Build When you run `asena build`, HTML import paths are automatically rewritten so they resolve correctly from the output directory. However, you must configure `include` in your `asena-config.ts` to copy the HTML files to the build output. ### Required Configuration ```typescript import { defineConfig } from '@asenajs/asena-cli'; export default defineConfig({ sourceFolder: 'src', rootFile: 'src/index.ts', include: ['src/frontend/pages'], // [!code highlight] buildOptions: { outdir: 'dist', }, }); ``` The `include` option copies files and directories into the output directory during build. Without it, your HTML files won't exist in the production build and imports will fail at runtime. ::: warning Don't add *.html to external Do **not** add `'*.html'` to `buildOptions.external`. The CLI handles HTML imports automatically — marking them as external yourself will prevent the path rewriting from working. ```typescript // ❌ Bad: Breaks HTML import path rewriting buildOptions: { external: ['*.html'], } // ✅ Good: Let the CLI handle HTML imports buildOptions: { // No *.html in external } ``` ::: ### How It Works 1. `asena build` bundles your code into a single output file (e.g., `dist/index.asena.js`) 2. The HTML build plugin detects `.html` imports and rewrites paths relative to the project root 3. `include` copies the HTML files to the output directory preserving their structure 4. At runtime, `import('./src/frontend/pages/home.html')` resolves correctly from `dist/` ### Example Project Structure ``` my-app/ ├── src/ │ ├── frontend/ │ │ ├── AppFrontendController.ts │ │ └── pages/ │ │ ├── home.html │ │ ├── settings.html │ │ └── app.ts │ └── index.ts ├── asena-config.ts # include: ['src/frontend/pages'] └── dist/ # After build: ├── index.asena.js └── src/frontend/pages/ # Copied by include ├── home.html ├── settings.html └── app.ts ``` ## Best Practices ### 1. Use for Static Pages ```typescript // ✅ Good: Static HTML pages @FrontendController('/ui') export class AppFrontend { @Page('/') home() { return import('./pages/home.html'); } } // ❌ Bad: Don't use for API routes @FrontendController('/api') export class ApiFrontend { @Page('/users') users() { return import('./pages/users.html'); } // Should be a Controller } ``` ### 2. Organize Pages in a Dedicated Directory ``` src/ ├── controllers/ # API controllers ├── frontend/ │ ├── AppFrontendController.ts │ └── pages/ │ ├── home.html │ ├── settings.html │ ├── styles.css │ └── app.ts └── services/ ``` ### 3. Consider Auth Requirements ```typescript // ✅ Good: Public pages via FrontendController @FrontendController('/public') export class PublicPages { @Page('/') landing() { return import('./pages/landing.html'); } } // ✅ Good: Protected pages should handle auth client-side // or use a @Controller with middleware for SSR ``` ::: tip SPA Authentication For single-page applications, handle authentication on the client side. The SPA can call your authenticated API endpoints (`@Controller` with auth middleware) while the initial HTML page is served without middleware. ::: ## Related Documentation - [Controllers](/docs/concepts/controllers) - HTTP route handling - [Static File Serving](/docs/concepts/static-files) - Serving static assets - [Middleware](/docs/concepts/middleware) - Middleware system - [Configuration](/docs/guides/configuration) - Server configuration --- **Next Steps:** - Learn about [Controllers](/docs/concepts/controllers) for API routes - Explore [Static File Serving](/docs/concepts/static-files) - Set up [Middleware](/docs/concepts/middleware) for protected routes --- # PostProcessor Section: Concepts Source: https://asena.sh/raw/concepts/post-processor.md # PostProcessor PostProcessor is Asena's component interception system. It lets you hook into the IoC bootstrap process to transform instances, collect metadata, or add cross-cutting behavior to components — similar to Spring's `BeanPostProcessor` or AOP interceptors. ## When to Use PostProcessor - **Metadata Collection** — Gather information from components at bootstrap (e.g., auto-generating API documentation) - **Instance Transformation** — Wrap instances with Proxies for tracing, logging, or monitoring - **AOP Patterns** — Add cross-cutting concerns without modifying individual components ::: tip If you just need initialization logic for a single component, use `@PostConstruct` instead. PostProcessor is for cross-cutting concerns that apply to **multiple** components. ::: ## Quick Start ```typescript import { PostProcessor } from '@asenajs/asena/decorators'; import type { ComponentPostProcessor } from '@asenajs/asena/ioc/types'; @PostProcessor() export class LoggingPostProcessor implements ComponentPostProcessor { postProcess(instance: T, Class: any): T { console.log(`Component initialized: ${Class.name}`); return instance; } } ``` Asena automatically discovers and registers the PostProcessor during bootstrap. The `postProcess()` method is called for every component that gets created. ## ComponentPostProcessor Interface ```typescript import type { ComponentPostProcessor } from '@asenajs/asena/ioc/types'; export interface ComponentPostProcessor { postProcess(instance: T, Class: any): T | Promise; } ``` | Parameter | Type | Description | |:----------|:-----|:------------| | `instance` | `T` | The fully initialized component instance (after DI + PostConstruct) | | `Class` | `any` | The original class constructor (for reading metadata) | | **Returns** | `T \| Promise` | The processed instance. Return `null`/`undefined` to keep the original | ## @PostProcessor Decorator ```typescript import { PostProcessor } from '@asenajs/asena/decorators'; @PostProcessor() // Default @PostProcessor({ name: 'MyProcessor' }) // Named ``` Accepts optional `ComponentParams`: | Parameter | Type | Default | Description | |:----------|:-----|:--------|:------------| | `name` | `string` | Class name | Component name for IoC | ## Lifecycle PostProcessor runs at a specific point in the component initialization chain: ``` Constructor → @Inject (DI) → @Strategy → @PostConstruct → postProcess() ``` 1. Component is instantiated (`new`) 2. Dependencies are injected (`@Inject`) 3. PostConstruct methods are called (`@PostConstruct`) 4. **PostProcessors run** — instance is fully initialized at this point ::: info Execution Order If multiple PostProcessors are registered, they execute in FIFO order (first registered, first executed). Each processor's output becomes the next processor's input (chaining). ::: ## Two Modes of Operation ### Mode 1: Instance Transformation Return a modified or wrapped instance. Useful for tracing, monitoring, or adding behavior. ```typescript import { PostProcessor } from '@asenajs/asena/decorators'; import type { ComponentPostProcessor } from '@asenajs/asena/ioc/types'; @PostProcessor() export class TracingPostProcessor implements ComponentPostProcessor { postProcess(instance: T, Class: any): T { return new Proxy(instance as object, { get(target, prop, receiver) { const value = Reflect.get(target, prop, receiver); if (typeof value === 'function') { return function (...args: any[]) { console.log(`[TRACE] ${Class.name}.${String(prop)}() called`); return value.apply(target, args); }; } return value; }, }) as T; } } ``` ### Mode 2: Metadata Collection Collect information from components without modifying them. Return the original instance unchanged. ```typescript import { PostProcessor } from '@asenajs/asena/decorators'; import type { ComponentPostProcessor } from '@asenajs/asena/ioc/types'; @PostProcessor() export class ComponentRegistryPostProcessor implements ComponentPostProcessor { private readonly registry: Map = new Map(); postProcess(instance: T, Class: any): T { // Collect metadata — don't modify the instance this.registry.set(Class.name, { name: Class.name, methods: Object.getOwnPropertyNames(Class.prototype), }); return instance; // Return unchanged } getRegistry() { return this.registry; } } ``` ## Real-World Example: OpenAPI PostProcessor The `@asenajs/asena-openapi` package uses a PostProcessor to automatically generate OpenAPI specs from your existing controllers and validators. Here's a simplified version: ```typescript import { PostProcessor } from '@asenajs/asena/decorators'; import { PostConstruct } from '@asenajs/asena/decorators/ioc'; import { Inject } from '@asenajs/asena/decorators/ioc'; import type { ComponentPostProcessor } from '@asenajs/asena/ioc/types'; import { extractControllerRouteInfo, isController, isValidator } from '@asenajs/asena/utils'; @PostProcessor() export class OpenApiPostProcessor implements ComponentPostProcessor { @Inject(ICoreServiceNames.ASENA_ADAPTER) private adapter: AsenaAdapter; private controllers: { instance: any; Class: any }[] = []; private validators = new Map(); @PostConstruct() public onInit(): void { // Register the /openapi endpoint during initialization this.adapter.registerRoute({ method: 'GET', path: '/openapi', handler: async (context: any) => { const spec = await this.generateSpec(); return context.send(spec); }, }); } public postProcess(instance: T, Class: any): T { // Mode 2: Collect controllers and validators if (isController(Class)) { this.controllers.push({ instance, Class }); } if (isValidator(Class)) { this.validators.set(Class.name, instance); } return instance; // Return unchanged } private async generateSpec() { // Generate OpenAPI 3.1 spec from collected controllers + validators for (const { instance, Class } of this.controllers) { const { basePath, routes } = extractControllerRouteInfo(instance); // ... build spec from route metadata and validator schemas } } } ``` **How it works:** 1. `@PostConstruct` registers the `/openapi` GET endpoint on the adapter 2. `postProcess()` is called for every component — it selectively collects controllers and validators 3. When `/openapi` is requested, the spec is lazily generated from collected metadata 4. The original instances are never modified ::: tip Full Source See the full implementation: [OpenApiPostProcessor.ts on GitHub](https://github.com/AsenaJs/asena-openapi/blob/master/lib/postprocessor/OpenApiPostProcessor.ts) ::: ## Bootstrap Priority PostProcessors are registered in a special **Phase A** during bootstrap, before all other components: 1. **Phase A:** PostProcessor classes and their dependencies are created. PostProcessors are **NOT** post-processed themselves (prevents infinite loops) 2. **Phase B:** All remaining components are created. Post-processing is now active, so every component goes through `postProcess()` This guarantees that PostProcessors are ready before any user component is created. ::: warning Dependencies of PostProcessors (services injected via `@Inject`) are also created in Phase A and are **not** post-processed. Keep PostProcessor dependencies minimal. ::: ## Dependency Injection in PostProcessors PostProcessors are full IoC components — you can inject other services: ```typescript @PostProcessor() export class MetricsPostProcessor implements ComponentPostProcessor { @Inject(ICoreServiceNames.ASENA_ADAPTER) private adapter: AsenaAdapter; @Inject('MetricsService') private metrics: MetricsService; postProcess(instance: T, Class: any): T { this.metrics.trackComponent(Class.name); return instance; } } ``` ## @PostConstruct vs @PostProcessor | Aspect | `@PostConstruct` | `@PostProcessor` | |:-------|:-----------------|:-----------------| | **Type** | Method decorator | Class decorator | | **Scope** | Single component | All components | | **When** | After DI, before post-processing | After DI + PostConstruct | | **Purpose** | Component initialization | Cross-cutting concerns | | **Can transform** | No (runs on self) | Yes (returns modified instance) | | **Use case** | Setup resources, validate config | Tracing, metadata collection, AOP | ## Best Practices ### 1. Use Mode 2 for Most Cases ```typescript // ✅ Good: Collect metadata, return unchanged postProcess(instance: T, Class: any): T { if (isController(Class)) { this.controllers.push(Class.name); } return instance; } // ⚠️ Careful: Only transform when truly needed (tracing, monitoring) postProcess(instance: T, Class: any): T { return new Proxy(instance, { /* ... */ }); } ``` ### 2. Be Selective ```typescript // ✅ Good: Only process relevant components postProcess(instance: T, Class: any): T { if (!isController(Class)) return instance; // Skip non-controllers // ... process controller return instance; } // ❌ Bad: Processing every single component postProcess(instance: T, Class: any): T { // Heavy processing on ALL components return this.expensiveOperation(instance); } ``` ### 3. Keep Dependencies Minimal ```typescript // ✅ Good: Minimal dependencies (remember: deps aren't post-processed) @PostProcessor() export class SimpleProcessor implements ComponentPostProcessor { postProcess(instance: T, Class: any): T { /* ... */ } } // ❌ Bad: Many dependencies (all created in Phase A, not post-processed) @PostProcessor() export class HeavyProcessor implements ComponentPostProcessor { @Inject('ServiceA') private a: ServiceA; @Inject('ServiceB') private b: ServiceB; @Inject('ServiceC') private c: ServiceC; // ... } ``` ## Related Documentation - [Services](/docs/concepts/services) - Service layer architecture - [Dependency Injection](/docs/concepts/dependency-injection) - IoC container - [OpenAPI](/docs/packages/openapi) - PostProcessor in action - [Configuration](/docs/guides/configuration) - Server configuration --- **Next Steps:** - See PostProcessor in action with [OpenAPI](/docs/packages/openapi) - Learn about [Dependency Injection](/docs/concepts/dependency-injection) - Explore [Services](/docs/concepts/services) --- # Inheritance Section: Concepts Source: https://asena.sh/raw/concepts/inheritance.md # Inheritance Asena components are ordinary TypeScript classes, so they can extend a base class. This is the way to share code across services: the base class ships in a package, and the concrete subclass lives in the consuming application's `src/` where the component scan can find it. ```typescript // packages/platform — shared, no decorator of its own export abstract class HealthControllerBase { @Get('/live') public live(context: Context) { return context.send({ status: 'ok' }); } } // services/orders/src/controllers — the decorated concrete class @Controller('/probe') export class ProbeController extends HealthControllerBase {} ``` `GET /probe/live` works. The route is declared on the base class and picked up by the subclass. ## The rule, in one sentence > **What changes the behaviour of a request travels with the route. What says where the route lives, or what a class is, belongs to the concrete class.** Everything below follows from that. It is the same split Spring and JAX-RS use. ::: warning An undecorated subclass is not a component `@Controller`, `@Service`, `@Middleware`, `@Config`, `@MessageController` and friends mark a class as a component, and that marking belongs to the class that carries it. A subclass of a `@Controller` is **not** a controller unless you decorate it too — and since 0.9.0 it is not registered at all. Before 0.9.0 such a class was registered under its *base's* name. The container promotes a duplicate name to an array, so every `@Inject(Base)` started handing out `[Base, Sub]` and failed on first property access, a long way from the missing decorator. If a class disappears after upgrading, it was missing its decorator all along. ::: ## What is inherited Everything declared on a method or a field is collected from the whole prototype chain: | Decorator | Inherited | Notes | |:----------|:----------|:------| | `@Get` / `@Post` / `@Put` / `@Delete` / `@Patch` / `@All` | ✅ | Registered under the **subclass's** `@Controller` prefix | | `@Page` | ✅ | Registered under the subclass's `@FrontendController` base path | | `@On` | ✅ | Uses the subclass's `@EventService` prefix | | `@MessagePattern` / `@EventPattern` | ✅ | Uses the subclass's `@MessageController` prefix and transport | | `@Inject` | ✅ | Fields declared on any ancestor are injected | | `@Strategy` | ✅ | | | `@Implements` | ✅ | A subclass joins its base's interface registration, so a `@Strategy` list picks it up — see below | | `@PostConstruct` | ✅ | Runs once per method, even when the chain is several levels deep | | `@Override` | ✅ | Marks accumulate across the chain; a subclass cannot un-mark an inherited one | | `@Hidden` on a method ([openapi](/docs/packages/openapi)) | ✅ | A hidden base-class route stays out of the spec | | `@Transaction` ([drizzle](/docs/packages/drizzle)) | ✅ | A base class's transactional methods run inside a transaction | | Controller-level `middlewares` | ✅ | See below — a subclass may add, never silently drop | | Plain methods, getters, statics | ✅ | Normal JavaScript inheritance | And what is **not** inherited, because it describes the class rather than the request: | Metadata | Why | |:---------|:----| | `@Controller` / `@WebSocket` / `@FrontendController` path | The route mounts under the concrete class's prefix | | Component name, scope | Identity — two classes must not resolve to one name | | `@EventService` / `@MessageController` prefix and transport | Same reason as the path | | Component type (`CONTROLLER`, `SERVICE`, …) | A class is what its own decorator says it is | | `@Hidden` on a class | Re-exposing a hidden base under a new `@Controller` is a legitimate thing to write | ## `@Implements` is inherited; the component name is not These look like the same kind of metadata and behave differently, on purpose. A component **name** must be unique — two classes resolving to one name is a collision, which is why an undecorated subclass is not registered at all. An **interface key** is deliberately multi-valued: registering several classes under one key is the whole mechanism behind [`@Strategy`](/docs/concepts/dependency-injection), whose consumer side injects everything found there as an array. `@Implements` is the producer side of that same mechanism. So a subclass joins its base's interface registration without redeclaring anything, exactly as a Java or Spring subclass remains an instance of its base's interfaces: ```typescript @Service() @Implements('IPaymentGateway') export class StripeGateway implements PaymentGateway { /* … */ } @Service() export class StripeGatewayWithAudit extends StripeGateway { /* … */ } @Service() export class Checkout { // Receives BOTH implementations - the subclass never declares @Implements @Strategy('IPaymentGateway') private gateways: PaymentGateway[]; } ``` ::: warning `@Inject` on an interface key returns an array An interface key holding more than one implementation is normal. Reach for it with `@Strategy`, which expects a list. `@Inject('IPaymentGateway')` hands back the array too, and the failure surfaces later as `… is not a function` on first use. ::: ## Guards follow the routes they protect A controller's class-level `middlewares` are unioned across the chain, ancestors first: ```typescript @Controller({ path: '/admin', middlewares: [AuthMiddleware] }) abstract class AdminBase { @Get('/users') public listUsers(context: Context) { return context.send(allUsers()); } } @Controller('/v2/admin') export class AdminV2 extends AdminBase {} ``` `GET /v2/admin/users` runs `AuthMiddleware`, even though `AdminV2` never mentions it. A subclass may **add** middlewares; it cannot silently drop an inherited one, because a route arriving without its guard is never the safe default. To publish a base class's routes *without* its guards, move the routes to an undecorated base class and let each concrete controller declare its own `middlewares`. Repositories behave the same way — `@Repository` and `@Database` from [`@asenajs/asena-drizzle`](/docs/packages/drizzle) keep everything the decorated class inherited, including getters and statics. ## How overrides resolve The unit of overriding is the **method name**, exactly as in JAX-RS ([spec §3.6](https://jakarta.ee/specifications/restful-ws/)) and Spring. ```typescript abstract class Base { @Get('/value') public value(context: Context) { return context.send({ from: 'base' }); } } @Controller('/example') class Example extends Base { @Get('/value') public override value(context: Context) { return context.send({ from: 'subclass' }); } } ``` One route is registered and it answers `{ from: 'subclass' }`. This is a plain override, not a conflict — nothing is reported. The same rule applies to `@On`, `@MessagePattern`, `@EventPattern` and `@Page`: same method name means the subclass replaces the inherited entry and keeps every other one. ::: tip Redeclaring the decorator is optional An override that does not repeat `@Get` still serves the inherited route — the metadata comes from the base class, the implementation from the subclass. Repeat the decorator only when you want to change the path or options. ::: ## Conflicts that fail the build Overriding is by method name; conflict detection is by **resolved path or pattern**. They are separate checks, and only the second one throws. ### Two methods on the same route ```typescript abstract class Base { @Get('/live') public live(context: Context) { /* ... */ } } @Controller('/probe') class Probe extends Base { // Different method name, same path as the inherited route @Get('/live') public health(context: Context) { /* ... */ } } ``` ``` Error: Duplicate route detected: GET /probe/live — already registered by Probe.live(), conflicts with Probe.health() ``` The server refuses to start. Spring reports the same situation as `Ambiguous mapping`. Fix it by renaming the subclass method to match the inherited one, which turns the conflict into an override. The same error appears when two controllers extend one route-carrying base class **and share a prefix**. Give them distinct prefixes, or move the shared routes out of the base class. ### Two handlers on the same message pattern `@MessagePattern` is request/response, so two handlers on one pattern is ambiguous — nothing decides which one produces the reply: ``` Error: Duplicate message pattern detected: "order.get" on transport "default" — already registered by OrderController.get(), conflicts with OrderController.fetch() ``` `@EventPattern` and `@On` are exempt. Several handlers on one pattern is fan-out, which is the point of an event. ## Inherited handlers are logged at startup Inheritance is invisible in the source of the class you are reading, so the server reports what each component picked up: ``` Controller ProbeController inherits routes: live, guarded [Microservice] OrderController inherits handlers: onPaymentCompleted EventService AuditNotifier inherits handlers: onUserCreated ``` Nothing is logged for a component that declares all of its own handlers. ::: warning An inherited @EventPattern opens a real subscription `@EventPattern` and `@MessagePattern` register against the configured microservice transport — Redis, Kafka, or whatever `transport()` returns. Extending a base class that declares them makes your service **consume those patterns**, with at-least-once delivery on durable transports. That is the intended semantic: extending a class means adopting its behaviour. But check the startup log after adding a base class you did not write, and keep inherited handlers idempotent like any other. ::: ## Practical pattern: a shared platform package Asena never scans `node_modules`, so shared components have to reach the container through a decorated class in the application's own source. The base class carries the behaviour; the subclass carries the decorator and the prefix. ```typescript // @acme/platform export abstract class CrudControllerBase { @Inject(AuditService) protected auditService: AuditService; @Get('/') public async list(context: Context) { return context.send(await this.findAll()); } @Get('/:id') public async byId(context: Context) { return context.send(await this.findOne(context.getParam('id'))); } protected abstract findAll(): Promise; protected abstract findOne(id: string): Promise; } ``` ```typescript // services/orders/src/controllers/OrderController.ts @Controller('/api/orders') export class OrderController extends CrudControllerBase { @Inject(OrderService) private orderService: OrderService; protected findAll() { return this.orderService.findAll(); } protected findOne(id: string) { return this.orderService.findById(id); } } ``` `GET /api/orders` and `GET /api/orders/:id` are served by the base class implementation, running against the subclass instance — `this.orderService` and `this.auditService` are both injected. ## Related - [Controllers](/docs/concepts/controllers) — routing and the `@Controller` prefix - [Dependency Injection](/docs/concepts/dependency-injection) — how `@Inject` resolves along the chain - [Microservices](/docs/concepts/microservices) — `@MessageController` prefixes and transports - [Event System](/docs/concepts/event-system) — in-process `@On` handlers - [Drizzle](/docs/packages/drizzle) — `@Repository` and `@Database` inheritance --- # Adapters Overview Section: Adapters Source: https://asena.sh/raw/adapters/overview.md # Adapters Overview Asena uses a **pluggable adapter system** that allows you to choose the HTTP server implementation that best fits your needs. This architectural decision provides flexibility while maintaining a consistent API across all adapters. ## What is an Adapter? An adapter is a bridge between Asena's core framework and the underlying HTTP server implementation. It handles: - HTTP request/response processing - WebSocket connections - Middleware execution - Static file serving - Context wrapping ## Available Adapters Asena currently provides two official adapters: ### Ergenecore (Native Bun) **The fastest adapter for production workloads** - ⚡ **Performance:** ~295k req/sec - 📦 **Dependencies:** Zero (except Zod) - 🔧 **Runtime:** Bun-exclusive - 🎯 **Use Case:** Production APIs, microservices ### Hono Adapter **Familiar and battle-tested** - ⚡ **Performance:** ~233k req/sec - 📦 **Dependencies:** Hono framework - 🔧 **Runtime:** Bun (can be ported to Node) - 🎯 **Use Case:** Projects using Hono, gradual migration ## Performance Comparison | Adapter | Requests/sec | Latency (avg) | Memory Usage | |:---------------------|:-------------|:--------------|:-------------| | **Ergenecore** | **294,962** | **1.34ms** | Low | | **Hono** | **233,182** | **1.70ms** | Low | | Hono (standalone) | 266,476 | 1.49ms | Low | | NestJS (Bun) | 100,975 | 3.92ms | Medium | | NestJS (Node) | 88,083 | 5.33ms | High | ::: tip Benchmark conditions: 12 threads, 400 connections, 120s duration, Hello World endpoint ::: ## Feature Comparison | Feature | Ergenecore | Hono | |:-------------------------|:-----------|:----- | | HTTP Methods | ✅ | ✅ | | WebSocket Support | ✅ | ✅ | | Middleware System | ✅ | ✅ | | Request Validation | ✅ (Zod) | ✅ (Zod) | | Static File Serving | ✅ | ✅ | | Cookie Support | ✅ | ✅ | | CORS Middleware | ✅ | ✅ | | Rate Limiting | ✅ | ✅ | ## Choosing the Right Adapter ### Use **Ergenecore** when: - ✅ You need **maximum performance** - ✅ You're building a **Test or Poc project** - ✅ You want **zero external dependencies** - ✅ You're using **Bun runtime** exclusively - ✅ You want **native Bun optimizations** ### Use **Hono** when: - ✅ You're already **familiar with Hono** - ✅ You're **migrating** an existing Hono project - ✅ You need **Hono-specific middleware** - ✅ You want a **battle-tested** adapter ## Quick Start Comparison ### Ergenecore Setup ```typescript import { AsenaServerFactory } from '@asenajs/asena'; import { createErgenecoreAdapter } from '@asenajs/ergenecore'; import { logger } from './logger'; const adapter = createErgenecoreAdapter(); const server = await AsenaServerFactory.create({ adapter, logger, port: 3000 }); await server.start(); ``` ### Hono Setup ```typescript import { AsenaServerFactory } from '@asenajs/asena'; import { createHonoAdapter } from '@asenajs/hono-adapter'; import { AsenaLogger } from '@asenajs/asena-logger'; // createHonoAdapter returns a tuple; createErgenecoreAdapter returns the adapter alone const [adapter, logger] = createHonoAdapter({ logger: new AsenaLogger() }); const server = await AsenaServerFactory.create({ adapter, logger, port: 3000 }); await server.start(); ``` ## Context API Both adapters implement the same `AsenaContext` interface, so handler code is identical - only the import path differs. ::: code-group ```typescript [Ergenecore] import type { Context } from '@asenajs/ergenecore'; // Get parameters - getParam is sync, getQuery/getBody are async const id = context.getParam('id'); const page = await context.getQuery('page'); const body = await context.getBody<{ name: string }>(); // Send response return context.send({ id, page, body }, 200); ``` ```typescript [Hono] import type { Context } from '@asenajs/hono-adapter'; // Get parameters - getParam is sync, getQuery/getBody are async const id = context.getParam('id'); const page = await context.getQuery('page'); const body = await context.getBody<{ name: string }>(); // Send response return context.send({ id, page, body }, 200); ``` ::: The differences are in what `context.req` gives you: a native `Request` on Ergenecore, a `HonoRequest` on Hono. See [Context API](/docs/concepts/context) for the full surface. ## Migration Between Adapters ::: tip Migrating between adapters means changing the adapter factory and the `Context` import path. Controllers, services and business logic stay unchanged. ::: ### From Hono to Ergenecore ```typescript import { Get } from '@asenajs/asena/decorators/http'; // Before: import type { Context } from '@asenajs/hono-adapter'; import type { Context } from '@asenajs/ergenecore'; // The handler body itself does not change @Get('/:id') async getUser(context: Context) { const id = context.getParam('id'); return context.send({ id }); } ``` The bootstrap file changes too, because the factories differ: ```typescript // Before (Hono) - returns a tuple const [adapter, logger] = createHonoAdapter({ logger: new AsenaLogger() }); // After (Ergenecore) - returns the adapter alone const adapter = createErgenecoreAdapter(); ``` ## Advanced Adapter Configuration ### Ergenecore Advanced Setup ```typescript import { createErgenecoreAdapter } from '@asenajs/ergenecore'; const adapter = createErgenecoreAdapter({ hostname: '0.0.0.0', enableWebSocket: true, // Custom WebSocket adapter if needed websocketAdapter: customWebSocketAdapter }); ``` ### Hono Advanced Setup ```typescript import { createHonoAdapter } from '@asenajs/hono-adapter'; import { logger } from './logger'; // Single argument: either a bare logger, or an options object containing one const [adapter, asenaLogger] = createHonoAdapter({ logger, strict: false, // match '/health' and '/health/' alike - useful behind a reverse proxy }); ``` ## Creating Custom Adapters You can create your own adapter by implementing the `AsenaAdapter` interface: ```typescript import type { AsenaAdapter } from '@asenajs/asena/adapter'; export class MyCustomAdapter implements AsenaAdapter { async start(port: number): Promise { // Implementation } registerRoute(method: string, path: string, handler: Function): void { // Implementation } // ... implement other required methods } ``` ::: tip Check the [Ergenecore source code](https://github.com/AsenaJs/Asena-ergenecore) for a complete implementation example. or Check the [Hono-adapter source code](https://github.com/AsenaJs/hono-adapter) for a complete implementation example. ::: ## Recommendations ### For New Projects Start with **Ergenecore** for optimal performance and native Bun features. ```bash bun add @asenajs/ergenecore ``` ### For Existing Hono Projects Use the **Hono adapter** for seamless migration and reuse of existing middleware. ```bash bun add @asenajs/hono-adapter ``` ### For Maximum Performance **Ergenecore** provides: - Native Bun optimizations - Minimal dependency overhead ## Related Documentation - [Ergenecore Adapter](/docs/adapters/ergenecore) - [Hono Adapter](/docs/adapters/hono) - [Context API](/docs/concepts/context) - [Middleware Guide](/docs/concepts/middleware) --- **Next Steps:** - Learn about [Ergenecore features](/docs/adapters/ergenecore) - Explore [Hono adapter usage](/docs/adapters/hono) - Understand [Context API](/docs/concepts/context) --- # Ergenecore Adapter Section: Adapters Source: https://asena.sh/raw/adapters/ergenecore.md # Ergenecore Adapter **Ergenecore** is Asena's native Bun adapter built exclusively with Bun's native APIs for maximum performance. Developed by the Asena team, it provides zero-dependency HTTP/WebSocket serving with SIMD-accelerated routing. ## What is Ergenecore? Ergenecore is a high-performance adapter that: - **Built by Asena Team** - First-party adapter maintained alongside Asena core - **Bun-Native** - Uses `Bun.serve()` and native Bun APIs exclusively - **Zero Dependencies** - No external dependencies except Zod (for validation) - **SIMD-Accelerated** - Leverages Bun's SIMD-optimized routing engine - **Zero-Copy File Serving** - Uses `Bun.file()` for optimal static file performance - **Built-in Middleware** - Includes CorsMiddleware and RateLimiterMiddleware ## Why Choose Ergenecore? ### Performance Ergenecore is the **fastest** Asena adapter: | Adapter | Requests/sec | Latency (avg) | |:---------------------|:-------------|:--------------| | **Ergenecore** | **294,962** | **1.34ms** | | Hono (standalone) | 266,476 | 1.49ms | | Hono adapter | 233,182 | 1.70ms | | NestJS (Bun) | 100,975 | 3.92ms | ::: tip Benchmark Details 12 threads, 400 connections, 120s duration ::: ### When to Use Ergenecore **Choose Ergenecore when:** - ✅ You need maximum performance on Bun runtime - ✅ You want zero external dependencies - ✅ You need built-in CORS and rate limiting - ✅ You're building Bun-exclusive applications - ✅ You want first-party support and updates **Choose Hono Adapter when:** - ✅ You need compatibility with Hono ecosystem - ✅ You're migrating from standalone Hono - ✅ You need Hono-specific middleware ::: info For Hono adapter documentation, see [Hono Adapter](/docs/adapters/hono). ::: ## Installation ```bash bun add @asenajs/ergenecore ``` **Requirements:** - Bun v1.3.12 or higher - [@asenajs/asena](https://github.com/AsenaJs/Asena) v0.9.0 or higher - TypeScript v5.9.3 or higher ## Quick Start ### Basic Server Setup ```typescript import { AsenaServerFactory } from '@asenajs/asena'; import { createErgenecoreAdapter } from '@asenajs/ergenecore'; import { logger } from './logger'; // Create adapter const adapter = createErgenecoreAdapter(); // Create and start server const server = await AsenaServerFactory.create({ adapter, logger, port: 3000 }); await server.start(); ``` ### Controller Example ```typescript import { Controller } from '@asenajs/asena/decorators'; import { Get, Post } from '@asenajs/asena/decorators/http'; import type { Context } from '@asenajs/ergenecore'; @Controller('/users') export class UserController { @Get({ path: '/:id' }) async getById(context: Context) { const id = context.getParam('id'); return context.send({ id, name: 'John Doe' }); } @Post({ path: '/' }) async create(context: Context) { const body = await context.getBody(); return context.send({ created: true, data: body }, 201); } } ``` ::: info Context API For complete Context API documentation, see [Context](/docs/concepts/context). ::: ## Factory Functions Ergenecore provides three factory functions for creating adapter instances with different configurations. ### createErgenecoreAdapter(options?) Creates a new Ergenecore adapter instance with custom configuration. ```typescript import { createErgenecoreAdapter } from '@asenajs/ergenecore'; const adapter = createErgenecoreAdapter({ hostname: 'localhost', enableWebSocket: true, logger: customLogger }); ``` **Options:** | Option | Type | Default | Description | |:--------------------|:-----------------------------|:------------|:-------------------------------| | `port` | `number` | — | **Ignored.** `AsenaServer.start()` overwrites it with the port from `AsenaServerFactory.create({ port })`. | | `hostname` | `string` | `undefined` | Server hostname. On Ergenecore this is the **only** way to set it - `serveOptions.hostname` is overwritten. | | `logger` | `ServerLogger` | `undefined` | Custom logger instance | | `enableWebSocket` | `boolean` | `true` | Enable WebSocket support | | `websocketAdapter` | `ErgenecoreWebsocketAdapter` | Auto | Custom WebSocket adapter | | `logErrors` | `boolean` | `true` | Log the failures the framework itself answers - a request your `onError`/`onNotFound` answered writes nothing. 5xx logs at `error` with a stack, 4xx at `debug` (falling back to `info`) without one, an unmatched route at `info`. Set `false` to silence all three. See [Adapter logging](/docs/guides/error-handling#adapter-logging). | ### createProductionAdapter(options?) Creates a production-optimized adapter with sensible defaults. ```typescript import { createProductionAdapter } from '@asenajs/ergenecore'; const adapter = createProductionAdapter({ hostname: '0.0.0.0', logger: productionLogger }); ``` **Production Defaults:** - WebSocket enabled (which is already the default) ::: info It is an alias `createProductionAdapter(options)` forwards to `createErgenecoreAdapter(options)` with `enableWebSocket` defaulted to `true` - and that is already the base default. There is no additional performance tuning; the name only documents intent. ::: ### createDevelopmentAdapter(options?) Creates a development-friendly adapter with verbose logging. ```typescript import { createDevelopmentAdapter } from '@asenajs/ergenecore'; const adapter = createDevelopmentAdapter(); ``` **Development Defaults:** - WebSocket **forced** on - unlike the other two factories, passing `enableWebSocket: false` here has no effect - Same default console logger as `createErgenecoreAdapter` ::: tip Prefer `createErgenecoreAdapter` The three factories are near-identical. Use the plain one unless you specifically want the forced-WebSocket behaviour. ::: ## Built-in Middleware Ergenecore includes two powerful built-in middleware classes that you can extend and customize. ### CorsMiddleware Ergenecore provides a high-performance CORS middleware with support for origin whitelisting and dynamic validation. #### Basic CORS (Allow All Origins) ```typescript import { Middleware } from '@asenajs/asena/decorators'; import { CorsMiddleware } from '@asenajs/ergenecore'; @Middleware() export class GlobalCors extends CorsMiddleware { constructor() { super(); // Defaults to { origin: '*' } } } ``` #### Whitelist Specific Origins ```typescript @Middleware() export class RestrictedCors extends CorsMiddleware { constructor() { super({ origin: ['https://example.com', 'https://app.example.com'], credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE'], allowedHeaders: ['Content-Type', 'Authorization'], exposedHeaders: ['X-Total-Count'], maxAge: 86400 // 24 hours }); } } ``` #### Dynamic Origin Validation ```typescript @Middleware() export class DynamicCors extends CorsMiddleware { constructor() { super({ origin: (origin: string) => { // Allow all subdomains of example.com return origin.endsWith('.example.com') || origin === 'https://example.com'; }, credentials: true }); } } ``` #### CORS Options | Option | Type | Default | Description | |:-----------------|:------------------------------|:-----------|:--------------------------------| | `origin` | `'*' \| string[] \| (origin: string) => boolean` | `'*'` | Allowed origins. A **bare origin string is not supported** here - wrap it in an array. | | `credentials` | `boolean` | `false` | Allow credentials | | `methods` | `string[]` | `['GET','POST','PUT','PATCH','DELETE','OPTIONS']` | Allowed HTTP methods | | `allowedHeaders` | `string[]` | `['Content-Type', 'Authorization']` | Allowed request headers | | `exposedHeaders` | `string[]` | `[]` | Exposed response headers | | `maxAge` | `number` | `86400` | Preflight cache duration (sec) | #### Using CORS Middleware ```typescript import { Config } from '@asenajs/asena/decorators'; import { ConfigService } from '@asenajs/ergenecore'; // Global CORS @Config() export class ServerConfig extends ConfigService { globalMiddlewares() { return [GlobalCors]; } } // Per-route CORS @Controller('/api') export class ApiController { @Get({ path: '/public', middlewares: [RestrictedCors] }) async publicData(context: Context) { return context.send({ data: 'public' }); } } ``` ::: tip Performance CorsMiddleware uses lazy header allocation and pre-joined strings for optimal performance. ::: ### RateLimiterMiddleware Ergenecore includes a Token Bucket-based rate limiter for controlling request rates and preventing abuse. #### Basic Rate Limiter ```typescript import { Middleware } from '@asenajs/asena/decorators'; import { RateLimiterMiddleware } from '@asenajs/ergenecore'; // 100 requests per minute @Middleware() export class ApiRateLimiter extends RateLimiterMiddleware { constructor() { super({ capacity: 100, refillRate: 100 / 60, // tokens per second }); } } ``` #### Strict Rate Limiter ```typescript // 5 requests per minute for sensitive endpoints @Middleware() export class StrictRateLimiter extends RateLimiterMiddleware { constructor() { super({ capacity: 5, refillRate: 5 / 60, message: 'Too many login attempts. Please try again later.', }); } } ``` #### Advanced Rate Limiter ```typescript @Middleware() export class CustomRateLimiter extends RateLimiterMiddleware { constructor() { super({ capacity: 50, refillRate: 50 / 60, // Rate limit by user ID instead of IP keyGenerator: (ctx) => ctx.getValue('user')?.id || 'anonymous', // Skip rate limiting for admin users skip: (ctx) => ctx.getValue('user')?.role === 'admin', // Expensive operations cost more tokens cost: (ctx) => ctx.req.url.includes('/search') ? 5 : 1, // Custom response message: 'Rate limit exceeded. Please slow down.', statusCode: 429, // Cleanup settings cleanupInterval: 60000, // 1 minute bucketTTL: 600000 // 10 minutes }); } } ``` #### Rate Limiter Options | Option | Type | Default | Description | |:------------------|:----------------------------|:-----------------------------------------|:-------------------------------| | `capacity` | `number` | `100` | Maximum burst capacity | | `refillRate` | `number` | `10` | Tokens per second | | `keyGenerator` | `(ctx) => string` | `x-forwarded-for` → `cf-connecting-ip` → `getRequestIp()` → `'unknown'` | Client identifier function | | `message` | `string` | `'Rate limit exceeded...'` | Error message | | `statusCode` | `number` | `429` | HTTP status code | | `cost` | `number \| (ctx) => number` | `1` | Token cost per request | | `skip` | `(ctx) => boolean` | `undefined` | Skip rate limiting function | | `cleanupInterval` | `number` | `60000` | Cleanup interval (ms) | | `bucketTTL` | `number` | `600000` | Inactive bucket TTL (ms) | #### Rate Limit Headers The middleware automatically sets these headers: - `X-RateLimit-Limit`: Requests allowed per minute - `X-RateLimit-Remaining`: Remaining tokens - `X-RateLimit-Reset`: Unix timestamp when bucket resets - `Retry-After`: Seconds to wait (on 429 response) #### Using Rate Limiter ```typescript import { Config } from '@asenajs/asena/decorators'; import { ConfigService } from '@asenajs/ergenecore'; // Global rate limiter @Config() export class ServerConfig extends ConfigService { globalMiddlewares() { return [ApiRateLimiter]; } } // Per-controller @Controller('/api', { middlewares: [ApiRateLimiter] }) export class ApiController { } // Per-route @Controller('/auth') export class AuthController { @Post({ path: '/login', middlewares: [StrictRateLimiter] }) async login(context: Context) { const body = await context.getBody(); return context.send({ token: 'abc123' }); } } ``` ::: tip Token Bucket Algorithm RateLimiterMiddleware uses O(1) bucket lookup and lazy token refill for optimal performance. Each middleware instance maintains its own bucket storage for route-specific rate limiting. ::: ## Performance & Architecture ### SIMD-Accelerated Routing Ergenecore leverages Bun's SIMD-accelerated router for ultra-fast route matching. No framework overhead, just native performance. ### Zero-Copy File Serving Uses `Bun.file()` for serving static files without copying data to memory. This provides optimal performance for static assets. ### Bun-Native APIs Ergenecore is built exclusively with: - `Bun.serve()` - Native HTTP server - `Bun.file()` - Zero-copy file I/O - Native WebSocket APIs - No external runtime dependencies ## Ergenecore-Specific Features ### Context Type Always import Context from Ergenecore's types: ```typescript import type { Context } from '@asenajs/ergenecore'; ``` ::: info For complete Context API, see [Context](/docs/concepts/context). ::: ### Middleware Base Class Extend `MiddlewareService` for custom middleware: ```typescript import { Middleware } from '@asenajs/asena/decorators'; import { MiddlewareService, type Context } from '@asenajs/ergenecore'; @Middleware() export class AuthMiddleware extends MiddlewareService { async handle(context: Context, next: () => Promise): Promise { const token = context.headers['authorization']; if (!token) { return context.send({ error: 'Unauthorized' }, 401); } await next(); } } ``` ::: info For middleware patterns, see [Middleware](/docs/concepts/middleware). ::: ### Validation Service Extend `ValidationService` for request validation: ```typescript import { Middleware } from '@asenajs/asena/decorators'; import { ValidationService } from '@asenajs/ergenecore'; import { z } from 'zod'; @Middleware({ validator: true }) export class CreateUserValidator extends ValidationService { json() { return z.object({ name: z.string().min(3), email: z.string().email() }); } } ``` ::: info For validation patterns, see [Validation](/docs/concepts/validation). ::: ### Config Service Extend `ConfigService` for server configuration: ```typescript import { Config } from '@asenajs/asena/decorators'; import { ConfigService, type Context } from '@asenajs/ergenecore'; import type { NotFoundRequest } from '@asenajs/asena/adapter'; @Config() export class ServerConfig extends ConfigService { globalMiddlewares() { return [GlobalCors, ApiRateLimiter]; } onError(error: Error, context: Context): Response | Promise { return context.send({ error: 'Something went wrong' }, 500); } onNotFound(context: Context, request: NotFoundRequest): Response | Promise { return context.send({ title: 'Not Found', status: 404, instance: request.path }, 404); } } ``` ### The two handlers do not overlap `onError` is for something your code **threw**. `onNotFound` is for a request that matched **no route** — a routing outcome, not a failure — so neither handler has to ask which case it is looking at. An unmatched route never reaches `onError`. `request.path` is the path only, with no origin and no query string, and `request.method` is normalised by the adapter, so the same handler body works unchanged on the Hono adapter. With no `onNotFound` declared, both adapters answer `{"error":"Not Found"}` with a 404. A *domain* 404 — the route exists, the record does not — is still a throw, and still goes to `onError`: ```typescript throw new HttpException(404, { message: 'User not found' }); ``` ::: warning `onNotFound` also catches missing static files A file that `@StaticServe` cannot find reaches this hook too, so both adapters answer the same body. The per-route `StaticServeService.onNotFound` still runs first when you declare one. ::: ### Every thrown error reaches `onError` first Including `HttpException`. The adapter used to answer an `HttpException` straight from `getResponse()` at several points and only consult your handler for everything else, so an application could reshape its own 4xx envelopes on the Hono adapter but not here. Your handler now sees all of them, and the adapter falls back to `getResponse()` (or a generic 500) only when there is no handler, when it returns nothing, or when it throws. With no `onError` declared, an unhandled error answers `{"error":"Internal Server Error"}`. The thrown message is deliberately not echoed to the caller — it is written to the log with its stack instead. ::: info For configuration, see [Configuration](/docs/guides/configuration), and for the full picture of both hooks see [Error Handling](/docs/guides/error-handling). ::: ## Best Practices ### 1. Use Type Imports ```typescript // ✅ Good: Type-only import import type { Context } from '@asenajs/ergenecore'; // ❌ Bad: Runtime import for types import { Context } from '@asenajs/ergenecore'; ``` ### 2. Leverage Built-in Middleware ```typescript // ✅ Good: Use built-in CORS and rate limiting @Config() export class ServerConfig extends ConfigService { globalMiddlewares() { return [GlobalCors, ApiRateLimiter]; } } ``` ### 3. Extend Ergenecore Base Classes ```typescript // ✅ Good: Extend base classes import { Config, Middleware } from '@asenajs/asena/decorators'; import { ConfigService, MiddlewareService, ValidationService, type Context, type ValidationSchema, } from '@asenajs/ergenecore'; import { z } from 'zod'; // MiddlewareService requires handle() - it is the only abstract member @Middleware() export class MyMiddleware extends MiddlewareService { public async handle(context: Context, next: () => Promise) { await next(); } } // ValidationService has no abstract members; define the request parts you validate @Middleware({ validator: true }) export class MyValidator extends ValidationService { public json(): ValidationSchema { return z.object({ name: z.string() }); } } // ConfigService has no abstract members; override only the hooks you need @Config() export class MyConfig extends ConfigService { public onError(error: Error, context: Context) { return context.send({ error: error.message }, 500); } } ``` ### 4. Use Factory Functions ```typescript // ✅ Good: Use appropriate factory const adapter = process.env.NODE_ENV === 'production' ? createProductionAdapter({ hostname: '0.0.0.0' }) : createDevelopmentAdapter(); ``` ## Troubleshooting ### Common Issues **Issue: TypeScript errors with Context** ```typescript // Solution: Use type-only import import type { Context } from '@asenajs/ergenecore'; ``` **Issue: Middleware not executing** ```typescript // Solution: Ensure middleware extends MiddlewareService import { MiddlewareService } from '@asenajs/ergenecore'; @Middleware() export class MyMiddleware extends MiddlewareService { async handle(context: Context, next: () => Promise) { await next(); } } ``` **Issue: WebSocket connection fails** ```typescript // Solution: Enable WebSocket in adapter const adapter = createErgenecoreAdapter({ enableWebSocket: true }); ``` ## Related Documentation - [Adapters Overview](/docs/adapters/overview) - Compare Ergenecore vs Hono - [Hono Adapter](/docs/adapters/hono) - Alternative adapter - [Context API](/docs/concepts/context) - Request/response handling - [Middleware](/docs/concepts/middleware) - Custom middleware patterns - [Validation](/docs/concepts/validation) - Request validation with Zod - [WebSocket](/docs/concepts/websocket) - WebSocket support - [Configuration](/docs/guides/configuration) - Server configuration --- **Next Steps:** - Learn about [Context API](/docs/concepts/context) - Explore [Middleware patterns](/docs/concepts/middleware) - Understand [Validation strategies](/docs/concepts/validation) --- # Hono Adapter Section: Adapters Source: https://asena.sh/raw/adapters/hono.md # Hono Adapter **Hono Adapter** is Asena's adapter built on top of the popular [Hono](https://hono.dev/) web framework. It provides seamless integration with Hono's rich ecosystem while leveraging Asena's dependency injection and architectural patterns. ## What is Hono Adapter? Hono Adapter brings together the best of both worlds: - **Built on Hono** - Uses the proven Hono web framework under the hood - **Familiar API** - If you know Hono, you already know how to use it - **Rich Ecosystem** - Access to Hono's middleware and community packages - **Easy Migration** - Seamlessly migrate existing Hono projects to Asena - **Built-in Middleware** - Includes CorsMiddleware and RateLimiterMiddleware - **Validation Support** - Full Zod validation support - **@Override Support** - Use native Hono middleware directly without wrappers ## Why Choose Hono Adapter? ### Performance Hono Adapter delivers excellent performance powered by Bun and Hono: | Adapter | Requests/sec | Latency (avg) | |:---------------------|:-------------|:--------------| | Ergenecore | 294,962 | 1.34ms | | **Hono Adapter** | **233,182** | **1.70ms** | | Hono (standalone) | 266,476 | 1.49ms | ::: tip Benchmark Details 12 threads, 400 connections, 120s duration ::: ### When to Use Hono Adapter **Choose Hono Adapter when:** - ✅ You're already familiar with Hono framework - ✅ You're migrating an existing Hono project to Asena - ✅ You need Hono-specific middleware or plugins - ✅ You want a battle-tested, production-proven adapter - ✅ You value ecosystem compatibility over raw performance **Choose Ergenecore when:** - ✅ You need maximum performance (~26% faster) - ✅ You want zero external dependencies - ✅ You're building a greenfield Bun-exclusive project ::: info For Ergenecore adapter documentation, see [Ergenecore Adapter](/docs/adapters/ergenecore). ::: ## Installation ```bash bun add @asenajs/hono-adapter ``` **Requirements:** - [Bun](https://bun.sh) runtime v1.3.12 or higher - [@asenajs/asena](https://github.com/AsenaJs/Asena) v0.9.0 or higher - TypeScript v5.8.2 or higher ## Quick Start ### Basic Server Setup ```typescript import { AsenaServerFactory } from '@asenajs/asena'; import { createHonoAdapter } from '@asenajs/hono-adapter'; import { AsenaLogger } from '@asenajs/asena-logger'; // Create adapter (returns tuple: [adapter, logger]) - a logger is required const [adapter, logger] = createHonoAdapter({ logger: new AsenaLogger() }); // Create and start server const server = await AsenaServerFactory.create({ adapter, logger, port: 3000 }); await server.start(); ``` ### Controller Example ```typescript import { Controller } from '@asenajs/asena/decorators'; import { Get, Post } from '@asenajs/asena/decorators/http'; import type { Context } from '@asenajs/hono-adapter'; @Controller('/users') export class UserController { @Get({ path: '/:id' }) async getById(context: Context) { const id = context.req.param('id'); return context.send({ id, name: 'John Doe' }); } @Post({ path: '/' }) async create(context: Context) { const body = await context.req.json(); return context.send({ created: true, data: body }, 201); } } ``` ::: info Context API For complete Context API documentation, see [Context](/docs/concepts/context). ::: ## Factory Function ### createHonoAdapter(loggerOrOptions?) Creates a new Hono adapter instance. Supports two calling conventions: **Legacy: Logger argument** ```typescript import { createHonoAdapter } from '@asenajs/hono-adapter'; const [adapter, logger] = createHonoAdapter(myLogger); ``` **Options object (recommended)** ```typescript import { createHonoAdapter } from '@asenajs/hono-adapter'; const [adapter, logger] = createHonoAdapter({ logger: myLogger, strict: false, // '/health' and '/health/' match the same route }); ``` ### HonoAdapterOptions | Property | Type | Required | Default | Description | |:---------|:-----|:---------|:--------|:------------| | `logger` | `ServerLogger` | Yes | — | Logger instance | | `app` | `Hono` | No | — | Pre-configured Hono app instance | | `websocketAdapter` | `HonoWebsocketAdapter` | No | — | Custom WebSocket adapter | | `strict` | `boolean` | No | `true` | Strict route matching (trailing slash) | | `logErrors` | `boolean` | No | `true` | Log the failures the framework itself answers - a request your `onError`/`onNotFound` answered writes nothing. 5xx logs at `error` with a stack, 4xx at `debug` (falling back to `info`) without one, an unmatched route at `info`. Set `false` to silence all three. See [Adapter logging](/docs/guides/error-handling#adapter-logging). | **Returns:** Tuple: `[adapter, logger]` ### Trailing Slash (Strict Mode) By default, Hono uses strict mode where `/health` and `/health/` are **different** routes. Set `strict: false` to treat them as the same: ```typescript // strict: true (default) — /users and /users/ are different routes const [adapter, logger] = createHonoAdapter({ logger: myLogger }); // strict: false — /users and /users/ match the same route const [adapter, logger] = createHonoAdapter({ logger: myLogger, strict: false }); ``` ::: tip Reverse Proxies Set `strict: false` when deploying behind reverse proxies (Nginx, Cloudflare, etc.) that may add or remove trailing slashes. This prevents 404 errors from slash mismatches. ::: ## Built-in Middleware Hono Adapter includes the same powerful built-in middleware as Ergenecore. ### CorsMiddleware High-performance CORS middleware with origin whitelisting and dynamic validation. #### Basic CORS (Allow All Origins) ```typescript import { Middleware } from '@asenajs/asena/decorators'; import { CorsMiddleware } from '@asenajs/hono-adapter'; @Middleware() export class GlobalCors extends CorsMiddleware { constructor() { super(); // Defaults to { origin: '*' } } } ``` #### Whitelist Specific Origins ```typescript @Middleware() export class RestrictedCors extends CorsMiddleware { constructor() { super({ origin: ['https://example.com', 'https://app.example.com'], credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE'], allowedHeaders: ['Content-Type', 'Authorization'], exposedHeaders: ['X-Total-Count'], maxAge: 86400 // 24 hours }); } } ``` #### Dynamic Origin Validation ```typescript @Middleware() export class DynamicCors extends CorsMiddleware { constructor() { super({ origin: (origin: string) => { // Allow all subdomains of example.com return origin.endsWith('.example.com') || origin === 'https://example.com'; }, credentials: true }); } } ``` #### CORS Options | Option | Type | Default | Description | |:-----------------|:------------------------------|:-----------|:--------------------------------| | `origin` | `string \| string[] \| function` | `'*'` | Allowed origins | | `credentials` | `boolean` | `false` | Allow credentials | | `methods` | `string[]` | `['GET','POST','PUT','PATCH','DELETE','OPTIONS']` | Allowed HTTP methods | | `allowedHeaders` | `string[]` | `['Content-Type', 'Authorization']` | Allowed request headers | | `exposedHeaders` | `string[]` | `[]` | Exposed response headers | | `maxAge` | `number` | `86400` | Preflight cache duration (sec) | #### Using CORS Middleware ```typescript import { Config } from '@asenajs/asena/decorators'; import { ConfigService } from '@asenajs/hono-adapter'; // Global CORS @Config() export class ServerConfig extends ConfigService { globalMiddlewares() { return [GlobalCors]; } } // Per-route CORS @Controller('/api') export class ApiController { @Get({ path: '/public', middlewares: [RestrictedCors] }) async publicData(context: Context) { return context.send({ data: 'public' }); } } ``` ::: tip Performance CorsMiddleware uses lazy header allocation and pre-joined strings for optimal performance. ::: ### RateLimiterMiddleware Token Bucket-based rate limiter for controlling request rates and preventing abuse. #### Basic Rate Limiter ```typescript import { Middleware } from '@asenajs/asena/decorators'; import { RateLimiterMiddleware } from '@asenajs/hono-adapter'; // 100 requests per minute @Middleware() export class ApiRateLimiter extends RateLimiterMiddleware { constructor() { super({ capacity: 100, refillRate: 100 / 60, // tokens per second }); } } ``` #### Strict Rate Limiter ```typescript // 5 requests per minute for sensitive endpoints @Middleware() export class StrictRateLimiter extends RateLimiterMiddleware { constructor() { super({ capacity: 5, refillRate: 5 / 60, message: 'Too many login attempts. Please try again later.', }); } } ``` #### Advanced Rate Limiter ```typescript @Middleware() export class CustomRateLimiter extends RateLimiterMiddleware { constructor() { super({ capacity: 50, refillRate: 50 / 60, // Rate limit by user ID instead of IP keyGenerator: (ctx) => ctx.getValue('user')?.id || 'anonymous', // Skip rate limiting for admin users skip: (ctx) => ctx.getValue('user')?.role === 'admin', // Expensive operations cost more tokens cost: (ctx) => ctx.req.url.includes('/search') ? 5 : 1, // Custom response message: 'Rate limit exceeded. Please slow down.', statusCode: 429, // Cleanup settings cleanupInterval: 60000, // 1 minute bucketTTL: 600000 // 10 minutes }); } } ``` #### Rate Limiter Options | Option | Type | Default | Description | |:------------------|:----------------------------|:-----------------------------------------|:-------------------------------| | `capacity` | `number` | `100` | Maximum burst capacity | | `refillRate` | `number` | `10` | Tokens per second | | `keyGenerator` | `(ctx) => string` | `x-forwarded-for` → `cf-connecting-ip` → `getRequestIp()` → `'unknown'` | Client identifier function | | `message` | `string` | `'Rate limit exceeded...'` | Error message | | `statusCode` | `number` | `429` | HTTP status code | | `cost` | `number \| (ctx) => number` | `1` | Token cost per request | | `skip` | `(ctx) => boolean` | `undefined` | Skip rate limiting function | | `cleanupInterval` | `number` | `60000` | Cleanup interval (ms) | | `bucketTTL` | `number` | `600000` | Inactive bucket TTL (ms) | #### Rate Limit Headers The middleware automatically sets these headers: - `X-RateLimit-Limit`: Requests allowed per minute - `X-RateLimit-Remaining`: Remaining tokens - `X-RateLimit-Reset`: Unix timestamp when bucket resets - `Retry-After`: Seconds to wait (on 429 response) #### Using Rate Limiter ```typescript import { Config } from '@asenajs/asena/decorators'; import { ConfigService } from '@asenajs/hono-adapter'; // Global rate limiter @Config() export class ServerConfig extends ConfigService { globalMiddlewares() { return [ApiRateLimiter]; } } // Per-controller @Controller('/api', { middlewares: [ApiRateLimiter] }) export class ApiController { } // Per-route @Controller('/auth') export class AuthController { @Post({ path: '/login', middlewares: [StrictRateLimiter] }) async login(context: Context) { const body = await context.req.json(); return context.send({ token: 'abc123' }); } } ``` ::: tip Token Bucket Algorithm RateLimiterMiddleware uses O(1) bucket lookup and lazy token refill for optimal performance. Each middleware instance maintains its own bucket storage for route-specific rate limiting. ::: ## Hono-Specific Features ### @Override Decorator The `@Override` decorator allows middleware to work directly with Hono's native context without Asena wrappers. This is **unique to Hono Adapter** and enables seamless integration with Hono ecosystem middleware. Extend `AsenaMiddlewareService` rather than the adapter's `MiddlewareService` here: the adapter class binds `handle()` to Asena's wrapped Context, so declaring a Hono `Context` parameter on it is a signature mismatch. `AsenaMiddlewareService` leaves the context type open, which is exactly what `@Override` needs. ```typescript import { Middleware, Override } from '@asenajs/asena/decorators'; import { AsenaMiddlewareService } from '@asenajs/asena/middleware'; import type { Context as HonoContext, Next } from 'hono'; @Middleware() export class NativeHonoMiddleware extends AsenaMiddlewareService { @Override() async handle(context: HonoContext, next: Next) { // Use Hono's native context directly - no wrapper! const startTime = Date.now(); await next(); const duration = Date.now() - startTime; // Hono's own API - this is the native context, not Asena's wrapper context.header('X-Response-Time', `${duration}ms`); } } ``` **Benefits of @Override:** - ✅ Use existing Hono middleware without modification - ✅ Access Hono's full native API - ✅ Zero performance overhead (no wrapper) - ✅ Integrate with Hono ecosystem packages **Example: Using Hono's Built-in Middleware** ```typescript import { Middleware, Override } from '@asenajs/asena/decorators'; import { AsenaMiddlewareService } from '@asenajs/asena/middleware'; import { compress } from 'hono/compress'; import { logger } from 'hono/logger'; import type { Context as HonoContext, Next } from 'hono'; @Middleware() export class CompressionMiddleware extends AsenaMiddlewareService { @Override() async handle(context: HonoContext, next: Next) { // Use Hono's compress middleware directly return compress()(context, next); } } @Middleware() export class LoggerMiddleware extends AsenaMiddlewareService { @Override() async handle(context: HonoContext, next: Next) { // Use Hono's logger middleware directly return logger()(context, next); } } ``` ::: warning When to Use @Override Use `@Override` only when you need direct access to Hono's context. For most use cases, Asena's wrapped Context provides a cleaner, adapter-agnostic API. ::: ### Context Type Always import Context from Hono Adapter: ```typescript import type { Context } from '@asenajs/hono-adapter'; ``` The Hono adapter Context wraps Hono's native context with Asena enhancements. ::: info For complete Context API, see [Context](/docs/concepts/context). ::: ### Middleware Base Class Extend `MiddlewareService` for custom middleware: ```typescript import { Middleware } from '@asenajs/asena/decorators'; import { MiddlewareService, type Context } from '@asenajs/hono-adapter'; @Middleware() export class AuthMiddleware extends MiddlewareService { async handle(context: Context, next: () => Promise): Promise { const token = context.req.header('authorization'); if (!token) { return context.send({ error: 'Unauthorized' }, 401); } await next(); } } ``` ::: info For middleware patterns, see [Middleware](/docs/concepts/middleware). ::: ### Validation Service Extend `ValidationService` for request validation: ```typescript import { Middleware } from '@asenajs/asena/decorators'; import { ValidationService } from '@asenajs/hono-adapter'; import { z } from 'zod'; @Middleware({ validator: true }) export class CreateUserValidator extends ValidationService { json() { return z.object({ name: z.string().min(3), email: z.string().email() }); } } ``` ::: info For validation patterns, see [Validation](/docs/concepts/validation). ::: ### Config Service Extend `ConfigService` for server configuration: ```typescript import { Config } from '@asenajs/asena/decorators'; import { ConfigService, type Context } from '@asenajs/hono-adapter'; @Config() export class ServerConfig extends ConfigService { globalMiddlewares() { return [GlobalCors, ApiRateLimiter]; } onError(error: Error, context: Context): Response | Promise { console.error('Error:', error); return context.send({ error: error.message }, 500); } } ``` ::: info For configuration, see [Configuration](/docs/guides/configuration). ::: ### Static File Serving Extend `StaticServeService` for serving static files: ```typescript import { Controller } from '@asenajs/asena/decorators'; import { Get } from '@asenajs/asena/decorators/http'; import { StaticServe } from '@asenajs/asena/decorators'; import { StaticServeService, type Context } from '@asenajs/hono-adapter'; @StaticServe({ root: './public' }) export class StaticMiddleware extends StaticServeService { rewriteRequestPath(path: string): string { return path.replace(/^\/static\/|^static\//, ''); } onFound(_path: string, _context: Context): void | Promise { console.log('File served successfully'); } onNotFound(path: string, context: Context): void | Promise { console.log(`File not found: ${path}`); } } @Controller('/static') export class StaticController { @Get({ path: '/*', staticServe: StaticMiddleware }) static() {} } ``` ## Migrating from Standalone Hono If you're migrating from a standalone Hono application to Asena with Hono adapter: ### Before (Standalone Hono) ```typescript import { Hono } from 'hono'; const app = new Hono(); app.get('/users/:id', (c) => { const id = c.req.param('id'); return c.json({ id, name: 'John' }); }); app.post('/users', async (c) => { const body = await c.req.json(); return c.json({ created: true, data: body }, 201); }); export default app; ``` ### After (Asena with Hono Adapter) ```typescript import { Controller } from '@asenajs/asena/decorators'; import { Get, Post } from '@asenajs/asena/decorators/http'; import type { Context } from '@asenajs/hono-adapter'; @Controller('/users') export class UserController { @Get({ path: '/:id' }) async getUser(context: Context) { const id = context.req.param('id'); return context.send({ id, name: 'John' }); } @Post({ path: '/' }) async create(context: Context) { const body = await context.req.json(); return context.send({ created: true, data: body }, 201); } } ``` ### Benefits of Migration - **Dependency Injection** - Built-in IoC container with `@Inject` - **Code Organization** - Controller-based routing with decorators - **Service Layer** - Clean separation of concerns - **Built-in Validation** - Zod validation with `ValidationService` - **WebSocket Support** - Native WebSocket integration - **Type Safety** - Full TypeScript support with decorators - **@Override Support** - Use existing Hono middleware without changes ## Testing Hono adapter provides excellent testing support with Bun's built-in test framework. ```typescript import { describe, expect, it, beforeEach, afterEach } from "bun:test"; import { AsenaServerFactory } from "@asenajs/asena"; import { createHonoAdapter } from "@asenajs/hono-adapter"; import { UserController } from "./controllers/UserController"; import { logger } from "./logger"; describe("UserController", () => { let server; let baseUrl; beforeEach(async () => { // 10000-31999: below the kernel's ephemeral floor (net.ipv4.ip_local_port_range, // 32768-60999). A server port drawn from that range collides with the outbound // sockets the suite itself holds open - TIME_WAIT included - and Bun.serve then // fails with EADDRINUSE, randomly, in whichever test happened to draw it. const port = 10000 + Math.floor(Math.random() * 22000); // createHonoAdapter returns a tuple and requires a logger - calling it with no argument // yields [adapter, undefined], and AsenaServerFactory.create throws on the undefined logger const [adapter] = createHonoAdapter({ logger }); server = await AsenaServerFactory.create({ adapter, logger, port, components: [UserController] // Register components for testing }); await server.start(); baseUrl = `http://localhost:${port}`; }); afterEach(async () => { await server.stop(); }); it("should get user by id", async () => { const response = await fetch(`${baseUrl}/users/123`); const data = await response.json(); expect(response.status).toBe(200); expect(data.id).toBe('123'); }); it("should create new user", async () => { const response = await fetch(`${baseUrl}/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'John', email: 'john@example.com' }) }); const data = await response.json(); expect(response.status).toBe(201); expect(data.created).toBe(true); }); }); ``` **AsenaServerFactory.create() Options:** | Option | Type | Description | |:-------------|:-----------|:------------------------------------------| | `adapter` | `Adapter` | Hono adapter instance | | `headless` | `boolean` | Explicit opt-in to boot without an adapter ([headless mode](/docs/concepts/microservices#headless-mode)) | | `logger` | `Logger` | Logger instance | | `port` | `number` | Server port (optional) | | `components` | `Class[]` | Controllers/services to register (for testing) | | `gc` | `boolean` | Enable garbage collection (optional) | ::: tip Testing with Components Use the `components` parameter to register only the controllers needed for testing. This prevents Asena from scanning the entire project, making tests faster and more isolated. ::: ::: info For testing strategies, see [Testing Guide](/docs/guides/testing). ::: ## Best Practices ### 1. Use Type Imports ```typescript // ✅ Good: Type-only import import type { Context } from '@asenajs/hono-adapter'; // ❌ Bad: Runtime import for types import { Context } from '@asenajs/hono-adapter'; ``` ### 2. Leverage Built-in Middleware ```typescript // ✅ Good: Use built-in CORS and rate limiting @Config() export class ServerConfig extends ConfigService { globalMiddlewares() { return [GlobalCors, ApiRateLimiter]; } } ``` ### 3. Use @Override for Hono Middleware ```typescript // ✅ Good: Use @Override for Hono ecosystem middleware @Middleware() export class HonoCompress extends AsenaMiddlewareService { @Override() async handle(c: HonoContext, next: Next) { return compress()(c, next); } } ``` ### 4. Use Hono's Native Methods ```typescript // ✅ Good: Use Hono's native context methods const id = context.req.param('id'); const body = await context.req.json(); return context.send({ data }); // Also works: Asena's unified API const id = context.getParam('id'); const body = await context.getBody(); return context.send({ data }); ``` ## Troubleshooting ### Common Issues **Issue: TypeScript errors with Context** ```typescript // Solution: Use type-only import import type { Context } from '@asenajs/hono-adapter'; ``` **Issue: Middleware not executing** ```typescript // Solution: Ensure middleware extends MiddlewareService import { MiddlewareService } from '@asenajs/hono-adapter'; @Middleware() export class MyMiddleware extends MiddlewareService { async handle(context: Context, next: () => Promise) { await next(); } } ``` **Issue: Hono-specific middleware not working** ```typescript // Solution: Use @Override decorator for native Hono middleware, and extend // AsenaMiddlewareService so the Hono Context parameter type-checks import { Override } from '@asenajs/asena/decorators'; import { AsenaMiddlewareService } from '@asenajs/asena/middleware'; import type { Context as HonoContext, Next } from 'hono'; @Middleware() export class MyHonoMiddleware extends AsenaMiddlewareService { @Override() async handle(context: HonoContext, next: Next) { // Use Hono's native context await next(); } } ``` ## Related Documentation - [Adapters Overview](/docs/adapters/overview) - Compare Hono vs Ergenecore - [Ergenecore Adapter](/docs/adapters/ergenecore) - Alternative adapter - [Context API](/docs/concepts/context) - Request/response handling - [Middleware](/docs/concepts/middleware) - Custom middleware patterns - [Validation](/docs/concepts/validation) - Request validation with Zod - [Testing Guide](/docs/guides/testing) - Testing strategies - [Hono Documentation](https://hono.dev/) - Official Hono docs --- **Next Steps:** - Learn about [Context API](/docs/concepts/context) - Explore [Middleware patterns](/docs/concepts/middleware) - Understand [@Override decorator](/docs/concepts/middleware#override-decorator) --- # AsenaLogger Section: Official Packages Source: https://asena.sh/raw/packages/logger.md # AsenaLogger **AsenaLogger** is Asena's official logging package built on Winston, providing beautifully formatted console output, performance profiling, and seamless integration with both adapters. ## Installation ```bash bun add @asenajs/asena-logger ``` **Requirements:** - [Bun](https://bun.sh) v1.3.12 or higher - TypeScript v5.8.3 or higher The package has no peer dependency on `@asenajs/asena` - it only implements the `ServerLogger` shape, so it works with any version. ## Quick Start ### With Ergenecore Adapter ```typescript import { AsenaServerFactory } from '@asenajs/asena'; import { createErgenecoreAdapter } from '@asenajs/ergenecore'; import { AsenaLogger } from '@asenajs/asena-logger'; // Create logger export const logger = new AsenaLogger(); // Create adapter const adapter = createErgenecoreAdapter(); // Create and start server const server = await AsenaServerFactory.create({ adapter, logger, port: 3000 }); await server.start(); ``` ### With Hono Adapter ```typescript import { AsenaServerFactory } from '@asenajs/asena'; import { createHonoAdapter } from '@asenajs/hono-adapter'; import { AsenaLogger } from '@asenajs/asena-logger'; // Create logger export const logger = new AsenaLogger(); // Create adapter - returns a tuple, and the logger is required const [adapter] = createHonoAdapter({ logger }); // Create and start server const server = await AsenaServerFactory.create({ adapter, logger, port: 3000 }); await server.start(); ``` ## Using Logger in Your Application ### Recommended: Global Export The most common pattern in Asena is to export the logger and use it directly: ```typescript // src/logger.ts import { AsenaLogger } from '@asenajs/asena-logger'; export const logger = new AsenaLogger(); ``` The constructor also takes an optional Winston logger and an options object, which is the simplest way to change the level or add transports without building a Winston logger yourself: ```typescript export const logger = new AsenaLogger(undefined, { level: 'debug', transports: [/* extra Winston transports */], }); ``` ```typescript // src/services/UserService.ts import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { logger } from '../logger'; @Service() export class UserService { @Inject(UserRepository) private userRepository: UserRepository; async getUsers() { logger.info('Getting users from the database'); return await this.userRepository.getUsers(); } async createUser(data: { name: string; email: string }) { logger.info('Creating user', { email: data.email }); try { const user = await this.userRepository.create(data); logger.info('User created successfully', { userId: user.id }); return user; } catch (error) { logger.error('Failed to create user', error); throw error; } } } ``` ### Alternative: IoC Injection You can also inject the logger using the IoC container: ```typescript import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { ICoreServiceNames } from '@asenajs/asena/ioc/types'; import type { ServerLogger } from '@asenajs/asena/logger'; @Service() export class UserService { @Inject(ICoreServiceNames.SERVER_LOGGER) private logger!: ServerLogger; @Inject(UserRepository) private userRepository: UserRepository; async getUsers() { this.logger.info('Getting users from the database'); return await this.userRepository.getUsers(); } } ``` ::: tip Which Approach to Use? **Global Export (Recommended):** - ✅ Simpler and more straightforward - ✅ Works everywhere (services, utilities, helpers) - ✅ Common pattern in Asena applications **IoC Injection:** - ✅ Better for unit testing (easier to mock) - ✅ Follows strict dependency injection principles - ✅ Useful when you need to swap logger implementations Most Asena applications use the global export pattern for simplicity. ::: ## AsenaLogger API ### info(message, meta?) Log informational messages. ```typescript logger.info('Server started successfully'); logger.info('User logged in', { userId: 123, ip: '192.168.1.1' }); ``` ### error(message, meta?) Log error messages with optional error object. ```typescript logger.error('Database connection failed'); logger.error('Payment processing error', new Error('Insufficient funds')); ``` ### warn(message, meta?) Log warning messages. ```typescript logger.warn('High memory usage detected', { usage: '85%' }); logger.warn('API rate limit approaching', { remaining: 5 }); ``` ### debug(message, meta?) Log debug-level information (useful during development). ```typescript logger.debug('Request received', { method: 'GET', path: '/api/users' }); logger.debug('Cache hit', { key: 'user:123' }); ``` ### log(level, message, meta?) Log at a custom level. ```typescript logger.log('verbose', 'Detailed operation info', { step: 1 }); logger.log('info', 'Custom informational message'); ``` ### profile(id) Start/stop profiling with the given ID. Call once to start, call again with the same ID to stop and log elapsed time. ```typescript logger.profile('expensive-operation'); // ... perform expensive operation logger.profile('expensive-operation'); // Logs: "expensive-operation 1234ms" ``` ## Log Levels AsenaLogger supports the following log levels (in order of priority): AsenaLogger uses Winston's default `npm` levels: | Level | Priority | Description | Color | |:----------|:---------|:--------------------------------|:-----------| | `error` | 0 | Error conditions | Red | | `warn` | 1 | Warning conditions | Yellow | | `info` | 2 | Informational messages | Green | | `http` | 3 | HTTP-level messages | uncoloured | | `verbose` | 4 | Detailed informational messages | uncoloured | | `debug` | 5 | Debug-level messages | Blue | | `silly` | 6 | Everything | uncoloured | Only `error`, `warn`, `info` and `debug` have a colour mapping; other levels print uppercased and uncoloured. ## Output Format ``` 2025-10-15 14:30:45 [INFO]: Server starting 2025-10-15 14:30:45 [WARN]: Resource usage high { "memory": "85%" } 2025-10-15 14:30:45 [ERROR]: Failed to connect to database Connection timeout Error: Connection timeout at connectToDatabase (/app/services/db.ts:42:11) at startServer (/app/index.ts:23:5) ... 2025-10-15 14:30:45 [PROFILE]: database-query 125ms ``` ## Real-World Examples ### Middleware Logging ```typescript import { Middleware } from '@asenajs/asena/decorators'; import { MiddlewareService, type Context } from '@asenajs/ergenecore'; import { logger } from '../logger'; @Middleware() export class RequestLoggerMiddleware extends MiddlewareService { async handle(context: Context, next: () => Promise) { const start = Date.now(); const method = context.req.method; const url = context.req.url; logger.info('Request started', { method, url }); await next(); const duration = Date.now() - start; logger.info('Request completed', { method, url, duration }); } } ``` ### Error Handling in Config ```typescript import { Config } from '@asenajs/asena/decorators'; import { ConfigService, type Context } from '@asenajs/ergenecore'; import { logger } from '../logger'; @Config() export class ServerConfig extends ConfigService { onError(error: Error, context: Context): Response { logger.error('Unhandled error', { error: error.message, stack: error.stack, url: context.req.url, method: context.req.method }); return context.send({ error: 'Internal server error' }, 500); } } ``` ### Performance Profiling ```typescript import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { logger } from '../logger'; @Service() export class ReportService { @Inject(AnalyticsRepository) private analyticsRepo: AnalyticsRepository; private async processData(data: unknown) { // your own aggregation logic return data; } async generateReport(userId: string) { logger.profile('generate-report'); const data = await this.analyticsRepo.getData(userId); const report = await this.processData(data); logger.profile('generate-report'); // Logs elapsed time return report; } } ``` ## Custom Winston Configuration AsenaLogger is built on Winston. For advanced logging configurations (file transports, custom formats, log rotation), you can pass a custom Winston instance: ```typescript import winston from 'winston'; import { AsenaLogger } from '@asenajs/asena-logger'; // Create custom Winston logger const winstonLogger = winston.createLogger({ level: 'info', transports: [ new winston.transports.File({ filename: 'logs/error.log', level: 'error' }), new winston.transports.File({ filename: 'logs/combined.log' }), new winston.transports.Console() ] }); // Use with AsenaLogger export const logger = new AsenaLogger(winstonLogger); ``` ::: info Winston Documentation For detailed Winston configuration options (transports, formats, log rotation, etc.), see the [Winston Documentation](https://github.com/winstonjs/winston). ::: ## Best Practices ### 1. Export Logger Globally ```typescript // ✅ Good: Export for global use // src/logger.ts export const logger = new AsenaLogger(); // ❌ Avoid: Creating logger in each file const logger = new AsenaLogger(); // Don't do this in multiple files ``` ### 2. Include Rich Context ```typescript // ✅ Good: Rich structured logging logger.info('User action', { userId: 123, action: 'update_profile', timestamp: new Date().toISOString() }); // ❌ Bad: No context logger.info('User did something'); ``` ### 3. Use Appropriate Log Levels ```typescript // ✅ Good: Use correct level logger.error('Database connection failed', error); logger.warn('Cache miss, fetching from database'); logger.info('User logged in successfully'); logger.debug('Cache key: user:123'); // ❌ Bad: Wrong level logger.info('Critical error occurred!'); logger.error('User logged in'); ``` ### 4. Profile Expensive Operations ```typescript // ✅ Good: Profile performance-critical operations logger.profile('complex-calculation'); const result = await performComplexCalculation(); logger.profile('complex-calculation'); ``` ### 5. Don't Log Sensitive Data ```typescript // ✅ Good: Sanitize sensitive data logger.info('User logged in', { userId: user.id, email: maskEmail(user.email) }); // ❌ Bad: Logging passwords and tokens logger.info('User logged in', { password: user.password, // NEVER do this! token: user.sessionToken }); ``` ## Related Documentation - [Services](/docs/concepts/services) - Service layer patterns - [Middleware](/docs/concepts/middleware) - Middleware patterns - [Configuration](/docs/guides/configuration) - Server configuration - [Dependency Injection](/docs/concepts/dependency-injection) - IoC container - [Winston Documentation](https://github.com/winstonjs/winston) - Advanced configuration --- **Next Steps:** - Learn about [Error Handling](/docs/guides/configuration) - Explore [Middleware patterns](/docs/concepts/middleware) - Understand [Services architecture](/docs/concepts/services) --- # Asena Drizzle Section: Official Packages Source: https://asena.sh/raw/packages/drizzle.md # Asena Drizzle Drizzle ORM utilities for AsenaJS - A powerful and type-safe database integration package that provides generic Database services and Repository patterns. ## Features - 🚀 **Generic Database Service** - Support for multiple database types - 🎯 **Type-Safe Repository Pattern** - Full TypeScript type inference - 🏷️ **Decorator-Based Configuration** - Easy setup with `@Database`, `@Repository`, `@Transaction`, and `@Drizzle` - 🔄 **Declarative Transactions** - `@Transaction` with `REQUIRED` / `NESTED` / `REQUIRES_NEW` propagation, propagated through `AsyncLocalStorage` - 🔧 **AsenaJS Integration** - Seamless IoC container integration - 📦 **Multiple Database Support** - Connect to different databases simultaneously - ⚡ **Performance Optimized** - Connection pooling and efficient queries ## Installation ```bash # Core packages bun add @asenajs/asena-drizzle drizzle-orm # Database drivers bun add pg # For PostgreSQL bun add mysql2 # For MySQL ``` **Requirements:** - [Bun](https://bun.sh) v1.3.12 or higher - [@asenajs/asena](https://github.com/AsenaJs/Asena) v0.9.0 or higher - [drizzle-orm](https://orm.drizzle.team) v0.45.2 or higher ## Supported Databases - ✅ **PostgreSQL** - using `pg` (node-postgres) with connection pooling - ✅ **MySQL** - using `mysql2` - ✅ **BunSQL** - using Bun's built-in SQL interface - ⏳ **SQLite** - coming soon ## Quick Start ### 1. Define Your Schema ```typescript import { pgTable, uuid, text, timestamp, boolean } from 'drizzle-orm/pg-core'; export const users = pgTable('users', { id: uuid('id').primaryKey().defaultRandom(), name: text('name').notNull(), email: text('email').notNull().unique(), isActive: boolean('is_active').default(true), createdAt: timestamp('created_at').defaultNow() }); ``` ### 2. Setup Database Service ```typescript // database/schemas/index.ts import * as User from '../schemas/user.schema.ts'; export default { ...User, }; ``` ```typescript import { Database, AsenaDatabaseService } from '@asenajs/asena-drizzle'; @Database({ type: 'postgresql', config: { host: 'localhost', port: 5432, database: 'myapp', user: 'postgres', password: 'password', }, name: 'MainDatabase' // Recommended: used for IoC registration }) export class MyDatabase extends AsenaDatabaseService> {} ``` ::: tip 💡 Schema Export Pattern We export all schemas as a single object for better TypeScript support: ```typescript // database/schemas/index.ts import * as User from './user.schema'; export default { ...User }; ``` **Why?** - ✅ Full IntelliSense in queries - ✅ Type-safe column access - ✅ Autocomplete for table names - ✅ Compile-time error catching **Usage:** ```typescript export class MyDatabase extends AsenaDatabaseService> { // Now you have full type safety! 🎉 } ``` This is Drizzle's recommended pattern for optimal developer experience. ::: ### 3. Create Repository ```typescript import { Repository, BaseRepository } from '@asenajs/asena-drizzle'; import { eq } from 'drizzle-orm'; @Repository({ table: users, databaseService: 'MainDatabase', }) export class UserRepository extends BaseRepository> { async findByEmail(email: string) { return this.findOne(eq(users.email, email)); } async findActiveUsers() { return this.findAll(eq(users.isActive, true)); } } ``` ::: tip Type-Safe Repository Pattern Always provide both generic parameters to your repository for full TypeScript support: ```typescript // First parameter: Your table schema // Second parameter: Your database connection type export class UserRepository extends BaseRepository> { // Now you have full IntelliSense and type checking! } ``` **Why this matters:** Without the database type parameter, TypeScript cannot infer the correct query builder methods, and you'll lose IDE autocomplete features. **Available Database Types:** | Database | Driver | Type to Use | |----------|--------|-------------| | PostgreSQL | `pg` | `NodePgDatabase` | | MySQL | `mysql2` | `MySql2Database` | | BunSQL (PostgreSQL) | Bun native (`type: 'bun-sql'`) | `BunSQLDatabase` | **Complete Example:** ```typescript import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { users } from './schema'; @Repository({ table: users, databaseService: 'MainDatabase' }) export class UserRepository extends BaseRepository> { // Type-safe methods with IntelliSense async findByEmail(email: string) { // findOne() is inherited; `this.db` is the raw drizzle connection for anything else return this.findOne(eq(users.email, email)); } } ``` ::: ### 4. Activate the Transaction Post-Processor (optional) If you plan to use `@Transaction` (see [Transactions](#transactions) below), drop a `@Drizzle`-decorated class somewhere in your source folder — `src/config/` is the convention. AsenaJS only scans your source files, never `node_modules`, so the transaction post-processor must be subclassed inside your project to be discovered. ```typescript // src/config/AppDrizzle.ts import { Drizzle, TransactionPostProcessor } from '@asenajs/asena-drizzle'; @Drizzle({ defaultDb: 'MainDatabase' }) export class AppDrizzle extends TransactionPostProcessor {} ``` The body is intentionally empty — this class exists only so AsenaJS's component scanner picks it up. Setting `defaultDb` lets every `@Transaction()` call site omit the `database` option in single-database projects. ::: tip Skip this step if you don't need transactions The package works fine without `@Drizzle` — you'll still get repositories, the typed query builder, pagination, and `BaseRepository#transaction(cb)`. You only need this step to enable the `@Transaction` decorator. ::: ### 5. Use in Services ```typescript import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; @Service('UserService') export class UserService { @Inject('UserRepository') private userRepository: UserRepository; async createUser(name: string, email: string) { return this.userRepository.create({ name, email }); } async getAllUsers() { return this.userRepository.findAll(); } async getUsersPaginated(page = 1, limit = 10) { return this.userRepository.paginate(page, limit); } } ``` ## @Database Decorator API The `@Database` decorator configures a database connection: ```typescript @Database({ type: 'postgresql' | 'mysql' | 'bun-sql', config: { host: string; port: number; database: string; user: string; password: string; ssl?: boolean; connectionString?: string; // Optional: overrides individual config name?: string; // Optional: shown in the connection log }, name?: string; // Optional: service name (recommended for multiple databases) logger?: ServerLogger; // Optional: where the adapter logs connection events drizzleConfig?: { logger?: boolean; // Enable SQL query logging schema?: any; // Your Drizzle schema configPath?: string; // Path to a drizzle config file } }) ``` ## @Repository Decorator API The `@Repository` decorator configures a repository: ```typescript @Repository({ table: DrizzleTable, // Your Drizzle table schema (must have 'id' column) databaseService: string, // Name of the @Database service name?: string // Optional: service name (defaults to class name) }) ``` ## @Drizzle Decorator API `@Drizzle` activates the transaction post-processor (see [Step 4 of Quick Start](#4-activate-the-transaction-post-processor-optional)). Apply it to a class extending `TransactionPostProcessor` placed in your source folder: ```typescript @Drizzle({ defaultDb?: string; // Optional: name of the @Database service used when // @Transaction() omits the `database` option }) ``` The decorator chains `@PostProcessor()` onto your subclass and writes the options as metadata, which `TransactionPostProcessor` reads at bootstrap. You can also apply it with no arguments (`@Drizzle()`) when you'd rather always specify `database` explicitly on each `@Transaction`. ## Database Configuration ### PostgreSQL ```typescript @Database({ type: 'postgresql', config: { host: process.env.DB_HOST || 'localhost', port: parseInt(process.env.DB_PORT || '5432'), database: process.env.DB_NAME || 'myapp', user: process.env.DB_USER || 'postgres', password: process.env.DB_PASSWORD || 'password', // Optional: Connection pool settings max: 20, // Maximum number of clients idleTimeoutMillis: 30000, connectionTimeoutMillis: 2000, }, name: 'MainDatabase' }) export class PostgresDB extends AsenaDatabaseService {} ``` ### MySQL ```typescript @Database({ type: 'mysql', config: { host: 'localhost', port: 3306, database: 'myapp', user: 'root', password: 'password', }, name: 'MySQLDB' }) export class MySQLDatabase extends AsenaDatabaseService {} ``` ### BunSQL ```typescript @Database({ type: 'bun-sql', config: { host: 'localhost', port: 5432, database: 'myapp', user: 'postgres', password: 'password', }, name: 'BunSQLDB' }) export class BunSQLDatabase extends AsenaDatabaseService {} ``` ### Connection String ```typescript @Database({ type: 'postgresql', config: { connectionString: process.env.DATABASE_URL, // Still required (can be empty if using connection string) host: '', port: 0, database: '', user: '', password: '' }, name: 'MainDatabase' }) export class DatabaseFromURL extends AsenaDatabaseService {} ``` ## Repository Methods The `BaseRepository` provides a comprehensive set of CRUD methods: ### Finding Records #### findById(id) Find a record by its ID. ```typescript const user = await userRepository.findById('123e4567-e89b-12d3-a456-426614174000'); ``` #### findOne(where) Find a single record matching the condition. ```typescript import { eq } from 'drizzle-orm'; const user = await userRepository.findOne(eq(users.email, 'john@example.com')); ``` #### findAll(where?) Find all records, optionally filtered. ```typescript // All records const allUsers = await userRepository.findAll(); // Filtered const activeUsers = await userRepository.findAll(eq(users.isActive, true)); ``` ### Creating Records #### create(data) Create a single record. ```typescript const newUser = await userRepository.create({ name: 'John Doe', email: 'john@example.com' }); ``` #### createMany(data[]) Create multiple records at once. ```typescript const newUsers = await userRepository.createMany([ { name: 'John', email: 'john@example.com' }, { name: 'Jane', email: 'jane@example.com' } ]); ``` ### Updating Records #### updateById(id, data) Update a record by ID. ```typescript const updated = await userRepository.updateById( '123e4567-e89b-12d3-a456-426614174000', { name: 'John Updated' } ); ``` #### update(where, data) Update records matching a condition. ```typescript import { eq } from 'drizzle-orm'; await userRepository.update( eq(users.isActive, false), { isActive: true } ); ``` ### Deleting Records #### deleteById(id) Delete a record by ID. ```typescript await userRepository.deleteById('123e4567-e89b-12d3-a456-426614174000'); ``` #### delete(where) Delete records matching a condition. ```typescript import { eq } from 'drizzle-orm'; await userRepository.delete(eq(users.isActive, false)); ``` ### Counting Records #### count() Count all records. ```typescript const total = await userRepository.count(); ``` #### countBy(where) Count records matching a condition. ```typescript import { eq } from 'drizzle-orm'; const activeCount = await userRepository.countBy(eq(users.isActive, true)); ``` ### Pagination #### paginate(page, limit, where?, orderBy?) Get paginated results. ```typescript import { desc } from 'drizzle-orm'; const result = await userRepository.paginate(1, 10, undefined, desc(users.createdAt)); // result.data - array of records // result.total - total count // result.page - current page // result.limit - items per page // result.totalPages - total pages ``` ::: tip Deterministic by default When you do not provide `orderBy`, the repository falls back to `asc(table.id)` so rows do not interleave across pages. Pass an explicit `orderBy` whenever you need a different sort. ::: ### Existence Check #### exists(where) Check if a record exists. ```typescript import { eq } from 'drizzle-orm'; const exists = await userRepository.exists(eq(users.email, 'john@example.com')); ``` ### Field Shortcuts These two helpers wrap the most common `eq(column, value)` lookups so you don't have to import `eq` from `drizzle-orm` for one-off checks. Both are fully type-safe — `field` must be a key of the row type, and `value` is constrained to that column's inferred type. #### findBy(field, value) Equivalent to `findAll(eq(table[field], value))`. ```typescript const activeUsers = await userRepository.findBy('status', 'active'); ``` #### existsBy(field, value) Equivalent to `exists(eq(table[field], value))`. ```typescript const taken = await userRepository.existsBy('email', 'john@example.com'); ``` ### Bulk Aliases `updateMany` / `deleteMany` are semantic aliases of `update` / `delete` that respect the documented public API surface. They behave identically to their counterparts. #### updateMany(where, data) ```typescript import { eq } from 'drizzle-orm'; await userRepository.updateMany(eq(users.isActive, false), { isActive: true }); ``` #### deleteMany(where) ```typescript import { eq } from 'drizzle-orm'; const removedCount = await userRepository.deleteMany(eq(users.isActive, false)); ``` ### Programmatic Transaction #### transaction(callback, options?) Run `callback` inside a Drizzle transaction. When called from within an active `@Transaction` scope (see [Transactions](#transactions) below), it opens a `SAVEPOINT`; otherwise it starts a brand-new top-level transaction. ```typescript await userRepository.transaction(async () => { await userRepository.create({ name: 'Ada', email: 'ada@example.com' }); await profileRepository.create({ userId: 'ada-id', bio: '…' }); }, { isolationLevel: 'serializable' }); ``` The optional second argument forwards `isolationLevel` and `accessMode` to Drizzle's `db.transaction(cb, config)`. ## Advanced Queries ### Complex Filtering ```typescript import { eq, and, or, gt, lt, like } from 'drizzle-orm'; @Repository({ table: users, databaseService: 'MainDatabase' }) export class UserRepository extends BaseRepository { async findAdultActiveUsers() { return this.findAll( and( eq(users.isActive, true), gt(users.age, 18) ) ); } async searchUsers(query: string) { return this.findAll( or( like(users.name, `%${query}%`), like(users.email, `%${query}%`) ) ); } } ``` ### Joins and Relations ```typescript import { pgTable, uuid, text, foreignKey } from 'drizzle-orm/pg-core'; export const posts = pgTable('posts', { id: uuid('id').primaryKey().defaultRandom(), title: text('title').notNull(), authorId: uuid('author_id').notNull().references(() => users.id), }); @Repository({ table: posts, databaseService: 'MainDatabase' }) export class PostRepository extends BaseRepository { async findPostsWithAuthors() { const db = this.db; return db .select({ post: posts, author: users }) .from(posts) .leftJoin(users, eq(posts.authorId, users.id)); } } ``` ### Raw Database Access ```typescript @Repository({ table: users, databaseService: 'MainDatabase' }) export class UserRepository extends BaseRepository { async complexQuery() { const db = this.db; // Use Drizzle's full API return db .select() .from(users) .where(eq(users.isActive, true)) .orderBy(desc(users.createdAt)) .limit(10); } async rawSQL() { const db = this.db; // Execute raw SQL if needed return db.execute(sql` SELECT * FROM users WHERE created_at > NOW() - INTERVAL '7 days' `); } } ``` ## Multiple Databases You can connect to multiple databases simultaneously: ```typescript // Primary database @Database({ type: 'postgresql', config: { /* primary db config */ }, name: 'PrimaryDB' }) export class PrimaryDatabase extends AsenaDatabaseService {} // Analytics database @Database({ type: 'mysql', config: { /* analytics db config */ }, name: 'AnalyticsDB' }) export class AnalyticsDatabase extends AsenaDatabaseService {} // Use different databases @Repository({ table: users, databaseService: 'PrimaryDB' }) export class UserRepository extends BaseRepository {} @Repository({ table: events, databaseService: 'AnalyticsDB' }) export class EventRepository extends BaseRepository {} ``` ## Transactions asena-drizzle ships a Spring-style `@Transaction` decorator backed by Bun's native `AsyncLocalStorage`. Repository calls made inside a `@Transaction`-wrapped method automatically pick up the active transaction — you do not have to thread a `tx` parameter through your code. ::: warning Setup required `@Transaction` only works once you have activated the post-processor with a `@Drizzle`-decorated class in your source folder — see [Step 4 of Quick Start](#4-activate-the-transaction-post-processor-optional). Without it the decorator silently does nothing because AsenaJS never sees the post-processor. ::: ### `@Transaction` Decorator ```typescript import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { Transaction } from '@asenajs/asena-drizzle'; @Service('AccountService') export class AccountService { @Inject('UserRepository') private userRepo: UserRepository; @Inject('AuditRepository') private auditRepo: AuditRepository; // Uses defaultDb from @Drizzle({ defaultDb: 'MainDatabase' }) @Transaction() async register(payload: { email: string; name: string }) { const user = await this.userRepo.create(payload); await this.auditRepo.create({ userId: user.id, event: 'register' }); // Either both rows commit, or both roll back atomically. return user; } } ``` When you have multiple `@Database` services, pass the target name explicitly — it overrides any `defaultDb`: ```typescript @Transaction({ database: 'AnalyticsDB' }) async trackEvent(...) { ... } ``` If neither `@Drizzle.defaultDb` nor `@Transaction.database` is set, the post-processor fails fast at registration time with a descriptive error. ### Propagation Modes | Mode | If a transaction is active | If no transaction is active | |---|---|---| | `REQUIRED` *(default)* | Joins the existing transaction (no new one is started). | Starts a new top-level transaction. | | `NESTED` | Opens a `SAVEPOINT` inside the existing transaction. Inner failures roll back to the savepoint without aborting the outer transaction. | Starts a new top-level transaction. | | `REQUIRES_NEW` | Suspends the outer transaction and runs in an independent top-level transaction (a fresh connection is taken from the pool). | Starts a new top-level transaction. | ```typescript @Transaction({ database: 'MainDatabase', propagation: 'NESTED', isolationLevel: 'serializable', }) async bestEffortAudit(userId: string) { // Inner failure rolls back to a savepoint; the outer caller can keep going. } @Transaction({ database: 'MainDatabase', propagation: 'REQUIRES_NEW', }) async writeAuditLog(event: AuditEvent) { // Survives even if the surrounding transaction rolls back — useful for // audit/telemetry writes that must persist independently. } ``` ### Isolation & Access Mode `isolationLevel` and `accessMode` are forwarded to Drizzle's `db.transaction(cb, config)` on the top-level paths (`REQUIRED` starting a new transaction, and `REQUIRES_NEW`). A `NESTED` call **inside** an existing transaction opens a SAVEPOINT with no config, so it inherits the outer transaction's isolation: ```typescript @Transaction({ database: 'MainDatabase', isolationLevel: 'repeatable read', accessMode: 'read only', }) async snapshot() { // … } ``` Supported values: - `isolationLevel`: `'read uncommitted' | 'read committed' | 'repeatable read' | 'serializable'` - `accessMode`: `'read only' | 'read write'` Dialects that don't recognize a given value silently ignore it (e.g. SQLite vs PostgreSQL). ### Programmatic Boundary Use [`BaseRepository#transaction(callback, options?)`](#programmatic-transaction) when you need a transaction inside a single method without lifting the boundary up to a service-level decorator. It is ALS-aware: inside an active `@Transaction` scope it opens a `SAVEPOINT`, otherwise it starts a new top-level transaction. ### Raw Drizzle Escape Hatch If you need access to Drizzle's full query builder inside a transaction (joins, raw SQL, dialect-specific helpers), reach for the underlying `db.transaction(...)`: ```typescript @Service() export class OrderService { @Inject('OrderRepository') private orderRepo: OrderRepository; @Inject('InventoryRepository') private inventoryRepo: InventoryRepository; async createOrder(items: Array<{ productId: string; quantity: number }>) { return this.orderRepo.db.transaction(async (tx) => { const order = await tx.insert(orders).values({ total: 100, status: 'pending', }).returning(); for (const item of items) { await tx.update(inventory) .set({ stock: sql`stock - ${item.quantity}` }) .where(eq(inventory.productId, item.productId)); } return order[0]; }); } } ``` ::: warning Self-invocation `@Transaction` only wraps actual class methods. Arrow-function class properties (`run = async () => …`) live on the instance, not the prototype, and are not intercepted. Use the standard `async method() { … }` syntax. ::: ::: tip Roadmap — full auto-resolution Today, single-database projects can use `@Drizzle({ defaultDb })` once and call `@Transaction()` with no arguments thereafter. Multi-database projects still need to name the target explicitly. Once AsenaJS core ships an `afterAllComponentsRegistered` post-processor hook (see [`docs/asena-core-feature-request-afterAllComponentsRegistered.md`](https://github.com/AsenaJs/asena-drizzle/blob/master/docs/asena-core-feature-request-afterAllComponentsRegistered.md) in the asena-drizzle repository), v1.3.0 will skip even the `defaultDb` step when exactly one `@Database` service is registered, mirroring Spring Boot's auto-wired repositories. ::: ## Best Practices ### 1. Use Repository Pattern ```typescript // ✅ Good: Business logic in service, data access in repository @Repository({ table: users, databaseService: 'MainDB' }) export class UserRepository extends BaseRepository { async findByEmail(email: string) { return this.findOne(eq(users.email, email)); } } @Service() export class UserService { @Inject(UserRepository) private userRepo: UserRepository; async createUser(data: any) { const existing = await this.userRepo.findByEmail(data.email); if (existing) throw new Error('Email exists'); return this.userRepo.create(data); } } // ❌ Bad: Direct database access in service @Service() export class UserService { async createUser(data: any) { const db = this.db; const existing = await db.select().from(users)... } } ``` ### 2. Type Safety ```typescript // ✅ Good: Full type inference const user = await userRepository.create({ name: 'John', email: 'john@example.com' }); // user.id, user.name, user.email are all typed! // ❌ Bad: Losing type safety const user: any = await userRepository.create({...}); ``` ### 3. Use Pagination for Large Datasets ```typescript // ✅ Good: Paginated results const page1 = await userRepository.paginate(1, 20); const page2 = await userRepository.paginate(2, 20); // ❌ Bad: Loading all records const allUsers = await userRepository.findAll(); // Could be millions! ``` ### 4. Custom Repository Methods ```typescript // ✅ Good: Encapsulate complex queries @Repository({ table: users, databaseService: 'MainDB' }) export class UserRepository extends BaseRepository { async findRecentActiveUsers(days: number = 7) { const db = this.db; const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - days); return db .select() .from(users) .where( and( eq(users.isActive, true), gt(users.lastLoginAt, cutoffDate) ) ); } } ``` ## Related Documentation - [Services](/docs/concepts/services) - [Dependency Injection](/docs/concepts/dependency-injection) - [Inheritance](/docs/concepts/inheritance) - Sharing repository methods through a base class - [Configuration](/docs/guides/configuration) - [Drizzle ORM Documentation](https://orm.drizzle.team/) --- **Next Steps:** - Set up your database schema - Create repositories for your entities - Learn about [Services](/docs/concepts/services) - Explore [Testing](/docs/guides/testing) with repositories --- # Asena OpenAPI Section: Official Packages Source: https://asena.sh/raw/packages/openapi.md # Asena OpenAPI Automatic OpenAPI 3.1 spec generation for AsenaJS — zero config, uses your existing validators. Your `@Controller` routes and validator schemas (`json()`, `query()`, `param()`, `response()`) are automatically converted to a full OpenAPI specification. No extra annotations needed. ## Features - **Zero Config** - Extracts schemas from existing validators, no extra annotations needed - **OpenAPI 3.1** - Generates JSON Schema draft-2020-12 compatible spec - **Built-in Swagger UI** - CDN-based UI page, no npm install required - **@Hidden Decorator** - Class and method level exclusion from spec - **Zod v4 Native** - Uses `z.toJSONSchema()` for accurate conversion - **Pluggable Converters** - `SchemaConverter` interface for custom schema types - **IoC Integrated** - PostProcessor pattern, auto-discovers controllers during bootstrap - **Zero Runtime Dependencies** - Only peer deps (asena, reflect-metadata, zod) ## Installation ```bash bun add @asenajs/asena-openapi ``` **Requirements:** - [Bun](https://bun.sh) v1.3.12 or higher - [@asenajs/asena](https://github.com/AsenaJs/Asena) v0.9.0 or higher - [Zod](https://zod.dev) v4.3 or higher ## Quick Start ```typescript import { OpenApi, OpenApiPostProcessor } from '@asenajs/asena-openapi'; @OpenApi({ info: { title: 'My API', version: '1.0.0' }, path: '/api/openapi', ui: true, // Swagger UI at /api/openapi/ui }) export class AppOpenApi extends OpenApiPostProcessor {} ``` Asena automatically discovers it — that's it. Now: - `GET /api/openapi` → OpenAPI 3.1 JSON spec - `GET /api/openapi/ui` → Swagger UI page ::: tip Zero Setup You don't need to register `AppOpenApi` anywhere. Asena's IoC container automatically discovers and initializes it during bootstrap, just like any other component. ::: ## How It Works The `OpenApiPostProcessor` automatically: 1. **Intercepts** every `@Controller` during IoC setup 2. **Extracts** route metadata (`@Get`, `@Post`, `@Put`, `@Delete`) 3. **Resolves** validators and converts their Zod schemas to JSON Schema 4. **Generates** a complete OpenAPI 3.1 spec 5. **Registers** GET endpoints on the adapter for spec and Swagger UI Your existing validators do double duty — they validate requests **AND** generate documentation. ## Validator Mapping Each validator method maps to a specific part of the OpenAPI spec: | Validator Method | OpenAPI Output | Location | |:-----------------|:---------------|:---------| | `json()` | RequestBody | `application/json` | | `form()` | RequestBody | `multipart/form-data` | | `query()` | ParameterObject[] | `in: query` | | `param()` | ParameterObject[] | `in: path` | | `header()` | ParameterObject[] | `in: header` | | `response()` | ResponseObject | by status code | ### Complete Validator Example ```typescript import { Middleware } from '@asenajs/asena/decorators'; import { ValidationService } from '@asenajs/hono-adapter'; // or '@asenajs/ergenecore' import { z } from 'zod'; @Middleware({ validator: true }) export class CreateUserValidator extends ValidationService { // → requestBody (application/json) json() { return z.object({ name: z.string().min(1), email: z.string().email(), }); } // → query parameters query() { return z.object({ page: z.coerce.number().optional(), }); } // → path parameters param() { return z.object({ id: z.string().uuid(), }); } // → response schemas by status code response() { return { 201: z.object({ id: z.string(), name: z.string() }), 400: { schema: z.object({ error: z.string() }), description: 'Validation error' }, }; } } ``` ::: info Response Format The `response()` method supports two formats per status code: - **Simple:** A Zod schema directly (e.g., `201: z.object({ ... })`) - **Detailed:** An object with `schema` and optional `description` (e.g., `400: { schema: z.object({ ... }), description: '...' }`) ::: ## @Hidden Decorator Hide controllers or individual routes from the spec: ```typescript import { Hidden } from '@asenajs/asena-openapi'; import { Controller } from '@asenajs/asena/decorators'; import { Get } from '@asenajs/asena/decorators/http'; // Hide entire controller @Hidden() @Controller('/internal') export class InternalController { @Get('/metrics') metrics() { /* hidden from spec */ } } // Hide single route @Controller('/api') export class ApiController { @Hidden() @Get('/health') healthCheck() { /* hidden from spec */ } @Get('/users') // this route IS in the spec listUsers() { /* visible in spec */ } } ``` ## Configuration ### OpenApiDecoratorOptions ```typescript @OpenApi({ info: { title: 'My API', // Required version: '1.0.0', // Required description: 'My app', // Optional }, path: '/api/openapi', // Default: '/openapi' ui: true, // Default: false — enables Swagger UI at {path}/ui servers: [ // Optional { url: 'https://api.example.com', description: 'Production' }, ], converters: [ // Default: [ZodSchemaConverter] new ZodSchemaConverter(), ], }) export class AppOpenApi extends OpenApiPostProcessor {} ``` | Option | Type | Default | Description | |:-------|:-----|:--------|:------------| | `info` | `{ title, version, description? }` | — | API metadata (required) | | `path` | `string` | `'/openapi'` | Base path for spec and UI endpoints | | `ui` | `boolean` | `false` | Enable Swagger UI at `{path}/ui` | | `servers` | `ServerObject[]` | — | Server URLs for the spec | | `converters` | `SchemaConverter[]` | `[ZodSchemaConverter]` | Schema converters | ## Swagger UI When `ui: true`, a Swagger UI page is served at `{path}/ui`. It loads from CDN — zero npm dependencies: - Uses `swagger-ui-dist@5` from unpkg CDN - No build step required - Works in development and production ::: warning Production Consideration Swagger UI loads from CDN, which requires internet access. If your production environment has no external network access, consider setting `ui: false` and using an external API documentation tool. ::: ## Best Practices ### 1. Use @Hidden for Internal Endpoints ```typescript // ✅ Good: Hide health checks and internal endpoints @Hidden() @Controller('/internal') export class InternalController { } // ❌ Bad: Exposing internal endpoints in public API docs @Controller('/internal') export class InternalController { } ``` ### 2. Keep Validators Co-located ```typescript // ✅ Good: Validator next to its controller // src/controllers/UserController.ts // src/validators/CreateUserValidator.ts // ❌ Bad: Validators scattered across the project ``` ### 3. Use Response Schemas ```typescript // ✅ Good: Document response schemas for better API docs response() { return { 200: z.object({ users: z.array(userSchema) }), 404: { schema: z.object({ error: z.string() }), description: 'Not found' }, }; } ``` ## Related Documentation - [Validation](/docs/concepts/validation) - Request validation with Zod - [Controllers](/docs/concepts/controllers) - HTTP route handling - [Middleware](/docs/concepts/middleware) - Middleware and validators - [Configuration](/docs/guides/configuration) - Server configuration --- **Next Steps:** - Set up [request validation](/docs/concepts/validation) with Zod - Learn about [Controllers](/docs/concepts/controllers) - Explore [Middleware patterns](/docs/concepts/middleware) --- # Asena OpenTelemetry Section: Official Packages Source: https://asena.sh/raw/packages/opentelemetry.md # Asena OpenTelemetry OpenTelemetry integration for AsenaJS — automatic HTTP tracing, method-level auto-tracing, metrics, and distributed tracing support. Your `@Otel` decorated class handles SDK initialization, and `OtelTracingMiddleware` traces every request. A single HTTP request automatically produces a full waterfall trace: ``` GET /api/users (SERVER) └─ UserController.list (INTERNAL) └─ UserService.getAll (INTERNAL) ``` ## Features - **Decorator-Based Setup** — `@Otel` decorator handles SDK initialization and IoC registration - **Automatic HTTP Tracing** — `OtelTracingMiddleware` traces all requests with zero code changes - **Method-Level Auto-Tracing** — Auto-wrap `@Service` and `@Controller` methods with `INTERNAL` spans - **Waterfall Trace Hierarchy** — HTTP → Controller → Service parent-child spans automatically - **Metrics Collection** — Request counter and duration histogram per route - **W3C Context Propagation** — Extract incoming `traceparent`, inject outgoing context - **Route Exclusion** — `ignoreRoutes` with exact and wildcard matching - **Custom Sampling** — `ratioBasedSampler` helper for production - **Minimal Dependencies** — One runtime dependency (`@opentelemetry/context-async-hooks`); everything else is a peer dep ## Installation ```bash bun add @asenajs/asena-otel @opentelemetry/api @opentelemetry/resources @opentelemetry/sdk-trace-base @opentelemetry/sdk-metrics @opentelemetry/semantic-conventions @opentelemetry/context-async-hooks ``` For OTLP exporters (Jaeger, Grafana Tempo, etc.): ```bash bun add @opentelemetry/exporter-trace-otlp-http @opentelemetry/exporter-metrics-otlp-http ``` ::: info Requirements - [Bun](https://bun.sh) v1.3.12 or higher - [@asenajs/asena](https://github.com/AsenaJs/Asena) v0.9.0 or higher ::: ## Quick Start ### 1. Create an @Otel Class Create a class that extends `OtelTracingPostProcessor` and apply the `@Otel` decorator. Asena automatically discovers and initializes it during bootstrap. ```typescript // src/otel/AppOtel.ts import { Otel, OtelTracingPostProcessor } from '@asenajs/asena-otel'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'; @Otel({ serviceName: 'my-app', serviceVersion: '1.0.0', traceExporter: new OTLPTraceExporter({ url: 'http://localhost:4318/v1/traces', }), metricReader: new PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter({ url: 'http://localhost:4318/v1/metrics', }), }), autoTrace: { services: true, controllers: true, }, }) export class AppOtel extends OtelTracingPostProcessor {} ``` ::: tip Zero Config Discovery Asena's IoC container automatically discovers your `@Otel` class and `OtelService`. No manual component registration needed — except for `OtelTracingMiddleware`, which requires a local wrapper class (see Step 2). ::: ### 2. Create a Local Middleware Class Create a class in your `src` folder that extends `OtelTracingMiddleware` and apply the `@Middleware()` decorator. This registers the middleware in Asena's IoC container. ```typescript // src/middlewares/AppOtelMiddleware.ts import { Middleware } from '@asenajs/asena/decorators'; import { OtelTracingMiddleware } from '@asenajs/asena-otel'; @Middleware() export class AppOtelMiddleware extends OtelTracingMiddleware {} ``` ::: warning Important Asena's IoC container only scans the `src` folder defined in your `asena-config.ts`. Since `OtelTracingMiddleware` lives in `node_modules`, the container cannot discover it automatically. You **must** create a local class extending it with `@Middleware()` so that Asena can register and use it. Without this step, the container will throw an error because it cannot find the middleware. ::: ### 3. Register the Middleware in Your Config Add `AppOtelMiddleware` to your config's `globalMiddlewares()`. This is required so that all HTTP requests are traced automatically. ```typescript import { Config } from '@asenajs/asena/decorators'; import { ConfigService, type Context, HttpException } from '@asenajs/ergenecore'; import { AppOtelMiddleware } from '../middlewares/AppOtelMiddleware'; import { AppCorsMiddleware } from '../middlewares/AppCorsMiddleware'; @Config() export class AppConfig extends ConfigService { public globalMiddlewares() { return [ AppOtelMiddleware, // traces all HTTP requests automatically AppCorsMiddleware, ]; } public onError(error: Error, context: Context) { if (error instanceof HttpException) { return context.send(error.body, error.status); } return context.send({ error: 'Internal Server Error' }, 500); } } ``` That's it. All HTTP requests are traced, service methods are auto-traced, and metrics are collected — without changing any business logic. ## How Auto-Tracing Works When `autoTrace` is enabled, `OtelTracingPostProcessor` wraps component methods with a JavaScript Proxy. Each method call creates an `INTERNAL` span named `"{ClassName}.{methodName}"`. Combined with the middleware's `SERVER` span, this produces a full waterfall: ``` GET /api/users (SERVER span — OtelTracingMiddleware) └─ UserController.list (INTERNAL span — auto-traced) └─ UserService.getAll (INTERNAL span — auto-traced) └─ UserService._buildQuery (NOT traced — private method) ``` All spans in the same request share the same `traceId` and form a parent-child hierarchy via `context.with()`. ::: info What Gets Traced | Decorator | Auto-Traced | |:----------|:------------| | `@Service` | Yes | | `@Controller` | Yes | | `@Repository` | Yes (treated as Service) | | `@Redis` | Yes (treated as Service) | | `@Middleware` | No | | `@Component` | No | Private methods (starting with `_`), constructors, and Symbol-keyed methods are always skipped. ::: ## OtelService API `OtelService` is an injectable `@Service` that provides access to OpenTelemetry tracer and meter. Asena automatically discovers it — just inject where needed. ### withSpan(name, fn) Wraps an async function in a span with automatic status and exception handling: ```typescript import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import type { OtelService } from '@asenajs/asena-otel'; @Service() export class OrderService { @Inject('OtelService') private otelService: OtelService; async processOrder(orderId: string) { return this.otelService.withSpan('process-order', async (span) => { span.setAttribute('order.id', orderId); // ... business logic return { success: true }; }); // span automatically ends, errors are recorded } } ``` ### getActiveSpan() Returns the currently active span, or `undefined` if there is none: ```typescript const span = this.otelService.getActiveSpan(); if (span) { span.setAttribute('custom.key', 'value'); } ``` ### injectTraceContext(headers?) Injects W3C `traceparent` header into a headers object for distributed tracing. See [Outgoing Request Context Propagation](#outgoing-request-context-propagation) for full usage. ```typescript const headers = this.otelService.injectTraceContext({ 'Content-Type': 'application/json', }); // headers now contains { 'Content-Type': '...', 'traceparent': '00-...' } ``` ### Expression Injection For direct tracer/meter access without going through `OtelService` methods: ```typescript @Inject('OtelService', (s) => s.tracer) private tracer: Tracer; @Inject('OtelService', (s) => s.meter) private meter: Meter; ``` ### API Reference | Method | Parameters | Returns | Description | |:-------|:-----------|:--------|:------------| | `withSpan(name, fn)` | `name: string, fn: (span: Span) => Promise` | `Promise` | Create span, auto-end, record errors | | `getActiveSpan()` | — | `Span \| undefined` | Get current active span | | `injectTraceContext(headers?)` | `headers?: Record` | `Record` | Inject W3C traceparent header | | `tracer` | — | `Tracer` | OpenTelemetry Tracer instance | | `meter` | — | `Meter` | OpenTelemetry Meter instance | ## OtelTracingMiddleware `@Middleware` that automatically traces all HTTP requests. Must be registered in your Config's `globalMiddlewares()`. **Creates for each request:** - A `SERVER` span named `"{METHOD} {PATH}"` (e.g., `GET /api/users`) - After route matching, the span name is updated to use the route pattern (e.g., `GET /api/users/:id`) for low-cardinality naming **Span Attributes:** | Attribute | Example | Description | |:----------|:--------|:------------| | `http.request.method` | `GET` | HTTP method | | `url.path` | `/api/users/123` | Request path | | `http.route` | `/api/users/:id` | Route pattern (after matching) | | `http.response.status_code` | `200` | Response status code | **Metrics:** | Metric | Type | Description | |:-------|:-----|:------------| | `http.server.request.count` | Counter | Total requests by method/path | | `http.server.request.duration` | Histogram (ms) | Request duration by method/path | The middleware also extracts incoming W3C `traceparent` headers, enabling distributed tracing when other services call your API. ## Configuration ### AsenaOtelOptions ```typescript interface AsenaOtelOptions { serviceName: string; // Required: identifies your service serviceVersion?: string; // Optional: defaults to '0.0.0' traceExporter: SpanExporter; // Required: where to send spans metricReader?: MetricReader; // Optional: enables metrics collection autoTrace?: AutoTraceConfig; // Optional: auto-trace settings sampler?: Sampler; // Optional: custom sampling strategy ignoreRoutes?: string[]; // Optional: routes to exclude from tracing } ``` ### AutoTraceConfig ```typescript interface AutoTraceConfig { services?: boolean; // auto-trace @Service methods (default: false) controllers?: boolean; // auto-trace @Controller methods (default: false) } ``` | Field | Type | Default | Description | |:------|:-----|:--------|:------------| | `serviceName` | `string` | — | Required. Identifies your service in traces | | `serviceVersion` | `string` | `'0.0.0'` | Service version for resource attributes | | `traceExporter` | `SpanExporter` | — | Required. Where to send spans (OTLP, Jaeger, etc.) | | `metricReader` | `MetricReader` | — | Enables metrics collection | | `autoTrace` | `AutoTraceConfig` | `{}` | Which component types to auto-trace | | `sampler` | `Sampler` | — | Custom sampling strategy | | `ignoreRoutes` | `string[]` | `[]` | Routes to exclude from tracing | ## Sampling Use `ratioBasedSampler()` for production environments to control trace volume: ```typescript import { Otel, OtelTracingPostProcessor, ratioBasedSampler } from '@asenajs/asena-otel'; @Otel({ serviceName: 'my-app', traceExporter: exporter, sampler: ratioBasedSampler(0.1), // sample 10% of traces autoTrace: { services: true, controllers: true }, }) export class AppOtel extends OtelTracingPostProcessor {} ``` `ratioBasedSampler(ratio)` creates a `ParentBasedSampler` wrapping `TraceIdRatioBasedSampler`. Root spans are sampled at the given ratio (0.0–1.0); child spans respect the parent's decision. ::: tip Production Recommendation Always configure a sampler in production to control trace volume and reduce exporter costs. A ratio of `0.1` (10%) is a good starting point for most applications. ::: ## Route Exclusion Use `ignoreRoutes` to skip tracing on specific paths (e.g., health checks, metrics endpoints): ```typescript @Otel({ serviceName: 'my-app', traceExporter: exporter, ignoreRoutes: ['/health', '/metrics', '/admin/*'], }) export class AppOtel extends OtelTracingPostProcessor {} ``` - **Exact match:** `/health` matches only `/health` - **Wildcard suffix:** `/admin/*` matches `/admin/` and all sub-paths Ignored routes produce no spans and no metrics. ::: warning Routes are matched against the URL path before route matching occurs. Use the static path, not the route pattern. ::: ## Outgoing Request Context Propagation `@asenajs/asena-otel` automatically traces **incoming** HTTP requests via `OtelTracingMiddleware` (extracts W3C `traceparent` header). However, **outgoing** HTTP calls (e.g., `fetch` to another service) are **not** automatically instrumented. ::: warning No Automatic Fetch Instrumentation When your service calls another service via `fetch`, the trace context is **not** propagated automatically. You must use `OtelService.injectTraceContext()` to manually add the `traceparent` header to outgoing requests. ::: Use `OtelService.injectTraceContext()` to propagate trace context to downstream services: ```typescript @Service() export class PaymentClient { @Inject('OtelService') private otelService: OtelService; async charge(payload: ChargeRequest) { const headers = this.otelService.injectTraceContext({ 'Content-Type': 'application/json', }); const res = await fetch('http://payment-service/api/charge', { method: 'POST', headers, body: JSON.stringify(payload), }); return res.json(); } } ``` For full visibility, combine with `withSpan()` to create a dedicated span for the outgoing call: ```typescript async charge(payload: ChargeRequest) { return this.otelService.withSpan('call-payment-service', async (span) => { span.setAttribute('service.target', 'payment-service'); const headers = this.otelService.injectTraceContext(); const res = await fetch('http://payment-service/api/charge', { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); return res.json(); }); } ``` This writes a `traceparent` header in the format `00-{traceId}-{spanId}-{traceFlags}`. The downstream service extracts this header to continue the same trace, enabling end-to-end distributed tracing across microservices. ## Microservice Messaging Instrumentation For Asena's [microservice layer](/docs/concepts/microservices), trace propagation is fully automatic — register the `otelMessaging()` interceptor in your `transport()` config: ```typescript import { otelMessaging } from '@asenajs/asena-otel'; import { RedisMicroserviceTransport } from '@asenajs/asena-redis'; @Config() export class AppConfig extends ConfigService { public transport() { return { microservice: new RedisMicroserviceTransport( { url: 'redis://localhost:6379' }, // connection { serviceName: 'order-service' }, // required options ), interceptors: [otelMessaging({ system: 'redis' })], }; } } ``` What it does: - **Outgoing `send`/`emit`**: opens a `PRODUCER` span (`send order.create` / `publish order.created`) and injects `traceparent`/`tracestate` into the message headers. Because the interceptor wraps the whole operation, RPC spans carry the full round-trip duration and error state. - **Incoming handlers**: extracts the upstream context from the message headers and runs the handler inside a `CONSUMER` span (`process order.create`) — services join into **one distributed trace** with no manual propagation. - **Attributes**: `messaging.system`, `messaging.destination.name`, `messaging.operation.type`, `messaging.message.id`; redeliveries set `messaging.redelivery_count`. Register the interceptor in **every** service (producers and consumers alike) so the chain stays unbroken end-to-end. ## Testing Use `InMemorySpanExporter` and `InMemoryMetricExporter` for testing: ```typescript import { Otel, OtelTracingPostProcessor } from '@asenajs/asena-otel'; import { InMemorySpanExporter } from '@opentelemetry/sdk-trace-base'; import { AggregationTemporality, InMemoryMetricExporter, PeriodicExportingMetricReader, } from '@opentelemetry/sdk-metrics'; const spanExporter = new InMemorySpanExporter(); // InMemoryMetricExporter requires an aggregation temporality const metricExporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE); const metricReader = new PeriodicExportingMetricReader({ exporter: metricExporter, exportIntervalMillis: 100, }); @Otel({ serviceName: 'test-service', traceExporter: spanExporter, metricReader, autoTrace: { services: true, controllers: true }, }) export class TestOtel extends OtelTracingPostProcessor {} ``` After making requests, flush spans (`BatchSpanProcessor` is lazy): ```typescript import { trace } from '@opentelemetry/api'; const provider = trace.getTracerProvider() as any; await provider.forceFlush?.(); const spans = spanExporter.getFinishedSpans(); ``` For metrics: ```typescript await metricReader.forceFlush(); const metrics = metricExporter.getMetrics(); ``` ### Verifying Parent-Child Hierarchy In OpenTelemetry SDK v2, use `parentSpanContext` (not `parentSpanId`): ```typescript import { SpanKind } from '@opentelemetry/api'; const httpSpan = spans.find(s => s.kind === SpanKind.SERVER); const serviceSpan = spans.find(s => s.name.includes('UserService')); // Same trace expect(serviceSpan.spanContext().traceId).toBe(httpSpan.spanContext().traceId); // Parent-child link (SDK v2) expect(serviceSpan.parentSpanContext?.spanId).toBe(httpSpan.spanContext().spanId); ``` ## Best Practices ### 1. Keep @Otel Class in a Dedicated File ```typescript // ✅ Good: src/otel/AppOtel.ts — easy to find and modify @Otel({ ... }) export class AppOtel extends OtelTracingPostProcessor {} ``` ### 2. Use Sampler in Production ```typescript // ✅ Good: control trace volume @Otel({ sampler: ratioBasedSampler(0.1), // ... }) ``` ### 3. Exclude Health and Metrics Routes ```typescript // ✅ Good: avoid noise from readiness probes @Otel({ ignoreRoutes: ['/health', '/ready', '/metrics'], // ... }) ``` ### 4. Inject Trace Context for Outgoing Calls ```typescript // ✅ Good: propagate context to downstream services const headers = this.otelService.injectTraceContext(); await fetch('http://other-service/api', { headers }); ``` ## Related Documentation - [PostProcessor](/docs/concepts/post-processor) — How PostProcessors work in Asena - [Services](/docs/concepts/services) — Service layer architecture - [Middleware](/docs/concepts/middleware) — Middleware system and registration - [Dependency Injection](/docs/concepts/dependency-injection) — IoC container and `@Inject` - [Configuration](/docs/guides/configuration) — Server configuration with `@Config` --- **Next Steps:** - Set up [Middleware](/docs/concepts/middleware) for CORS and rate limiting - Learn about [Services](/docs/concepts/services) and dependency injection - Configure [Sampling](#sampling) for production deployments --- # Asena Redis Section: Official Packages Source: https://asena.sh/raw/packages/redis.md # Asena Redis Redis integration for AsenaJS — service client with built-in multi-pod WebSocket transport. Your `@Redis` decorated service gives you full Redis operations with automatic IoC registration. For multi-pod deployments, `RedisTransport` synchronizes WebSocket messages across instances via Redis pub/sub. ## Features - **Decorator-Based Setup** - `@Redis` decorator handles IoC registration and connection lifecycle - **Dual Adapter Support** - Bun native `RedisClient` (default) and `redis` (node-redis) package - **Multi-Pod WebSocket Transport** - Synchronize WebSocket messages across pods via Redis pub/sub - **Full Redis Operations** - String, Hash, Set, Key, and raw command support - **Binary Data Support** - ArrayBuffer and Uint8Array transport with Base64 encoding - **Zero Runtime Dependencies** - Only peer deps (asena, reflect-metadata) ## Installation ```bash bun add @asenajs/asena-redis ``` For node-redis adapter (optional): ```bash bun add @asenajs/asena-redis redis ``` **Requirements:** - [Bun](https://bun.sh) v1.3.12 or higher - [@asenajs/asena](https://github.com/AsenaJs/Asena) v0.9.0 or higher ## Quick Start ### 1. Create Redis Service ```typescript import { Redis, AsenaRedisService } from '@asenajs/asena-redis'; @Redis({ config: { url: 'redis://localhost:6379' }, name: 'AppRedis', }) export class AppRedis extends AsenaRedisService { async getOrSet(key: string, factory: () => Promise, ttl?: number): Promise { const cached = await this.get(key); if (cached) return cached; const value = await factory(); await this.set(key, value, ttl); return value; } } ``` Asena automatically discovers it — that's it. ### 2. Inject and Use ```typescript import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; @Service('CacheService') export class CacheService { @Inject('AppRedis') private redis: AppRedis; async getUserName(id: string): Promise { return this.redis.getOrSet(`user:${id}`, async () => { // fetch from database... return 'John'; }, 60); } } ``` ::: tip The `@Redis` decorator automatically handles connection during IoC initialization and disconnection on server shutdown. You don't need to manage the lifecycle manually. ::: ## Adapter Selection By default, `@asenajs/asena-redis` uses Bun's native `RedisClient`. For environments requiring the `redis` (node-redis) package: ```typescript @Redis({ config: { url: 'redis://localhost:6379' }, adapter: 'node-redis', }) export class AppRedis extends AsenaRedisService {} ``` | Adapter | Package | Best For | |:--------|:--------|:---------| | `'bun'` (default) | None (Bun built-in) | Bun runtime, maximum performance | | `'node-redis'` | `redis` ^5.11.0 | Node.js compatibility, Redis modules | ## API Reference ### String Operations | Method | Parameters | Returns | Description | |:-------|:-----------|:--------|:------------| | `get(key)` | `key: string` | `string \| null` | Get string value | | `set(key, value, ttl?)` | `key: string, value: string, ttl?: number` | `void` | Set value with optional TTL (seconds) | | `del(...keys)` | `...keys: string[]` | `number` | Delete keys, returns count | | `exists(key)` | `key: string` | `boolean` | Check if key exists | | `incr(key)` | `key: string` | `number` | Increment counter | | `decr(key)` | `key: string` | `number` | Decrement counter | | `expire(key, seconds)` | `key: string, seconds: number` | `void` | Set expiration | | `ttl(key)` | `key: string` | `number` | Get remaining TTL | | `keys(pattern)` | `pattern: string` | `string[]` | Find keys by pattern | ### Hash Operations | Method | Parameters | Returns | Description | |:-------|:-----------|:--------|:------------| | `hget(key, field)` | `key: string, field: string` | `string \| null` | Get hash field | | `hmset(key, fields)` | `key: string, fields: string[]` | `void` | Set multiple fields (`['f1', 'v1', 'f2', 'v2']`) | | `hmget(key, fields)` | `key: string, fields: string[]` | `(string \| null)[]` | Get multiple fields | ### Set Operations | Method | Parameters | Returns | Description | |:-------|:-----------|:--------|:------------| | `sadd(key, member)` | `key: string, member: string` | `number` | Add member to set | | `srem(key, member)` | `key: string, member: string` | `number` | Remove member from set | | `smembers(key)` | `key: string` | `string[]` | Get all members | | `sismember(key, member)` | `key: string, member: string` | `boolean` | Check membership | ### Raw & Lifecycle | Method | Parameters | Returns | Description | |:-------|:-----------|:--------|:------------| | `send(command, args)` | `command: string, args: string[]` | `any` | Execute raw Redis command | | `client` | — | `RedisClientAdapter` | Access underlying client | | `createSubscriber()` | — | `RedisClientAdapter` | Create duplicate connection for pub/sub | | `testConnection()` | — | `boolean` | Returns `true` if connected | | `disconnect()` | — | `void` | Close connection | ## Configuration ### RedisConfig ```typescript interface RedisConfig { // Connection url?: string; // redis[s]://[[username][:password]@][host][:port][/db] host?: string; // default: 'localhost' port?: number; // default: 6379 username?: string; password?: string; db?: number; // Timeouts & Reconnection connectionTimeout?: number; // Connection timeout in ms (default: 10000) idleTimeout?: number; // Idle timeout in ms (Bun only, default: 0) autoReconnect?: boolean; // Auto-reconnect on disconnect (default: true) maxRetries?: number; // Max reconnection attempts (default: 10) // Behavior enableOfflineQueue?: boolean; // Queue commands when disconnected (default: true) enableAutoPipelining?: boolean; // Automatic command pipelining (Bun only, default: true) // TLS tls?: boolean | TLSOptions; // Identification name?: string; // Service name for logging } ``` ::: info Bun-Only Features `idleTimeout` and `enableAutoPipelining` are Bun-only features and are silently ignored when using the `node-redis` adapter. ::: ### @Redis Decorator Options ```typescript @Redis({ config: RedisConfig, // Redis connection configuration adapter?: 'bun' | 'node-redis', // Client adapter (default: 'bun') name?: string, // Service name for IoC registration }) ``` ## Multi-Pod WebSocket Transport `RedisTransport` synchronizes WebSocket messages across multiple server instances using Redis pub/sub. This enables room-based messaging, broadcasting, and direct socket messaging to work seamlessly across pods. ### Setup Configure the transport in your `@Config` class's `transport()` method: ```typescript import { Config } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { ConfigService } from '@asenajs/hono-adapter'; // or '@asenajs/ergenecore' import { RedisTransport } from '@asenajs/asena-redis'; @Config() export class AppConfig extends ConfigService { @Inject('AppRedis') private redis: AppRedis; public transport() { return new RedisTransport(this.redis); } } ``` Or without an existing Redis service: ```typescript public transport() { return new RedisTransport({ url: 'redis://localhost:6379' }); } ``` ### How It Works Each server instance gets a unique pod ID. When a WebSocket message is published: 1. The message is delivered locally via `server.publish()` 2. The message is sent to Redis pub/sub with the originating pod ID 3. Other pods receive the message and deliver it to their local sockets 4. Messages from the same pod are deduplicated automatically ### Options ```typescript new RedisTransport(source, { channel: 'asena:ws:transport', // Redis pub/sub channel (default) }); ``` ::: tip No Code Changes Needed Your WebSocket services, Ulak messaging, and room management work exactly the same with `RedisTransport`. The transport layer is transparent — just configure it in `@Config` and multi-pod support is enabled automatically. ::: ## Microservice Transport (Redis Streams) `RedisMicroserviceTransport` is the production-grade broker implementation for Asena's [microservice layer](/docs/concepts/microservices) — request/response and events between independent Asena services, built on Redis Streams consumer groups. ### Setup ```typescript import { Config } from '@asenajs/asena/decorators'; import { RedisMicroserviceTransport } from '@asenajs/asena-redis'; @Config() export class AppConfig extends ConfigService { public transport() { return { microservice: new RedisMicroserviceTransport( { url: 'redis://localhost:6379' }, { serviceName: 'order-service' }, // serviceName REQUIRED - consumer group identity ), }; } } ``` `serviceName` is the consumer group: all replicas of the same service **must** share it, different services **must** differ (each service group receives its own copy of every event). ### Reusing Your @Redis Service If your app already has a `@Redis` service, you do not need to repeat the connection settings. Inject it into the config class and pass it where the config object would go — the transport then reuses its connection instead of opening its own: ```typescript import { Config } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { ConfigService } from '@asenajs/hono-adapter'; // or '@asenajs/ergenecore' import { RedisMicroserviceTransport } from '@asenajs/asena-redis'; import { AppRedis } from '../redis/AppRedis'; @Config() export class AppConfig extends ConfigService { @Inject(AppRedis) private redis: AppRedis; public transport() { return { microservice: new RedisMicroserviceTransport(this.redis, { serviceName: 'order-service', }), }; } } ``` URL, credentials and TLS now live in exactly one place. Config classes are prepared after user components are initialized, so the injected service is already connected by the time `transport()` runs. ::: tip The same works for Kafka [`KafkaMicroserviceTransport`](/docs/packages/kafka#reusing-your-kafka-service) accepts an `AsenaKafkaService` in the same position. ::: ### How It Works | Purpose | Redis Key | Mechanism | |---|---|---| | Events | `asena:ms:evt` (stream) | `XADD` + one consumer group per service; wildcard patterns matched locally; at-least-once with ACK | | Requests | `asena:ms:req:` (stream per pattern) | Consumer group distributes each request to exactly one replica | | Replies | `asena:ms:reply:` (pub/sub) | Caller is alive and waiting — persistence unnecessary | | Dead letters | `asena:ms:dlq` (stream) | Poison events after `maxRetries`, with provenance fields | A background sweep (`XPENDING` + `XCLAIM`) rescues entries from crashed replicas, counts delivery attempts and moves poison events to the DLQ. **RPC errors are final** — the caller receives an `ok:false` reply and the entry is ACKed; only event handlers are retried. ### Options | Option | Default | Description | |---|---|---| | `serviceName` | — (required) | Consumer group identity shared by replicas | | `streamPrefix` | `'asena:ms'` | Key prefix for streams/channels | | `requestTimeout` | `30000` | Default `send()` reply timeout (ms) | | `maxRetries` | `3` | Event delivery attempts before DLQ (events only) | | `claimIdleMs` | `60000` | Idle time before the sweep reclaims a pending entry — handler duration must stay below this | | `maxStreamLength` | `100000` | `XADD MAXLEN ~` trim — bounds memory AND offline tolerance | | `blockMs` | `5000` | `XREADGROUP BLOCK` duration | | `count` | `16` | Max entries fetched per read | | `maxInFlight` | `32` | Concurrent handler limit (backpressure) | | `handlerTimeout` | `min(30000, claimIdleMs)` | Per-handler execution timeout — explicit values above `claimIdleMs` throw at construction; on timeout the dispatch is rejected but the handler keeps running | | `drainTimeout` | `10000` | Graceful drain window on shutdown | | `commandTimeout` | `10000` | Watchdog bound for publisher commands (`XADD`, `XACK`, `XCLAIM`, ...) — a command outliving it means the connection died mid-flight, so the connection is discarded and replaced | ### Delivery Guarantees - **Events: at-least-once.** Messages survive restarts and deploys (within the `maxStreamLength` trim window). Duplicate delivery is possible — [write idempotent handlers](/docs/concepts/microservices#idempotent-handlers). - **RPC: at-most-once per attempt.** Handler errors are final; retrying is the caller's decision. - **Replies are not durable.** A reply is a plain `PUBLISH` on the caller's reply channel and the request entry is ACKed either way — Redis pub/sub has no replay, so a reply published while the caller has no live subscription on that channel is **dropped and never redelivered**. The caller sees a `TIMEOUT`. This is the one place where the transport is deliberately lossy; see [Reply loss across an outage](#reply-loss-across-an-outage) for exactly when it bites. - **Readiness means "this instance can complete a `send()`", not "a socket is open".** `isConnected` requires the publisher connection **and** a reply subscription Redis is actually serving — the socket open *and* its `SUBSCRIBE` acknowledged since the last reconnect. Those are different facts: Bun's `RedisClient` reconnects on its own but does not restore subscriptions, so the replay costs a further round trip after the socket reports open, and the publisher is typically back before it finishes. Expect an instance to stay `503` a little past its reconnect; that is the honest signal, and it is what keeps a load balancer from releasing traffic at a pod whose replies are still being dropped. - **Reconnect:** on connection loss the transport reports `isConnected: false`, retries with capped backoff, and continues where it left off — Streams hold the messages meanwhile. If the consumer group itself was lost (Redis restart without persistence, `FLUSHALL`), the transport re-creates it automatically from the beginning of the stream, so surviving entries are replayed — but data already trimmed or flushed from the stream is gone. - **Connection poisoning defense:** Bun's `RedisClient` loses in-flight commands when a socket dies and afterwards resolves replies against the wrong promises. The transport guards both of its command connections against this — the blocking consumer via a wedge watchdog, the publisher via connection-loss detection plus the `commandTimeout` bound — and replaces a poisoned connection with a fresh one. The publisher is always a transport-owned duplicate, so a shared `AsenaRedisService` client is never touched. - **Retry latency is sweep-driven, not immediate.** A failed event is redelivered by the sweep once its idle time exceeds `claimIdleMs` — with defaults the first retry lands after ~60–90s and a poison message reaches the DLQ after `maxRetries` sweep cycles (minutes, not seconds). Coming from BullMQ/NestJS-style immediate retries, plan for this or lower `claimIdleMs`. - **No ordering guarantee.** Entries within a read batch are dispatched in parallel; consumers must not rely on event order. ### Reply Loss Across an Outage Readiness is honest about the reply channel, but it does **not** make the reply channel durable. A reply is lost whenever it is published at a moment when the caller has no subscription on its reply channel: | Situation | Outcome | |---|---| | `send()` in flight when the connection drops | **Lost.** The responder publishes into an empty channel and ACKs the request; the caller gets a `TIMEOUT` after `requestTimeout`. | | `send()` issued while the instance reports `503` | Your own choice — the endpoint told you it could not serve. | | `send()` issued after the instance reports `200` | Delivered. This is what readiness now guarantees; before it did not. | So an RPC that spans a Redis outage must be treated as **retryable, not reliable**. Make handlers idempotent and retry the `TIMEOUT` at the caller, or use `emit()` (Streams, at-least-once) where losing the call is unacceptable. Two narrower residuals are worth knowing about: - Readiness is driven by connection events, so a health check that lands between the socket being marked open and the connect event being dispatched can read `200` for at most one event-loop turn. - A **custom** `RedisClientAdapter` that implements `onConnected` but neither `onResubscribed` nor subscription restoration of its own is taken at its word on connect. Both built-in adapters (`BunRedisAdapter`, `NodeRedisAdapter`) report honestly. ::: warning Liveness must be more forgiving than readiness `isConnected` feeds Asena's health endpoint: `503` now means "the publisher is down **or** this instance's reply channel is not being served", i.e. a `send()` issued right now cannot complete. Wire it into your orchestrator's **readiness** probe. Because an instance stays `503` until its reply subscription is acknowledged — not merely until its socket reconnects — a liveness probe sharing the same thresholds will restart pods that were about to recover on their own. ::: ::: info Durable replies are not in 0.9.0 Moving replies onto a Stream would close the loss entirely, at the cost of a per-request write and trim. It is deliberately deferred; the table above is the current contract. ::: ## Best Practices ### 1. Name Your Redis Services ```typescript // ✅ Good: Named service for clear IoC registration @Redis({ config: { url: 'redis://localhost:6379' }, name: 'AppRedis', }) export class AppRedis extends AsenaRedisService {} // ❌ Bad: Unnamed service (defaults to class name, but less explicit) @Redis({ config: { url: 'redis://localhost:6379' } }) export class AppRedis extends AsenaRedisService {} ``` ### 2. Use Cache Patterns ```typescript // ✅ Good: getOrSet pattern for caching async getOrSet(key: string, factory: () => Promise, ttl?: number) { const cached = await this.get(key); if (cached) return cached; const value = await factory(); await this.set(key, value, ttl); return value; } ``` ### 3. Health Checks ```typescript // ✅ Good: Use testConnection() in health endpoints @Get('/health') async health(context: Context) { const redisOk = await this.redis.testConnection(); return context.send({ redis: redisOk ? 'up' : 'down' }); } ``` ::: warning `testConnection()` is not a microservice readiness check It `PING`s the cache client and says nothing about whether this instance's **reply channel** is being served, which is what decides whether a `send()` can complete. For an instance running `RedisMicroserviceTransport`, the readiness signal is the transport's `isConnected` (already wired into Asena's health endpoint) — see [Delivery Guarantees](#delivery-guarantees). ::: ## Related Documentation - [WebSocket](/docs/concepts/websocket) - WebSocket implementation guide - [Microservices](/docs/concepts/microservices) - Microservice messaging concepts and delivery semantics - [Configuration](/docs/guides/configuration) - Server configuration with `transport()` - [Services](/docs/concepts/services) - Service layer architecture - [Dependency Injection](/docs/concepts/dependency-injection) - IoC container --- **Next Steps:** - Set up [WebSocket](/docs/concepts/websocket) real-time communication - Configure [multi-pod transport](/docs/concepts/websocket#multi-pod-websocket) for scaling - Learn about [Services](/docs/concepts/services) --- # Asena Kafka Section: Official Packages Source: https://asena.sh/raw/packages/kafka.md # Asena Kafka This package gives you **two independent things**: 1. **A Kafka client for your application** — the `@Kafka` decorated service. Produce to and consume from your own topics, with connection lifecycle and IoC registration handled for you. 2. **A microservice transport** — `KafkaMicroserviceTransport` plugs Kafka into Asena's [microservice layer](/docs/concepts/microservices) so independent Asena services can call each other (RPC) and broadcast events. If you only need to publish and read Kafka messages, **part 1 is all you need** — start at [Quick Start](#quick-start) and stop after [Service Client](#service-client). Read the [Microservice Transport](#microservice-transport) section when you want service-to-service messaging on top of Kafka. ## Features - **Decorator-Based Setup** - `@Kafka` handles IoC registration and connection lifecycle - **Full Producer/Consumer/Admin Access** - the underlying kafkajs objects, with lifecycle you control - **Microservice Transport** - full `MicroserviceTransport` implementation: RPC, event fan-out with wildcards, retry + DLQ - **Broker-Tracked Attempts** - `context.attempt` survives crashes and rebalances (persisted in offset-commit metadata) - **External Topic Interop** - consume from and emit to envelope-less foreign topics - **Zero Runtime Dependencies** - only peer deps (asena, kafkajs, reflect-metadata) ## Installation ```bash bun add @asenajs/asena-kafka kafkajs ``` **Requirements:** - [Bun](https://bun.sh) v1.3.12 or higher - [@asenajs/asena](https://github.com/AsenaJs/Asena) v0.9.0 or higher - [kafkajs](https://kafka.js.org) v2.2 or higher ::: warning Broker compatibility Use Apache Kafka **2.8 – 3.9**. Kafka 4.0 removed old protocol API versions (KIP-896) and kafkajs 2.2.4 has reported incompatibilities — pin your broker to 3.9.x. See [Client Roadmap](#client-roadmap) for why kafkajs is the client today. ::: For local development, a single-node KRaft container is enough: ```bash docker run -d --name asena-kafka --restart always -p 9092:9092 \ -e KAFKA_NODE_ID=1 -e KAFKA_PROCESS_ROLES=broker,controller \ -e KAFKA_LISTENERS=PLAINTEXT://:9092,CONTROLLER://:9093 \ -e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9092 \ -e KAFKA_CONTROLLER_LISTENER_NAMES=CONTROLLER \ -e KAFKA_CONTROLLER_QUORUM_VOTERS=1@localhost:9093 \ -e KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT \ -e KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1 \ -e KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR=1 \ -e KAFKA_TRANSACTION_STATE_LOG_MIN_ISR=1 \ -e KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS=0 \ -e KAFKA_GROUP_MIN_SESSION_TIMEOUT_MS=1000 \ apache/kafka:3.9.1 ``` ## Quick Start A complete produce-and-consume round trip in three steps. ### 1. Define a Kafka service Extend `AsenaKafkaService` and decorate with `@Kafka`. The decorator registers the class as an Asena `@Service`, so it is injectable everywhere, and connects it during bootstrap. ```typescript // src/kafka/AppKafka.ts import { Kafka, AsenaKafkaService } from '@asenajs/asena-kafka'; @Kafka({ config: { brokers: ['localhost:9092'], clientId: 'my-app', }, name: 'AppKafka', // the injection key }) export class AppKafka extends AsenaKafkaService {} ``` That is a complete, working service — every method below is inherited. ### 2. Produce a message Inject the service and call `sendMessage(topic, messages)`. Values must be strings or `Buffer`s, so serialize your payload yourself. ```typescript // src/services/AuditService.ts import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { AppKafka } from '../kafka/AppKafka'; interface AuditEntry { userId: string; action: string; } @Service() export class AuditService { @Inject(AppKafka) private kafka: AppKafka; async record(entry: AuditEntry) { await this.kafka.sendMessage('audit.log', [ { key: entry.userId, // same key -> same partition -> ordered per user value: JSON.stringify(entry), headers: { source: 'my-app' }, }, ]); } } ``` ::: tip Add domain methods to the service `AppKafka` is a normal class — putting `publishAudit()` on it instead of calling `sendMessage` from every caller keeps topic names and serialization in one place. ::: ### 3. Consume messages `createConsumer()` returns a **caller-owned** consumer: you connect it, subscribe, run it, and disconnect it. A `@Service` with `@PostConstruct` is the natural home. ```typescript // src/services/AuditConsumer.ts import { Service } from '@asenajs/asena/decorators'; import { Inject, PostConstruct } from '@asenajs/asena/decorators/ioc'; import type { KafkaConsumerLike } from '@asenajs/asena-kafka'; import { AppKafka } from '../kafka/AppKafka'; @Service() export class AuditConsumer { @Inject(AppKafka) private kafka: AppKafka; private consumer: KafkaConsumerLike; @PostConstruct() async start() { // groupId is required - replicas sharing it split the partitions between them this.consumer = this.kafka.createConsumer({ groupId: 'audit-archiver' }); await this.consumer.connect(); await this.consumer.subscribe({ topics: ['audit.log'], fromBeginning: false }); await this.consumer.run({ eachMessage: async ({ topic, partition, message }) => { const entry = JSON.parse(message.value!.toString()); console.log(`[${topic}/${partition}@${message.offset}]`, entry); }, }); } async stop() { await this.consumer?.disconnect(); } } ``` ::: warning The topic must exist The service never auto-creates topics (`allowAutoTopicCreation: false`). Create them with your broker tooling, or via `createAdmin()`: ```typescript const admin = this.kafka.createAdmin(); await admin.connect(); await admin.createTopics({ topics: [{ topic: 'audit.log', numPartitions: 3 }] }); await admin.disconnect(); ``` ::: ## Service Client ### API Reference | Method | Description | |:-------|:------------| | `sendMessage(topic, messages)` | Produce via the service's default producer | | `createProducer()` | New caller-owned producer | | `createConsumer(config)` | New caller-owned consumer (`groupId` required) | | `createAdmin()` | New caller-owned admin client | | `client` | The underlying `KafkaClientAdapter` | | `config` | The `KafkaConfig` used | | `disconnect()` | Close the default producer | | `testConnection()` | Bounded broker probe (3s), returns `boolean` | Objects returned by `createProducer` / `createConsumer` / `createAdmin` are **yours** — the service will not connect or disconnect them. `disconnect()` only closes the service's own default producer. ### Configuration `KafkaConfig` is passed straight through to the kafkajs `Kafka` constructor, plus Asena's display `name`. | Option | Type | Description | |:-------|:-----|:------------| | `brokers` | `string[]` | **Required.** Seed broker list — one entry is enough, the client discovers the rest | | `clientId` | `string` | Identifies your app in broker logs and quotas. Default `'asena-kafka'` | | `ssl` | `boolean \| object` | TLS settings | | `sasl` | `object` | SASL authentication (see below) | | `connectionTimeout` | `number` | TCP connect timeout in ms | | `requestTimeout` | `number` | Per-request timeout in ms | | `retry` | `object` | kafkajs retry policy (`retries`, `initialRetryTime`, …) | | `logLevel` | `logLevel` | kafkajs log level. Default `NOTHING` | | `name` | `string` | Asena display name used in connection logs — **not** forwarded to kafkajs | ### Production Config A managed cluster (Confluent Cloud, Aiven, MSK) typically needs SASL over TLS and multiple seed brokers: ```typescript import { Kafka, AsenaKafkaService } from '@asenajs/asena-kafka'; @Kafka({ config: { brokers: [ 'broker-1.example.com:9093', 'broker-2.example.com:9093', 'broker-3.example.com:9093', ], clientId: 'order-service', ssl: true, sasl: { mechanism: 'scram-sha-512', // or 'plain', 'scram-sha-256' username: Bun.env.KAFKA_USERNAME!, password: Bun.env.KAFKA_PASSWORD!, }, connectionTimeout: 10_000, requestTimeout: 30_000, retry: { retries: 8, initialRetryTime: 300 }, name: 'Orders', }, name: 'AppKafka', }) export class AppKafka extends AsenaKafkaService {} ``` ::: danger Never hardcode credentials Read them from the environment (`Bun.env`) and keep them out of version control. ::: ### Multiple Kafka Services Define one class per cluster and give each a distinct `name` — that name is the injection key. ```typescript @Kafka({ config: { brokers: ['events:9092'] }, name: 'EventsKafka' }) export class EventsKafka extends AsenaKafkaService {} @Kafka({ config: { brokers: ['logs:9092'] }, name: 'LogsKafka' }) export class LogsKafka extends AsenaKafkaService {} @Service() export class Reporter { @Inject(EventsKafka) private events: EventsKafka; @Inject(LogsKafka) private logs: LogsKafka; } ``` ### Health Checks `testConnection()` performs a bounded (3 second) `listTopics` probe and never throws: ```typescript @Service() export class KafkaHealth { @Inject(AppKafka) private kafka: AppKafka; async check(): Promise<'up' | 'down'> { return (await this.kafka.testConnection()) ? 'up' : 'down'; } } ``` ### Reusing an Existing Client The `@Kafka` decorator's `client` option accepts any `KafkaClientAdapter` and bypasses connection construction entirely — the service adopts it as-is and only calls `connect()` if it is not already connected. Use it when the connection must come from somewhere else: a client you configured by hand, a test double, or a different Kafka library. `KafkaClientAdapter` is a small interface — `isConnected`, `connect`, `disconnect`, `producer`, `consumer`, `admin`, `send`: ```typescript import { Kafka, AsenaKafkaService, KafkajsAdapter } from '@asenajs/asena-kafka'; import type { KafkaClientAdapter } from '@asenajs/asena-kafka'; // A pre-built adapter - e.g. constructed from secrets fetched at startup const sharedAdapter: KafkaClientAdapter = new KafkajsAdapter({ brokers: ['localhost:9092'], clientId: 'shared', }); @Kafka({ config: { brokers: ['localhost:9092'] }, client: sharedAdapter, // adopted as-is; config is not used to connect name: 'AppKafka', }) export class AppKafka extends AsenaKafkaService {} ``` ::: tip Don't repeat broker config for the transport If your goal is simply to avoid writing brokers twice — once for the service, once for the microservice transport — you don't need this option at all. Hand the `@Kafka` service itself to the transport: see [Reusing Your @Kafka Service](#reusing-your-kafka-service). ::: ## Microservice Transport ### When You Need This Everything above is **raw Kafka**: you pick topic names, you serialize, you manage consumers. That is the right tool for integrating with existing topics and pipelines. The microservice transport is for something different: **Asena services calling each other.** It gives you request/response with correlation and timeouts, event fan-out with wildcard patterns, automatic retries with a dead-letter topic, and delivery-attempt tracking — over topics it creates and owns. You write `@MessagePattern` / `@EventPattern` handlers and never touch a topic name. Both can coexist in one application. ### Setup ```typescript import { Config } from '@asenajs/asena/decorators'; import { ConfigService } from '@asenajs/hono-adapter'; // or '@asenajs/ergenecore' import { KafkaMicroserviceTransport } from '@asenajs/asena-kafka'; @Config() export class AppConfig extends ConfigService { public transport() { return { microservice: new KafkaMicroserviceTransport( { brokers: ['localhost:9092'] }, { serviceName: 'order-service' }, // REQUIRED - consumer group identity ), }; } } ``` `serviceName` is the consumer group identity: - All replicas of the same service **must** share it — they then split partitions between them. - Different services **must** differ — each service group receives its own copy of every event. The actual Kafka group id is prefix-scoped (`asena.ms.order-service`) because consumer groups are cluster-global. ### Reusing Your @Kafka Service If your app already has a `@Kafka` service, you do not need to repeat the broker configuration. Inject it into the config class and hand it to the transport — the transport borrows its adapter (and still creates its **own** producer, consumers and admin, so lifecycles never collide): ```typescript import { Config } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { ConfigService } from '@asenajs/hono-adapter'; // or '@asenajs/ergenecore' import { KafkaMicroserviceTransport } from '@asenajs/asena-kafka'; import { AppKafka } from '../kafka/AppKafka'; @Config() export class AppConfig extends ConfigService { @Inject(AppKafka) private kafka: AppKafka; public transport() { return { microservice: new KafkaMicroserviceTransport(this.kafka, { serviceName: 'order-service', }), }; } } ``` Broker list, credentials and TLS now live in exactly one place. Config classes are prepared after user components are initialized, so the injected service is already connected by the time `transport()` runs. ::: tip The same works for Redis [`RedisMicroserviceTransport`](/docs/packages/redis#microservice-transport-redis-streams) accepts an `AsenaRedisService` in the same position. ::: ### Topics It Owns | Purpose | Kafka Topic | Mechanism | |---|---|---| | Events | `asena.ms.evt` (shared) | Keyless round-robin production; one consumer group per service; wildcard patterns matched locally; at-least-once with committed offsets | | Requests | `asena.ms.req.` (topic per pattern) | Consumer group distributes each request to exactly one replica | | Replies | `asena.ms.reply` (shared) | Per-instance ephemeral consumer group filters by correlationId | | Dead letters | `asena.ms.dlq` | Poison events after `maxRetries`, with provenance headers | The transport creates these explicitly and waits for partition leaders before consuming. ### Delivery Attempts **What you get:** `context.attempt` is a trustworthy retry counter. It keeps counting correctly even if the process crashes or the consumer group rebalances mid-handler — and it needs no database or Redis on the side. **How it works:** Kafka has no per-message delivery counter, so the transport stores one in the offset-commit **metadata**. Before dispatching the record at offset X it commits X with `{"a":attempt}`; on success it commits X+1 clean. A crash therefore redelivers exactly that record, and whichever consumer picks it up derives `attempt = a + 1` when it joins the group. The cost is two commits per record per partition. Throughput scales by adding partitions. ### Retries and DLQ - A failed **event** handler leaves the offset uncommitted. The partition is paused, seeked back, and re-fetched after `retryBackoffMs` — the broker genuinely redelivers, sharing one mechanism with crash recovery. - After `maxRetries` the event is produced to the DLQ topic with provenance headers (`origin_stream`, original value and headers preserved). - **RPC errors are final.** The caller receives the error and the offset is committed; only event handlers are retried. Retrying a request is the caller's decision. ### Options | Option | Default | Description | |---|---|---| | `serviceName` | — (required) | Consumer group identity shared by replicas | | `topicPrefix` | `'asena.ms'` | Topic/group namespace (`[a-zA-Z0-9._-]` only) | | `requestTimeout` | `30000` | Default `send()` reply timeout (ms) | | `maxRetries` | `3` | Event delivery attempts before DLQ (events only) | | `retryBackoffMs` | `5000` | Pause before a failed event's partition is resumed for the retry fetch | | `handlerTimeout` | `min(30000, sessionTimeout)` | Per-handler timeout — explicit values above `sessionTimeout` throw at construction (kafkajs cannot heartbeat during a handler; a longer handler gets the member evicted and the record concurrently redelivered) | | `maxInFlight` | `16` | Partitions processed concurrently (per-partition processing stays sequential) | | `drainTimeout` | `10000` | Graceful drain window on shutdown | | `sessionTimeout` | `30000` | Consumer group session timeout — crash detection speed | | `heartbeatInterval` | `3000` | Consumer heartbeat (keep ≤ 1/3 of sessionTimeout) | | `rebalanceTimeout` | `60000` | Max rebalance duration | | `maxWaitTimeInMs` | `1000` | Idle fetch long-poll (boot readiness / shutdown responsiveness) | | `eventPartitions` / `requestPartitions` / `replyPartitions` | `4` | Partition counts for transport-created topics | | `replicationFactor` | `-1` | Broker default | | `healthCheckIntervalMs` | `5000` | Interval of the active broker probe. A failed probe is **one** of the inputs to `isConnected` — the reply consumer's fetch loop is the other (see [Delivery Guarantees](#delivery-guarantees)) | | `external` | — | Foreign-topic interop: `{ topics: (string \| { name, keyHeader? })[], fromBeginning? }` — see [External Topics](#external-topics-interop) | ### Delivery Guarantees - **Events: at-least-once.** Messages survive restarts and deploys. Duplicate delivery is possible — [write idempotent handlers](/docs/concepts/microservices#idempotent-handlers) keyed on `context.messageId` (one id per emit, identical for every group and every redelivery). - **RPC: at-most-once per attempt.** Handler errors are final. Requests older than the caller's own timeout are **dropped without execution** — a restarting service never burns through a backlog of dead RPCs. - **First boot starts at latest.** A brand-new consumer group ignores messages produced before the service ever existed — deploy consumers before producers. The start position is pinned to a committed offset immediately, so later crash-restarts can never skip records. - **Reconnect:** on broker loss the transport reports `isConnected: false` (health endpoint 503), kafkajs reconnects with its built-in retry, and consumption resumes from committed offsets. - **Readiness means "can serve", not "a broker answers".** `isConnected` requires **both** a passing metadata probe **and** a reply consumer that has rejoined its group and is fetching. The probe alone goes green within about a second of a broker coming back, while the ephemeral reply group can still be rejoining ~20 seconds later — and until it fetches, nothing consumes replies, so every `send()` on that instance times out. Expect an instance to stay 503 for as long as its reply consumer takes to rejoin after an outage; that is the honest signal, and it is what keeps an orchestrator from routing RPC traffic at an instance that cannot complete it. A stall is detected from the fetch loop going quiet for 5 s, so readiness can lag a fresh stall by up to that much — kafkajs's own `CRASH` takes ~7.5 s. - **Replies survive a rejoin.** The reply consumer's start position is committed as an offset, so a rejoin resumes where it left off instead of re-resolving `latest`. A reply produced while the consumer was away is delivered late, never skipped. - **Ordering:** with the default multi-partition event topic, publish order is not preserved end-to-end. Use `eventPartitions: 1` for strict ordering at the cost of parallelism. - **Rolling deploys:** kafkajs uses eager rebalancing — every membership change briefly pauses the group. Graceful shutdown leaves the group cleanly (short pause); SIGKILL costs a full `sessionTimeout`. ## External Topics (interop) Everything above rides Asena's own envelope on Asena-owned topics. The `external` option connects your Asena service to **foreign systems** — a Quarkus/SmallRye service, a CDC pipeline, any plain Kafka producer or consumer — whose topics carry no envelope at all. Say a team runs a Quarkus order service that publishes plain JSON to an `orders` topic and consumes an `invoices` topic. An Asena microservice joins that world with nothing but configuration: ```typescript import { KafkaMicroserviceTransport } from '@asenajs/asena-kafka'; const transport = new KafkaMicroserviceTransport( { brokers: ['localhost:9092'] }, { serviceName: 'billing-service', external: { topics: [ 'orders', // string shorthand { name: 'invoices', keyHeader: 'x-tenant' }, // outbound partition affinity ], fromBeginning: false, // start position on FIRST subscribe (default: latest) }, }, ); ``` Controllers stay completely transport-agnostic — external topics surface through the same decorators: ```typescript import { MessageController } from '@asenajs/asena/decorators'; import { EventPattern } from '@asenajs/asena/microservice'; import type { MessageContext } from '@asenajs/asena/microservice'; @MessageController() export class OrdersListener { // The pattern is the external TOPIC NAME - prefix: false keeps it verbatim @EventPattern({ pattern: 'orders', prefix: false }) async onOrder(order: any, context: MessageContext) { // context.headers = ALL raw record headers - Quarkus's traceparent / // ce-* headers arrive verbatim // context.messageId = the 'mid' header if present, else 'topic:partition:offset' } } ``` ::: danger External topics and the controller prefix The [`@MessageController` prefix is joined onto `@EventPattern` too](/docs/concepts/microservices), so on a prefixed controller `@EventPattern('orders')` registers as `billing.orders` and **stops matching the foreign topic**. This fails quietly: the service boots green, health returns 200, and the topic is simply never consumed. Always pass `prefix: false` for external topic handlers. If you forget, `listen()` prints the reason: ``` KafkaMicroserviceTransport(billing-service): external topic "orders" has no matching event handler - outbound-only (emit works, nothing is consumed) - but "billing.orders" looks like the same handler with the @MessageController prefix joined on; add prefix: false to that @EventPattern to register "orders" verbatim ``` ::: And emitting to the foreign side is a plain `emit` on the topic name: ```typescript await this.ulak.emit('invoices', { invoiceId: 'inv-1' }, { headers: { 'x-tenant': 't-42' } }); ``` **Inbound rules** (external topics with a matching `@EventPattern`): - The dispatch pattern is **always the topic name** — a foreign record's incidental `p` header never steers routing. - The handler must resolve to exactly the topic name, so use `prefix: false` on any prefixed controller. - `context.headers` exposes **all raw record headers**. `context.messageId` honors a `mid` header, otherwise it falls back to the record position `topic:partition:offset` — stable across groups and redeliveries, so [messageId dedup](/docs/concepts/microservices#idempotent-handlers) still works. - Retry, DLQ (`origin_stream` = the foreign topic; value and headers preserved) and `context.attempt` work exactly as for own topics. **Outbound rules** (`emit('topicName')` where the name is configured external): - The value is plain JSON, headers go over **verbatim** — no `p`/`mid`/`h`/`ts` envelope keys the foreign consumer wouldn't understand. - With `keyHeader` set, that header's value becomes the record **key** (partition affinity on the foreign topic); the header itself is still sent. - External topics are **event-only**: `send()` to an external name rejects immediately with `SEND_FAILED` — request/reply needs the envelope. **Boot behavior:** the transport **never creates external topics** — they are foreign property. Subscribed external topics are awaited (bounded) at startup and missing ones fail the boot loudly; configured topics with no matching handler are outbound-only, logged and never an error. `fromBeginning: true` reads each external topic from the earliest retained record on the group's first subscribe. ::: tip Trace continuity with OpenTelemetry With [@asenajs/asena-otel](/docs/packages/opentelemetry) messaging instrumentation active, an inbound Quarkus `traceparent` header is extracted from `context.headers` and your handler joins the foreign trace; on outbound emits the injected `traceparent` travels as a plain header and the Quarkus consumer resumes yours. Distributed tracing works across the framework boundary with zero extra code. ::: ::: warning Position-based ids and replays When a foreign record has no `mid` header, its messageId is derived from the record position. A replay *tool* that re-produces a DLQ'd record creates a new position — and therefore a new messageId. If you replay externally-sourced records, carry your own dedup key in the payload or add a `mid` header at the producer. ::: ::: warning Rolling deploys Changing the `external` list changes the consumer group's subscription. Restart all replicas together for a deterministic assignment instead of mixing generations. ::: ## Best Practices ### 1. Make Event Handlers Idempotent At-least-once delivery means duplicates are normal, not exceptional. Deduplicate on `context.messageId`: ```typescript @MessageController('booking') export class BookingListener { @EventPattern('*') // handles 'booking.*' async onBooking(data: any, context: MessageContext) { if (this.seen.has(context.messageId)) return; // dedup key this.seen.add(context.messageId); // ... } } ``` ### 2. Keep Handlers Below sessionTimeout A handler outliving `sessionTimeout` gets its instance evicted from the group and the record redelivered elsewhere **while it is still running**. Offload long work to a job queue and return quickly. ### 3. Key Your Messages for Ordering Kafka guarantees order within a partition, not across a topic. Give related messages the same `key` (a user id, an order id) so they land on the same partition and stay ordered. ### 4. Watch the Health Endpoint The transport's `isConnected` feeds Asena's health endpoint — `503` means either the broker probe is failing **or** the reply consumer is not fetching, i.e. this instance cannot complete a `send()`. Wire it into your orchestrator's readiness checks. After a broker outage an instance stays 503 until its reply consumer has rejoined, which can take tens of seconds; a liveness probe must be more forgiving than the readiness probe, or the orchestrator will restart pods that were about to recover on their own. ## Troubleshooting ### `TimeoutNegativeWarning` on startup Cosmetic. Bun warns where Node silently clamps negative timers to 1ms; it comes from kafkajs's request queue and behavior is identical. Safe to ignore. ### `There is no leader for this topic-partition` / `topic not present in metadata` The topic does not exist, or its metadata has not propagated yet. The client never auto-creates topics. Create it via `createAdmin().createTopics(...)` or your broker tooling. Right after creation a brief window of this error is normal — the transport retries it internally. ### The service boots but consumes nothing Check the boot log for the resolved patterns. Since Asena 0.8 the `@MessageController` prefix applies to `@EventPattern` as well, so a handler you expect on `orders` may have registered as `billing.orders`: ``` [Microservice] OrdersListener → "default" (prefix "billing") evt: billing.orders ``` Add `prefix: false` to that `@EventPattern`. For external topics, `listen()` prints an explicit hint (see [External Topics](#external-topics-interop)). ### `handlerTimeout must not exceed sessionTimeout` Thrown at construction, on purpose. kafkajs cannot heartbeat while your handler runs, so a handler allowed to outlive the session would get the member evicted and the record redelivered concurrently. Lower `handlerTimeout` or raise `sessionTimeout`. ### Health endpoint returns 503 Two independent causes, both recovering on their own: 1. **The active metadata probe is failing** — the broker is unreachable, credentials are wrong, or TLS is misconfigured. 2. **The reply consumer is not fetching** — normal for tens of seconds after a broker outage, while kafkajs works through its retry backoff and the ephemeral reply group rejoins. Until it does, this instance cannot complete a `send()`, so 503 is the correct answer. If 503 persists past a rejoin, check the logs for `reply consumer recreate failed`. ### Connecting to Kafka 4.0 fails kafkajs 2.2.4 does not support brokers that dropped the old protocol API versions (KIP-896). Pin your broker to 3.9.x — see [Client Roadmap](#client-roadmap). ## Client Roadmap **Why kafkajs?** It is the only full-featured Kafka client that actually runs on Bun today. It is also effectively unmaintained (2.2.4 is the final release), which is why the package keeps every kafkajs call behind the `KafkaClientAdapter` interface — a seam a different client could be slotted into. **Why not `@confluentinc/kafka-javascript`?** Confluent's official client is the obvious successor and even ships a KafkaJS-compatibility mode. It was tested directly on Bun 1.3.14 and **does not work**, for two independent reasons: 1. **It cannot load on Bun.** The addon is built on NAN (the V8 C++ API) rather than N-API. Bun's ABI (`NODE_MODULE_VERSION` 137) matches a published prebuilt, and that prebuilt downloads and installs cleanly — so this is neither an install-script nor an ABI problem. Loading it still dies inside `dlopen`: ``` bun: symbol lookup error: .../confluent-kafka-javascript.node: undefined symbol: _ZN2v816FunctionTemplate12SetClassNameENS_5LocalINS_6StringEEE ``` That symbol is `v8::FunctionTemplate::SetClassName`, which JavaScriptCore does not provide. Nothing on the Asena side can work around it. Tracking: [bun#4290](https://github.com/oven-sh/bun/issues/4290), [confluent-kafka-javascript#264](https://github.com/confluentinc/confluent-kafka-javascript/issues/264). 2. **Even on Node it would cost you a feature.** Confluent's KafkaJS-compatibility mode does not support sending metadata with offset commits. The [broker-tracked delivery attempts](#delivery-attempts) described above are built entirely on offset-commit metadata, so `context.attempt` would need a side store to survive. `consumer.stop()` and the consumer instrumentation events used for ready-gating and crash detection are also unsupported. **What would change this:** [confluent-kafka-javascript#471](https://github.com/confluentinc/confluent-kafka-javascript/pull/471) migrates the addon from NAN to N-API. If it lands, blocker 1 disappears for Bun, Deno and future Node ABIs alike. Blocker 2 would still need upstream work. That PR is the one thing worth watching. Until then: kafkajs, with brokers pinned to 3.9.x. ## Related Documentation - [Microservices](/docs/concepts/microservices) - The transport-agnostic microservice layer - [Redis](/docs/packages/redis) - Redis Streams transport (same SPI, different broker) - [OpenTelemetry](/docs/packages/opentelemetry) - Distributed tracing across transports --- # CLI Overview Section: Asena CLI Source: https://asena.sh/raw/cli/overview.md # CLI Overview The Asena CLI is a powerful command-line tool that streamlines Asena application development. It handles project scaffolding, code generation, building, and development workflows. ## What is Asena CLI? Asena CLI provides essential tools for building and managing Asena applications: - **Project Scaffolding** - Create new projects with complete setup - **Code Generation** - Generate controllers, services, middleware, and more - **Build System** - Bundle your application for production - **Development Mode** - One-shot build and run with `asena dev start`; use the scaffolded `bun run dev:hot` for hot reload - **Multi-Adapter Support** - Works with both Ergenecore and Hono adapters ## Key Features ### 🚀 Quick Project Setup Create a fully configured Asena project in seconds with interactive prompts for adapter selection, ESLint, and Prettier setup. ### 🔧 Code Generation Quickly generate boilerplate code for: - Controllers with route handlers - Services with business logic - Middleware for request processing - Config classes for server configuration - WebSocket namespaces for real-time features ### ⚡ Fast Development Development mode with automatic building and component registration. No manual imports needed—the CLI discovers and registers all your components automatically. ### 📦 Production Builds Bundle your application with Bun's fast bundler, with full control over minification, source maps, and output options. ## Getting Started Install the CLI globally: ```bash bun install -g @asenajs/asena-cli ``` Create your first project: ```bash asena create ``` Start developing: ```bash cd my-project asena dev start ``` ## Documentation - [Installation](/docs/cli/installation) - Install and verify the CLI - [Commands](/docs/cli/commands) - All available commands and options - [Configuration](/docs/cli/configuration) - Configure your project - [Examples](/docs/cli/examples) - Step-by-step tutorials ## Requirements - **Bun Runtime** - v1.3.12 or higher - **TypeScript** - v5.8.2 or higher (installed automatically) ## Related Resources - [Get Started Guide](/docs/get-started) - Complete beginner's guide - [Adapters Overview](/docs/adapters/overview) - Choose between Ergenecore and Hono - [CLI Examples](/docs/cli/examples) - See project structure examples --- **Next Steps:** - [Install the CLI](/docs/cli/installation) - [Learn CLI Commands](/docs/cli/commands) - [Try the Examples](/docs/cli/examples) --- # CLI Installation Section: Asena CLI Source: https://asena.sh/raw/cli/installation.md # Installation Install the Asena CLI globally to access the `asena` command from anywhere on your system. ## Prerequisites **Required:** - [Bun runtime](https://bun.sh) v1.3.12 or higher **Verify Bun installation:** ```bash bun --version ``` If Bun is not installed, install it: ```bash # macOS, Linux, and WSL curl -fsSL https://bun.sh/install | bash # Windows (PowerShell) powershell -c "irm bun.sh/install.ps1 | iex" ``` ## Install Asena CLI Install globally using Bun: ```bash bun install -g @asenajs/asena-cli ``` ## Verify Installation Check that the CLI is installed correctly: ```bash asena --version ``` You should see the version number (e.g., `0.7.1`). View available commands: ```bash asena --help ``` ## Update Asena CLI To update to the latest version: ```bash bun install -g @asenajs/asena-cli@latest ``` ## Uninstall To remove the CLI: ```bash bun remove -g @asenajs/asena-cli ``` ## Troubleshooting ### Command Not Found If you see `asena: command not found`, make sure Bun's global bin directory is in your PATH: **Check your PATH:** ```bash echo $PATH ``` **Add Bun's bin directory to your PATH** (if needed): ```bash # For bash echo 'export PATH="$HOME/.bun/bin:$PATH"' >> ~/.bashrc source ~/.bashrc # For zsh echo 'export PATH="$HOME/.bun/bin:$PATH"' >> ~/.zshrc source ~/.zshrc ``` ### Permission Errors If you encounter permission errors during installation: ```bash # On Linux/macOS, you may need sudo sudo bun install -g @asenajs/asena-cli ``` ### Version Mismatch If you have an old version installed, force reinstall: ```bash bun remove -g @asenajs/asena-cli bun install -g @asenajs/asena-cli ``` ## Next Steps Now that the CLI is installed: - [Create your first project](/docs/cli/examples) - [Learn CLI commands](/docs/cli/commands) - [Configure your project](/docs/cli/configuration) --- **Related Documentation:** - [CLI Overview](/docs/cli/overview) - [CLI Commands](/docs/cli/commands) - [Get Started Guide](/docs/get-started) --- # CLI Commands Section: Asena CLI Source: https://asena.sh/raw/cli/commands.md # CLI Commands Asena CLI provides command-line utilities to help you manage your Asena applications efficiently. ## Installation **Prerequisite:** [Bun runtime](https://bun.sh) (v1.3.12 or higher) ```bash bun install -g @asenajs/asena-cli ``` Verify installation: ```bash asena --version ``` ## asena create Bootstrap a new Asena project with a complete development environment setup. ### Features - **Interactive Setup** - User-friendly setup experience with inquirer - **Non-Interactive Mode** - Support for SSH and CI/CD environments with CLI arguments - **Multi-Adapter Support** - Choose between Hono or Ergenecore adapters - **Project Structure** - Creates complete project structure with necessary files - **Default Components** - Generates default controller and server setup - **Development Tools** - Optional ESLint and Prettier integration - **Dependency Management** - Automatically installs required dependencies ### Usage **Interactive Mode** (prompts for all options): ```bash asena create # or create in current directory asena create . ``` ::: warning SSH Connection Issue Interactive prompts may not work properly over SSH connections or in non-TTY environments (CI/CD pipelines). Use non-interactive mode instead. ::: **Non-Interactive Mode** (specify options via CLI arguments): ```bash # Create with all features enabled asena create my-project --adapter=hono --logger --eslint --prettier # Create in current directory without optional features asena create . --adapter=ergenecore --no-logger --no-eslint --no-prettier # Mix of CLI arguments and interactive prompts asena create my-app --adapter=hono # Will prompt for remaining options ``` ### CLI Options | Option | Description | Values | Default | |:-------|:------------|:-------|:--------| | `[project-name]` | Project name (use `.` for current directory) | Any string | Prompted | | `--adapter ` | Adapter to use | `hono`, `ergenecore` | Prompted | | `--logger` / `--no-logger` | Setup Asena logger | boolean | `true` | | `--eslint` / `--no-eslint` | Setup ESLint | boolean | `true` | | `--prettier` / `--no-prettier` | Setup Prettier | boolean | `true` | ### Interactive Prompts When using interactive mode without CLI arguments: ```bash ✔ Enter your project name: my-asena-app ✔ Select adapter: Ergenecore ✔ Do you want to setup logger? Yes ✔ Do you want to setup ESLint? Yes ✔ Do you want to setup Prettier? Yes ⠙ Creating asena project... ``` ### Generated Project Structure ``` my-asena-app/ ├── src/ │ ├── controllers/ │ │ └── AsenaController.ts # Sample controller │ ├── logger/ │ │ └── logger.ts # Only when the logger option is enabled │ └── index.ts # Application entry point ├── .asena/ │ ├── config.json # Adapter + suffix settings used by `asena generate` │ └── config.schema.json ├── asena-config.ts # Build configuration ├── package.json ├── tsconfig.json ├── .gitignore ├── eslint.config.cjs # Only when ESLint is enabled ├── .prettierrc.js # Only when Prettier is enabled └── .prettierignore # Only when Prettier is enabled ``` ::: info Directories are created on demand `asena create` does not pre-create `services/`, `middlewares/`, `config/`, `namespaces/`, `tests/` or `public/`. `asena generate` creates the folder it needs the first time you use it. Folder layout is a convention only - the component scanner walks the whole `sourceFolder` tree and finds components wherever they live. ::: ## asena generate Quickly and consistently create project components with proper structure and imports. **Shortcut:** `asena g` ### Features - **Multi-Component Support** - Generate controllers, services, middlewares, configs, and websockets - **Automatic Code Generation** - Creates template code with base structure and necessary imports - **Adapter-Aware** - Generates adapter-specific code based on project configuration - **Suffix Configuration** - Customize component naming conventions (see [Suffix Configuration](/docs/cli/suffix-configuration)) - **Project Structure Integration** - Places files in the correct directories - **Command Shortcuts** - Faster usage with aliases ### Commands | Component | Full Command | Shortcut | Description | |:-----------|:----------------------------|:--------------|:----------------------------| | Controller | `asena generate controller` | `asena g c` | Generates a controller | | Service | `asena generate service` | `asena g s` | Generates a service | | Middleware | `asena generate middleware` | `asena g m` | Generates a middleware | | Validator | `asena generate validator` | `asena g v` | Generates a Zod validator | | Config | `asena generate config` | `asena g config` | Generates a server config | | WebSocket | `asena generate websocket` | `asena g ws` | Generates a WebSocket namespace | ### Examples #### Generate Controller ```bash asena g c # or asena generate controller ``` **Prompt:** ```bash ✔ Enter controller name: User ``` **Generated:** `src/controllers/UserController.ts` ```typescript import { Controller } from '@asenajs/asena/decorators'; import { Get } from '@asenajs/asena/decorators/http'; import type { Context } from '@asenajs/ergenecore'; @Controller() export class UserController { } ``` ::: tip The body is intentionally empty The generator scaffolds the class and its imports only - add the path and your routes yourself, e.g. `@Controller('/users')` with a `@Get('/')` handler. ::: #### Generate Service ```bash asena g s # or asena generate service ``` **Prompt:** ```bash ✔ Enter service name: User ``` **Generated:** `src/services/UserService.ts` ```typescript import { Service } from '@asenajs/asena/decorators'; @Service() export class UserService { } ``` #### Generate Middleware ```bash asena g m # or asena generate middleware ``` **Prompt:** ```bash ✔ Enter middleware name: Auth ``` **Generated:** `src/middlewares/AuthMiddleware.ts` ```typescript import { Middleware } from '@asenajs/asena/decorators'; import { MiddlewareService, type Context } from '@asenajs/ergenecore'; @Middleware() export class AuthMiddleware extends MiddlewareService { public async handle(context: Context, next: () => Promise) { context.setValue('testValue', 'test'); await next(); } } ``` ::: warning Replace the placeholder body The scaffolded `handle()` is a smoke-test stub. Real middleware should be `async`, take `next: () => Promise`, and `await next()`. ::: #### Generate Config ```bash asena g config # or asena generate config ``` **Prompt:** ```bash ✔ Enter config name: Server ``` **Generated:** `src/config/ServerConfig.ts` ```typescript import { Config } from '@asenajs/asena/decorators'; import { ConfigService, type Context } from '@asenajs/ergenecore'; @Config() export class ServerConfig extends ConfigService { public onError(error: Error, context: Context): Response | Promise { console.error('Error:', error.message); return context.send({ error: error.message }, 500); } } ``` #### Generate WebSocket ```bash asena g ws # or asena generate websocket ``` **Prompt:** ```bash ✔ Enter websocket namespace name: Chat ``` **Generated:** `src/namespaces/ChatNamespace.ts` ```typescript import { WebSocket } from '@asenajs/asena/decorators'; import { AsenaWebSocketService, type Socket } from '@asenajs/asena/web-socket'; @WebSocket({ path: '/chat', name: 'ChatNamespace' }) export class ChatNamespace extends AsenaWebSocketService { protected async onOpen(ws: Socket): Promise { console.log('Client connected'); } protected async onMessage(ws: Socket, message: string): Promise { console.log('Message received:', message); } protected async onClose(ws: Socket): Promise { console.log('Client disconnected'); } } ``` ::: info Adapter-agnostic WebSocket namespaces are generated the same way for both adapters - the base class and `Socket` type come from `@asenajs/asena/web-socket`, not from the adapter package. ::: ::: tip Adapter-Specific Generation The CLI automatically detects your adapter (Ergenecore or Hono) from `asena-config.ts` and generates appropriate imports and base classes. ::: ## asena dev start Start the application in development mode with automatic building. ### Features - **Automatic Build** - Builds the project before starting - **Component Registration** - Automatically registers all controllers, services, and middlewares - **Single Build + Run** - Builds once, then runs the bundle. `asena dev start` takes no options and does **not** watch for changes; use the scaffolded `bun run dev:hot` (`bun run --hot src/index.ts`) while iterating. ### Usage ```bash asena dev start ``` ### Output ``` Build completed successfully. 2025-10-15 14:30:19 [info]: ___ _____ ______ _ __ ___ / | / ___/ / ____// | / // | / /| | \__ \ / __/ / |/ // /| | / ___ | ___/ // /___ / /| // ___ | /_/ |_|/____//_____//_/ |_//_/ |_| 2025-10-15 14:30:20 [info]: Adapter: ErgenecoreAdapter implemented 2025-10-15 14:30:20 [info]: All components registered and ready to use 2025-10-15 14:30:20 [info]: Controller: UserController found: 2025-10-15 14:30:20 [info]: Successfully registered GET route for PATH: /users 2025-10-15 14:30:20 [info]: Controller: UserController successfully registered. 2025-10-15 14:30:20 [info]: Server started on port 3000 ``` ::: info Controller Names in Output Controller names are visible in logs when `buildOptions.minify.identifiers` is set to `false` in `asena-config.ts`. ::: ## asena build Build the project for production deployment. ### Features - **Configuration Processing** - Reads and processes `asena-config.ts` - **Code Generation** - Creates a temporary build file combining all components - **Import Management** - Automatically organizes imports based on project structure - **Server Integration** - Integrates all components with AsenaServer - **No Manual Registration** - Controllers are automatically discovered and registered ### Usage ```bash asena build ``` ### Build Process 1. Reads `asena-config.ts` 2. Scans source folder for controllers, services, middlewares, configs, and websockets 3. Generates a temporary build file with all imports 4. Bundles the application using Bun's bundler 5. Outputs compiled files to `buildOptions.outdir` (CLI default `./out`; the scaffolded `asena-config.ts` sets `dist`) ### Build Output ``` Build completed successfully. Output: dist/index.asena.js ``` ::: tip Production Deployment After building, you can run your application with: ```bash bun dist/index.asena.js ``` ::: ## asena init Initialize an existing project with Asena configuration. ### Features - **Configuration Generation** - Creates `asena-config.ts` - **Default Values** - Provides sensible defaults for quick start - **No Need if Using `create`** - Not required if you used `asena create` ### Usage ```bash asena init ``` ### Generated Configuration Creates `asena-config.ts`: ```typescript import { defineConfig } from '@asenajs/asena-cli'; export default defineConfig({ sourceFolder: 'src', rootFile: 'src/index.ts', // include: ['public'], // Directories/files to copy into outdir during build buildOptions: { outdir: 'dist', minify: { whitespace: true, syntax: true, identifiers: false, //It's better for you to make this false for better debugging during the running phase of the application. keepNames: true }, }, }); ``` ::: tip Why `identifiers: false` and `keepNames: true` Component registration is name-based. Minifying identifiers would rename your classes and break `@Inject('UserService')` lookups at runtime. ::: ::: info When to Use `asena init` Use `asena init` when: - Adding Asena to an existing project - Manually setting up a project without `asena create` - Resetting configuration to defaults ::: ## Command Reference ### Quick Reference | Command | Shortcut | Description | |:---------------------|:----------------|:-------------------------------------| | `asena create` | - | Create a new Asena project | | `asena generate` | `asena g` | Generate project components | | `asena generate controller` | `asena g c` | Generate a controller | | `asena generate service` | `asena g s` | Generate a service | | `asena generate middleware` | `asena g m` | Generate a middleware | | `asena generate config` | `asena g config` | Generate a config | | `asena generate websocket` | `asena g ws` | Generate a WebSocket namespace | | `asena dev start` | - | Start development server | | `asena build` | - | Build for production | | `asena init` | - | Initialize configuration | | `asena --version` | `asena -V` | Show CLI version | | `asena --help` | `asena -h` | Show help | ## Related Documentation - [Configuration](/docs/cli/configuration) - CLI configuration options - [Suffix Configuration](/docs/cli/suffix-configuration) - Component naming conventions - [CLI Examples](/docs/cli/examples) - See complete project examples - [Controllers](/docs/concepts/controllers) - Controller patterns - [Services](/docs/concepts/services) - Service patterns - [Middleware](/docs/concepts/middleware) - Middleware patterns - [WebSocket](/docs/concepts/websocket) - WebSocket patterns --- **Next Steps:** - Learn about [CLI Configuration](/docs/cli/configuration) - See [CLI Examples](/docs/cli/examples) for project structure - Understand [Controllers](/docs/concepts/controllers) --- # CLI Configuration Section: Asena CLI Source: https://asena.sh/raw/cli/configuration.md # CLI Configuration All Asena projects come with an `asena-config.ts` file for project configuration. This file controls how the CLI builds, develops, and manages your project. ## defineConfig Helper Use the `defineConfig` helper for type-safe configuration: ```typescript import { defineConfig } from '@asenajs/asena-cli'; export default defineConfig({ sourceFolder: 'src', rootFile: 'src/index.ts', }); ``` ## Configuration Options ### Complete Configuration Interface ```typescript interface AsenaConfig { sourceFolder: string; // Source code directory rootFile: string; // Application entry point include?: string[]; // Files/directories to copy to output buildOptions?: BuildOptions; // Optional Bun bundler options } /** * BuildOptions is a subset of Bun's BuildConfig. * Only backend-relevant options are exposed. */ type BuildOptions = Partial< Pick< Bun.BuildConfig, 'outdir' | 'sourcemap' | 'minify' | 'external' | 'format' | 'drop' > >; ``` ## Default Configuration When you create a project with `asena create` or run `asena init`, this default configuration is generated: ```typescript import { defineConfig } from '@asenajs/asena-cli'; export default defineConfig({ sourceFolder: 'src', rootFile: 'src/index.ts', buildOptions: { outdir: 'dist', minify: { whitespace: true, syntax: true, identifiers: false, //It's better for you to make this false for better debugging during the running phase of the application. keepNames: true }, }, }); ``` ## Configuration Properties ### sourceFolder **Type:** `string` **Default:** `'src'` Specifies the source code directory where Asena looks for components (controllers, services, middlewares, configs, websockets). ```typescript export default defineConfig({ sourceFolder: 'src', // Asena scans src/ for components }); ``` ::: tip Component Discovery The CLI automatically discovers and registers all decorated classes in this folder: - `@Controller` classes in `sourceFolder/controllers/` - `@Service` classes in `sourceFolder/services/` - `@Middleware` classes in `sourceFolder/middlewares/` - `@Config` classes in `sourceFolder/config/` - `@Websocket` classes in `sourceFolder/namespaces/` ::: ### rootFile **Type:** `string` **Default:** `'src/index.ts'` Specifies the application entry point file where your server is initialized. ```typescript export default defineConfig({ rootFile: 'src/index.ts', // Entry point for the application }); ``` **Example rootFile (`src/index.ts`):** ```typescript import { AsenaServerFactory } from '@asenajs/asena'; import { createErgenecoreAdapter } from '@asenajs/ergenecore'; import { AsenaLogger } from '@asenajs/asena-logger'; const logger = new AsenaLogger(); const adapter = createErgenecoreAdapter(); const server = await AsenaServerFactory.create({ adapter, logger, port: 3000 }); await server.start(); ``` ::: info Why `rootFile` matters at runtime The component scan reads `rootFile` to keep the entry out of its own sweep. Importing the entry while it is suspended on its top-level `await` is a cyclic import that never settles, which would hang startup. Components declared inside the entry file are still registered - Asena picks them up from the decorator registry rather than by importing the file - as long as they are declared above the `AsenaServerFactory.create()` call. ::: ### include **Type:** `string[]` (optional) **Default:** `[]` (empty array) Specifies files and directories that should be copied into the output directory during build. Paths are relative to the project root. Directory structure is preserved. ```typescript export default defineConfig({ include: ['public', 'src/frontend/pages'], }); ``` This is essential when using [`@FrontendController`](/docs/concepts/frontend-controller) — the HTML files referenced by `@Page` methods must be available in the build output. **Example:** If your frontend controller imports `../../public/home.html`, you must include the `public` directory: ```typescript export default defineConfig({ sourceFolder: 'src', rootFile: 'src/index.ts', include: ['public'], // [!code highlight] buildOptions: { outdir: 'dist', }, }); ``` After build, `public/` is copied to `dist/public/`, and HTML import paths are automatically rewritten to resolve correctly. ::: tip You can include both files and directories. Directories are copied recursively. ```typescript include: [ 'public', // Entire directory 'config/app.json', // Single file ] ``` ::: ### buildOptions **Type:** `BuildOptions` (optional) **Default:** `{ outdir: './out' }` when `buildOptions` is omitted. The `asena-config.ts` that `asena create` / `asena init` scaffolds sets `outdir: 'dist'` explicitly, which is why most projects build into `dist/`. Configuration options for Bun's bundler. Asena exposes only backend-relevant build options from Bun's `BuildConfig`. ::: info Managed Internally The `entrypoints` and `target` properties are managed internally by Asena CLI and cannot be configured by users. Asena always builds for the `bun` target since it's a Bun-native backend framework. ::: **Available BuildOptions:** - `outdir` - Output directory for compiled files - `sourcemap` - Source map generation strategy - `minify` - Code minification options - `external` - Dependencies to exclude from bundling - `format` - Output module format (ESM/CJS) - `drop` - Remove function calls from bundle (e.g., `console`, `debugger`) ## Build Options Reference ### outdir **Type:** `string` **Default:** `'dist'` Output directory for compiled files. ```typescript export default defineConfig({ buildOptions: { outdir: 'dist', // Build output to dist/ }, }); ``` ### sourcemap **Type:** `'none' | 'inline' | 'external' | 'linked'` **Default:** Not set (optional) Controls source map generation for debugging: - `'none'` - No source maps - `'inline'` - Embedded in output file - `'external'` - Separate .map files - `'linked'` - Separate .map files with links ```typescript export default defineConfig({ buildOptions: { sourcemap: 'linked', // For debugging with linked source maps }, }); ``` ::: tip Development vs Production - **Development:** Use `'linked'` or `'external'` for debugging - **Production:** Use `'none'` for smaller bundle size ::: ### format **Type:** `'esm' | 'cjs'` **Default:** `'esm'` (Bun's default) Specifies the output module format. ```typescript export default defineConfig({ buildOptions: { format: 'esm', // ES modules (recommended for Bun) }, }); ``` ::: tip Asena works best with ESM format since Bun has native ESM support. CJS is supported but not recommended unless you have specific compatibility requirements. ::: ### external **Type:** `string[]` **Default:** `[]` (empty array) List of dependencies that should not be bundled into the output. Useful for native modules or dependencies that should be resolved at runtime. ```typescript export default defineConfig({ buildOptions: { external: ['pg', 'mysql2', 'better-sqlite3'], // Don't bundle database drivers }, }); ``` **Common use cases:** - Native Node.js modules (e.g., `fs`, `path` - though Bun handles these) - Database drivers with native bindings - Large dependencies that should be installed separately ::: warning Native Dependencies Some packages with native bindings (like `better-sqlite3`) must be marked as external to work correctly. ::: ### drop **Type:** `string[]` **Default:** `[]` (empty array) Removes specified function calls from the bundle during build. Commonly used to strip debugging code in production. ```typescript export default defineConfig({ buildOptions: { drop: ['console', 'debugger'], // Remove all console calls and debugger statements }, }); ``` **Common values:** - `'console'` - Removes all `console.*` calls - `'debugger'` - Removes `debugger` statements - Custom identifiers like `'logger.debug'` ::: danger Side Effects Warning The `drop` option removes the entire call expression, including arguments, even if they have side effects. For example, `drop: ['console']` will turn `console.log(doSomething())` into nothing, so `doSomething()` will never execute. ::: **Example - Production build:** ```typescript export default defineConfig({ sourceFolder: 'src', rootFile: 'src/index.ts', buildOptions: { outdir: 'dist', sourcemap: 'none', minify: true, drop: ['console', 'debugger'], // Clean production output }, }); ``` ### minify **Type:** `boolean | MinifyOptions` **Default:** `{ whitespace: true, syntax: true, identifiers: false, keepNames: true }` Controls code minification for smaller bundle sizes. #### Boolean Mode ```typescript export default defineConfig({ buildOptions: { minify: true, // Enable all minification }, }); ``` #### Fine-Grained Control ```typescript export default defineConfig({ buildOptions: { minify: { whitespace: true, // Remove unnecessary whitespace syntax: true, // Apply smart condensation transforms identifiers: false, // Keep original variable/function names keepNames: true // Preserve function and class names }, }, }); ``` **Minify Options:** | Option | Type | Description | |:--------------|:----------|:-----------------------------------------------| | `whitespace` | `boolean` | Removes unnecessary whitespace and newlines | | `syntax` | `boolean` | Applies syntax-level optimizations | | `identifiers` | `boolean` | Renames variables/functions to shorter names | | `keepNames` | `boolean` | Preserves function and class names for debugging | ::: warning identifiers and Debugging Setting `identifiers: true` makes debugging harder because: - Controller names become unreadable in logs - Stack traces show minified names - Hot reload becomes less predictable **Recommendation:** Keep `identifiers: false` in development, set to `true` only in production if bundle size is critical. ::: ## Environment-Specific Configuration You can create environment-specific configurations: ### Development Configuration ```typescript // asena.config.dev.ts import { defineConfig } from '@asenajs/asena-cli'; export default defineConfig({ sourceFolder: 'src', rootFile: 'src/index.ts', buildOptions: { outdir: 'dist', sourcemap: 'linked', // Full debugging support minify: { whitespace: false, // Keep readable syntax: false, identifiers: false, // Keep original names }, drop: [], // Keep all console logs for debugging }, }); ``` ### Production Configuration ```typescript // asena.config.prod.ts import { defineConfig } from '@asenajs/asena-cli'; export default defineConfig({ sourceFolder: 'src', rootFile: 'src/index.ts', buildOptions: { outdir: 'dist', sourcemap: 'none', // No source maps minify: { whitespace: true, // Minimize size syntax: true, identifiers: true, // Shorten names }, drop: ['console', 'debugger'], // Remove debugging code }, }); ``` **Usage:** ```bash # Development cp asena.config.dev.ts asena-config.ts asena dev start # Production cp asena.config.prod.ts asena-config.ts asena build ``` ## Advanced Build Options Asena exposes backend-relevant build options from Bun's bundler. Here are some common advanced configurations: ### Custom Entry Points ```typescript export default defineConfig({ sourceFolder: 'src', rootFile: 'src/server.ts', // Custom entry point buildOptions: { outdir: 'build', }, }); ``` ### Production-Optimized Build ```typescript export default defineConfig({ sourceFolder: 'src', rootFile: 'src/index.ts', buildOptions: { outdir: 'dist', format: 'esm', sourcemap: 'none', minify: true, drop: ['console', 'debugger'], // Strip debugging code }, }); ``` ### External Dependencies ```typescript export default defineConfig({ sourceFolder: 'src', rootFile: 'src/index.ts', buildOptions: { outdir: 'dist', external: ['pg', 'mysql2', 'better-sqlite3'], // Don't bundle native modules }, }); ``` ## Configuration Examples ### Minimal Configuration ```typescript import { defineConfig } from '@asenajs/asena-cli'; export default defineConfig({ sourceFolder: 'src', rootFile: 'src/index.ts', buildOptions: { outdir: 'dist', }, }); ``` ### Monorepo Configuration ```typescript import { defineConfig } from '@asenajs/asena-cli'; export default defineConfig({ sourceFolder: 'packages/api/src', rootFile: 'packages/api/src/index.ts', buildOptions: { outdir: 'packages/api/dist', sourcemap: 'linked', format: 'esm', }, }); ``` ### Docker-Optimized Configuration ```typescript import { defineConfig } from '@asenajs/asena-cli'; export default defineConfig({ sourceFolder: 'src', rootFile: 'src/index.ts', buildOptions: { outdir: '/app/dist', sourcemap: 'none', minify: true, drop: ['console', 'debugger'], // Clean production logs }, }); ``` ## Bun Bundler Options Asena uses Bun's bundler under the hood and exposes only the options relevant for backend framework builds. ::: info The `entrypoints` and `target` are managed internally by Asena CLI. All exposed options are **optional** (wrapped in `Partial<>`). ::: **Supported BuildOptions:** | Option | Type | Description | Reference | |:--------------|:----------------------|:-------------------------------------------------|:---------------------------------------------------------| | `outdir` | `string` | Output directory for compiled files | [Bun Docs](https://bun.com/docs/bundler#outdir) | | `sourcemap` | `'none' \| 'inline' \| 'external' \| 'linked'` | Source map generation strategy | [Bun Docs](https://bun.com/docs/bundler#sourcemap) | | `minify` | `boolean \| MinifyOptions` | Code minification options | [Bun Docs](https://bun.com/docs/bundler#minify) | | `external` | `string[]` | Dependencies to exclude from bundling | [Bun Docs](https://bun.com/docs/bundler#external) | | `format` | `'esm' \| 'cjs'` | Output module format | [Bun Docs](https://bun.com/docs/bundler#format) | | `drop` | `string[]` | Remove function calls (e.g., `console`, `debugger`) | [Bun Docs](https://bun.com/docs/bundler#drop) | ::: warning Unsupported Options Options like `target`, `entrypoints`, `splitting`, `define`, and `loader` are **not exposed** because they're either managed internally or not relevant for backend framework builds. ::: For a complete reference of Bun's bundler capabilities, see the [Bun Bundler Documentation](https://bun.com/docs/bundler). ## Best Practices ### 1. Keep identifiers Unminified in Development ```typescript // ✅ Good: Easy debugging export default defineConfig({ buildOptions: { minify: { identifiers: false, // See real controller names in logs }, }, }); ``` ### 2. Use Linked Source Maps ```typescript // ✅ Good: Balance between debugging and performance export default defineConfig({ buildOptions: { sourcemap: 'linked', }, }); ``` ### 3. Organize by Environment ```typescript // ✅ Good: Clear separation // - asena.config.dev.ts // - asena.config.prod.ts // - asena-config.ts (active config) ``` ### 4. Document Custom Configuration ```typescript // ✅ Good: Comments for team members export default defineConfig({ sourceFolder: 'src', rootFile: 'src/index.ts', buildOptions: { outdir: 'dist', // Keep identifiers readable for easier debugging minify: { identifiers: false, }, }, }); ``` ## Troubleshooting ### Build Output Not Found **Problem:** CLI can't find build output **Solution:** Check `outdir` matches your expectations: ```typescript export default defineConfig({ buildOptions: { outdir: 'dist', // Make sure this directory exists }, }); ``` ### Controllers Not Discovered **Problem:** Controllers not being registered **Solution:** Ensure `sourceFolder` is correct: ```typescript export default defineConfig({ sourceFolder: 'src', // Must match your actual source directory }); ``` ### Source Maps Not Working **Problem:** Can't debug with source maps **Solution:** Use `'linked'` or `'external'`: ```typescript export default defineConfig({ buildOptions: { sourcemap: 'linked', // Not 'none' }, }); ``` ## .asena/config.json The `.asena/config.json` file is automatically created by `asena create` or `asena init` commands and stores project-level settings. ### Suffix Configuration Configure how component names are suffixed when using `asena generate` commands. You can use default suffixes (`Controller`, `Service`), disable them entirely, or define custom ones. ```json { "adapter": "hono", "suffixes": true // or false, or custom object } ``` For complete suffix configuration options and examples, see [Suffix Configuration](/docs/cli/suffix-configuration). ## Related Documentation - [CLI Commands](/docs/cli/commands) - Available CLI commands - [Suffix Configuration](/docs/cli/suffix-configuration) - Component naming conventions - [CLI Examples](/docs/cli/examples) - See project structure examples - [Deployment](/docs/guides/deployment) - Production deployment - [Bun Bundler](https://bun.com/docs/bundler) - Complete bundler reference --- **Next Steps:** - Explore [CLI Commands](/docs/cli/commands) - Learn about [Deployment](/docs/guides/deployment) - See [CLI Examples](/docs/cli/examples) for project structure --- # Suffix Configuration Section: Asena CLI Source: https://asena.sh/raw/cli/suffix-configuration.md # Suffix Configuration Suffix configuration allows you to customize how class names are generated when using `asena generate` commands. ## What are Suffixes? Suffixes are text strings automatically appended to class names during component generation: | Input | Default Suffix | Result | |:------|:---------------|:-------| | `User` | `Controller` | `UserController` | | `Auth` | `Service` | `AuthService` | | `Logger` | `Middleware` | `LoggerMiddleware` | | `Database` | `Config` | `DatabaseConfig` | | `Chat` | `Namespace` | `ChatNamespace` | ## Configuration File Suffix settings are stored in `.asena/config.json`, which is automatically created by `asena create` or `asena init`: ```json { "adapter": "hono", "suffixes": true } ``` ::: info Backward Compatibility If your `.asena/config.json` doesn't include a `suffixes` field, all components will use default suffixes (equivalent to `"suffixes": true`). ::: ## Default Suffixes | Component Type | Default Suffix | |:---------------|:---------------| | Controller | `Controller` | | Service | `Service` | | Middleware | `Middleware` | | Config | `Config` | | WebSocket | `Namespace` | ## Configuration Options The `suffixes` field supports three value types: ### Boolean (Global Setting) ```json { "adapter": "hono", "suffixes": true // Use all default suffixes } ``` ```json { "adapter": "hono", "suffixes": false // No suffixes at all } ``` ### Object (Granular Control) ```json { "adapter": "hono", "suffixes": { "controller": true, // Use default (Controller) "service": false, // No suffix "middleware": "MW", // Custom suffix "config": true, // Use default (Config) "websocket": "Socket" // Custom suffix } } ``` Each component type accepts: - `true` → Use default suffix - `false` → No suffix - `"CustomString"` → Custom suffix - `undefined` → Fallback to default suffix ## Examples ### Global Boolean ```json { "adapter": "hono", "suffixes": true } ``` ```bash asena g c User # → UserController.ts asena g s Auth # → AuthService.ts asena g m Logger # → LoggerMiddleware.ts ``` --- ```json { "adapter": "hono", "suffixes": false } ``` ```bash asena g c User # → User.ts asena g s Auth # → Auth.ts asena g m Logger # → Logger.ts ``` ### Granular Control ```json { "adapter": "hono", "suffixes": { "controller": true, "service": "Svc", "middleware": false } } ``` ```bash asena g c User # → UserController.ts (true → default) asena g s Auth # → AuthSvc.ts (custom) asena g m Logger # → Logger.ts (false → no suffix) asena g config DB # → DBConfig.ts (undefined → default) ``` ## Best Practices ### ❌ Things to Avoid **Invalid TypeScript Identifiers** ```json // ❌ Invalid - Contains special characters { "controller": "Ctrl-", "service": "Svc_", "middleware": "Mid@ware" } ``` Suffixes must be valid TypeScript class name segments (letters and numbers only). **Empty Strings** ```json // ❌ Invalid - Empty string { "controller": "", "service": "" } ``` Use `false` instead of empty strings to disable suffixes. ## Configuration Comparison | Config Value | `asena g c User` Result | |:------------|:------------------------| | `undefined` (no config) | `UserController.ts` | | `true` (global) | `UserController.ts` | | `false` (global) | `User.ts` | | `{ controller: true }` | `UserController.ts` | | `{ controller: false }` | `User.ts` | | `{ controller: "Ctrl" }` | `UserCtrl.ts` | | `{}` (empty object) | `UserController.ts` | ## Manual Configuration Edit `.asena/config.json` directly to modify suffix settings: ```bash nano .asena/config.json # or code .asena/config.json ``` **Example:** ```json { "adapter": "hono", "suffixes": { "controller": true, "service": "Svc", "middleware": false } } ``` After saving, generate components immediately: ```bash asena g c Product # → ProductController.ts asena g s Payment # → PaymentSvc.ts asena g m Auth # → Auth.ts ``` ::: info No Restart Required Configuration changes are applied immediately. ::: ## Related Commands ```bash # Create new project with suffix config asena create my-project # Add config to existing project asena init # Generate components asena g c Product # Controller asena g s Auth # Service asena g m Logger # Middleware asena g config Database # Config asena g ws Chat # WebSocket ``` ## Related Documentation - [CLI Configuration](/docs/cli/configuration) - Complete CLI configuration reference - [CLI Commands](/docs/cli/commands) - Available CLI commands - [Controllers](/docs/concepts/controllers) - Controller patterns - [Services](/docs/concepts/services) - Service patterns - [Middleware](/docs/concepts/middleware) - Middleware patterns --- **Next Steps:** - Explore [CLI Commands](/docs/cli/commands) to start generating components - Learn about [Controllers](/docs/concepts/controllers) and other components --- # CLI Examples Section: Asena CLI Source: https://asena.sh/raw/cli/examples.md # CLI Examples This guide walks you through creating your first Asena project from scratch, including creating components and building for production. ## Prerequisites - [Bun runtime](https://bun.sh) v1.3.12 or higher - Asena CLI installed globally **Verify installations:** ```bash bun --version asena --version ``` ## Step 1: Install Asena CLI If you haven't installed the CLI yet: ```bash bun install -g @asenajs/asena-cli ``` Verify installation: ```bash asena --version ``` ## Step 2: Create a New Project Create a new Asena project with interactive prompts: ```bash asena create ``` Answer the prompts: ```bash ✔ Enter your project name: my-asena-app ✔ Select adapter: Ergenecore ✔ Do you want to setup logger? Yes ✔ Do you want to setup ESLint? Yes ✔ Do you want to setup Prettier? Yes ⠙ Creating asena project... ``` ::: tip Adapter Selection - **Ergenecore** - Bun-native adapter with zero dependencies (recommended) - **Hono** - Hono framework-based adapter with ecosystem compatibility ::: ::: warning SSH or CI/CD Environment? If you're working over SSH or in a CI/CD pipeline, interactive prompts won't work. Use non-interactive mode instead: ```bash # Create project with all options via CLI arguments asena create my-asena-app --adapter=ergenecore --logger --eslint --prettier ``` ::: ## Step 3: Verify Project Setup Navigate to your project directory: ```bash cd my-asena-app ``` Start the development server: ```bash asena dev start ``` You should see output like this: ``` Build completed successfully. 2025-10-15 14:30:19 [info]: ___ _____ ______ _ __ ___ / | / ___/ / ____// | / // | / /| | \__ \ / __/ / |/ // /| | / ___ | ___/ // /___ / /| // ___ | /_/ |_|/____//_____//_/ |_//_/ |_| 2025-10-15 14:30:20 [info]: Adapter: ErgenecoreAdapter implemented 2025-10-15 14:30:20 [info]: All components registered and ready to use 2025-10-15 14:30:20 [info]: No configs found 2025-10-15 14:30:20 [info]: Controller: AsenaController found: 2025-10-15 14:30:20 [info]: Successfully registered GET route for PATH: / 2025-10-15 14:30:20 [info]: Controller: AsenaController successfully registered. 2025-10-15 14:30:20 [info]: No websockets found 2025-10-15 14:30:20 [info]: Server started on port 3000 ``` Test the default endpoint: ```bash curl http://localhost:3000/ ``` You should see: `Hello asena` ## Step 4: Create a Controller Generate a new controller: ```bash asena g c ``` Enter the controller name when prompted: ```bash ✔ Enter controller name: User ``` This creates `src/controllers/UserController.ts`. Let's modify it: ### For Ergenecore Adapter ```typescript import { Controller } from '@asenajs/asena/decorators'; import { Get } from '@asenajs/asena/decorators/http'; import type { Context } from '@asenajs/ergenecore'; @Controller('/users') export class UserController { @Get({ path: '/' }) async getAllUsers(context: Context) { return context.send({ message: 'List of all users', users: [ { id: 1, name: 'John Doe' }, { id: 2, name: 'Jane Smith' } ] }); } @Get({ path: '/:id' }) async getUserById(context: Context) { const id = context.getParam('id'); return context.send({ message: `User with ID: ${id}`, user: { id, name: 'John Doe' } }); } } ``` ### For Hono Adapter ```typescript import { Controller } from '@asenajs/asena/decorators'; import { Get } from '@asenajs/asena/decorators/http'; import type { Context } from '@asenajs/hono-adapter'; @Controller('/users') export class UserController { @Get({ path: '/' }) async getAllUsers(context: Context) { return context.send({ message: 'List of all users', users: [ { id: 1, name: 'John Doe' }, { id: 2, name: 'Jane Smith' } ] }); } @Get({ path: '/:id' }) async getUserById(context: Context) { const id = context.getParam('id'); return context.send({ message: `User with ID: ${id}`, user: { id, name: 'John Doe' } }); } } ``` Restart the development server: ```bash asena dev start ``` You should see the new routes registered: ``` 2025-10-15 14:35:27 [info]: Controller: UserController found: 2025-10-15 14:35:27 [info]: Successfully registered GET route for PATH: /users 2025-10-15 14:35:27 [info]: Successfully registered GET route for PATH: /users/:id 2025-10-15 14:35:27 [info]: Controller: UserController successfully registered. ``` Test the new endpoints: ```bash # Get all users curl http://localhost:3000/users # Get user by ID curl http://localhost:3000/users/1 ``` ::: info Controller Names in Output Controller names are visible in logs when `buildOptions.minify.identifiers` is set to `false` in `asena-config.ts`. ::: ## Step 5: Create a Service Generate a service for business logic: ```bash asena g s ``` Enter the service name: ```bash ✔ Enter service name: User ``` Edit `src/services/UserService.ts`: ```typescript import { Service } from '@asenajs/asena/decorators'; interface User { id: number; name: string; email: string; } @Service() export class UserService { private users: User[] = [ { id: 1, name: 'John Doe', email: 'john@example.com' }, { id: 2, name: 'Jane Smith', email: 'jane@example.com' } ]; async getAllUsers(): Promise { return this.users; } async getUserById(id: number): Promise { return this.users.find(user => user.id === id); } async createUser(name: string, email: string): Promise { const newUser: User = { id: this.users.length + 1, name, email }; this.users.push(newUser); return newUser; } } ``` Update your controller to use the service: ```typescript import { Controller } from '@asenajs/asena/decorators'; import { Get, Post } from '@asenajs/asena/decorators/http'; import { Inject } from '@asenajs/asena/decorators/ioc'; import type { Context } from '@asenajs/ergenecore'; import { UserService } from '../services/UserService'; @Controller('/users') export class UserController { @Inject(UserService) private userService: UserService; @Get({ path: '/' }) async getAllUsers(context: Context) { const users = await this.userService.getAllUsers(); return context.send({ users }); } @Get({ path: '/:id' }) async getUserById(context: Context) { const id = parseInt(context.getParam('id')); const user = await this.userService.getUserById(id); if (!user) { return context.send({ error: 'User not found' }, 404); } return context.send({ user }); } @Post({ path: '/' }) async createUser(context: Context) { const { name, email } = await context.getBody<{ name: string; email: string }>(); const user = await this.userService.createUser(name, email); return context.send({ user }, 201); } } ``` Test the new endpoints: ```bash # Get all users curl http://localhost:3000/users # Get user by ID curl http://localhost:3000/users/1 # Create a new user curl -X POST http://localhost:3000/users \ -H "Content-Type: application/json" \ -d '{"name":"Bob Johnson","email":"bob@example.com"}' ``` ## Step 6: Build for Production Build your project: ```bash asena build ``` Output: ``` Build completed successfully. Output: dist/index.asena.js ``` The build files are in the `dist/` directory (configured in `asena-config.ts`). Run the production build: ```bash bun dist/index.asena.js ``` ::: tip Build Output The CLI automatically discovers and bundles all your components. No manual imports needed! ::: ## Step 7: Add Middleware (Optional) Generate a middleware: ```bash asena g m ``` Enter the middleware name: ```bash ✔ Enter middleware name: Logger ``` Edit `src/middlewares/LoggerMiddleware.ts`: ```typescript import { Middleware } from '@asenajs/asena/decorators'; import { MiddlewareService, type Context } from '@asenajs/ergenecore'; @Middleware() export class LoggerMiddleware extends MiddlewareService { async handle(context: Context, next: () => Promise) { const start = Date.now(); const method = context.req.method; const url = context.req.url; console.log(`[${method}] ${url} - Started`); await next(); const duration = Date.now() - start; console.log(`[${method}] ${url} - Completed in ${duration}ms`); } } ``` Apply it to your controller: ```typescript @Controller({ path: '/users', middlewares: [LoggerMiddleware] }) export class UserController { // ... your routes } ``` ## Project Structure Your project should now look like this: ``` my-asena-app/ ├── src/ │ ├── controllers/ │ │ ├── AsenaController.ts # Default controller │ │ └── UserController.ts # Your controller │ ├── services/ │ │ └── UserService.ts # Your service │ ├── middlewares/ │ │ └── LoggerMiddleware.ts # Your middleware │ └── index.ts # Entry point ├── dist/ # Build output ├── asena-config.ts # CLI configuration ├── package.json └── tsconfig.json ``` ## Next Steps Now that you've created your first Asena application: - Learn about [Controllers](/docs/concepts/controllers) - Explore [Services](/docs/concepts/services) - Understand [Middleware](/docs/concepts/middleware) - Set up [Database integration](/docs/packages/drizzle) - Configure [WebSocket](/docs/concepts/websocket) - Deploy to [Production](/docs/guides/deployment) --- **Related Documentation:** - [CLI Commands](/docs/cli/commands) - All CLI commands - [CLI Configuration](/docs/cli/configuration) - Configure your project - [Get Started Guide](/docs/get-started) - Complete guide - [Adapters](/docs/adapters/overview) - Learn about adapters --- # Configuration Section: Guides Source: https://asena.sh/raw/guides/configuration.md # @Config - Application Configuration The `@Config` decorator provides a centralized way to configure your Asena application, including server options, error handling, and global middleware. It allows you to customize both Bun's native server settings and Asena-specific features in a type-safe manner. ## Quick Start ::: code-group ```typescript [Ergenecore] import type { ConfigService, Context } from '@asenajs/ergenecore'; import type { AsenaServeOptions } from '@asenajs/asena/adapter'; import { Config } from '@asenajs/asena/decorators'; @Config() export class AppConfig implements ConfigService { public serveOptions(): AsenaServeOptions { return { serveOptions: { development: true, // port comes from AsenaServerFactory.create({ port }) - see Network Configuration }, wsOptions: { perMessageDeflate: true, idleTimeout: 120, }, }; } public onError(error: Error, _context: Context) { console.error('Application error:', error); return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } } ``` ```typescript [Hono] import type { ConfigService, Context } from '@asenajs/hono-adapter'; import type { AsenaServeOptions } from '@asenajs/asena/adapter'; import { Config } from '@asenajs/asena/decorators'; @Config() export class AppConfig implements ConfigService { public serveOptions(): AsenaServeOptions { return { serveOptions: { development: true, // port comes from AsenaServerFactory.create({ port }) - see Network Configuration }, wsOptions: { perMessageDeflate: true, idleTimeout: 120, }, }; } public onError(error: Error, _context: Context) { console.error('Application error:', error); return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } } ``` ::: ## API Reference ### @Config Decorator Marks a class as the application configuration component. **Only one `@Config` class is allowed per application.** ```typescript function Config(params?: ComponentParams | string): ClassDecorator ``` **Parameters:** - `params` (optional) - Configuration name or component parameters **Example:** ```typescript @Config('AppConfig') class AppConfig implements AsenaConfig { // ... } ``` ### AsenaConfig Interface The configuration interface that your config class should implement. **All methods are optional.** ```typescript interface AsenaConfig = AsenaContext> { /** * Configure server options */ serveOptions?(): AsenaServeOptions; /** * Custom error handler for unhandled errors */ onError?(error: Error, context: C): Response | Promise; /** * Answers a request that matched no route */ onNotFound?(context: C, request: NotFoundRequest): Response | Promise; /** * Global middleware configuration with pattern-based filtering */ globalMiddlewares?(): Promise | GlobalMiddlewareEntry[]; /** * WebSocket and/or microservice transport configuration */ transport?(): | WebSocketTransport | AsenaTransportConfig | Promise; } ``` ## serveOptions() Method Configure Bun server options and WebSocket settings. This method returns `AsenaServeOptions` which contains both HTTP server configuration and WebSocket-specific options. ### Type Structure ```typescript interface AsenaServeOptions { serveOptions?: AsenaServerOptions; // Bun server configuration wsOptions?: WSOptions; // WebSocket configuration } ``` ### AsenaServerOptions Type-safe wrapper around Bun's `ServeOptions` that excludes framework-managed properties: ```typescript type AsenaServerOptions = Omit; ``` **❌ Excluded Options (Managed by AsenaJS):** | Property | Managed By | Reason | |----------|------------|--------| | `fetch` | HTTP Adapter (e.g., HonoAdapter) | Core request handler | | `routes` | AsenaJS decorators (@Get, @Post, etc.) | Route definitions | | `websocket` | AsenaWebsocketAdapter | WebSocket handler | | `error` | AsenaConfig.onError() | Error handling | **✅ Available Options:** | Category | Options | Description | |----------|---------|-------------| | **Network** | `hostname`, `port`, `unix` | Network interface binding | | **Network** | `reusePort`, `ipv6Only` | Advanced networking | | **Security** | `tls` | TLS/SSL configuration | | **Performance** | `maxRequestBodySize`, `idleTimeout` | Performance tuning | | **Development** | `development`, `id` | Development features | ### Network Configuration ```typescript @Config() class AppConfig implements AsenaConfig { public serveOptions(): AsenaServeOptions { return { serveOptions: { reusePort: true, // Enable load balancing across processes ipv6Only: false, // Allow both IPv4 and IPv6 }, }; } } ``` ::: danger `port` and `hostname` do not belong here **`port`** is always overwritten. `AsenaServer.start()` pushes the port from `AsenaServerFactory.create({ port })` into the adapter on every start, after `serveOptions()` has been read - so a `port` (including `port: 0`) inside `serveOptions` never takes effect. Set it on the factory: ```typescript const server = await AsenaServerFactory.create({ adapter, logger, port: 8080 }); ``` **`hostname`** is adapter-specific. Hono honours `serveOptions.hostname`; Ergenecore overwrites it with the value given to the factory function: ```typescript const adapter = createErgenecoreAdapter({ hostname: '0.0.0.0' }); ``` ::: **Unix Socket Configuration:** A unix socket is a *start* option, not a serve option - Bun rejects `hostname` and `unix` together, so the framework only wires it through `start()`: ```typescript await server.start({ unix: '/tmp/asena.sock' }); ``` ### TLS/SSL Configuration Configure HTTPS with TLS certificates. ```typescript @Config() class AppConfig implements AsenaConfig { public serveOptions(): AsenaServeOptions { return { serveOptions: { hostname: 'mydomain.com', port: 443, tls: { cert: Bun.file('/path/to/cert.pem'), key: Bun.file('/path/to/key.pem'), ca: Bun.file('/path/to/ca.pem'), // Optional: CA certificate passphrase: 'secret', // Optional: Key passphrase serverName: 'mydomain.com', // Optional: SNI server name lowMemoryMode: false, // Optional: Reduce memory footprint dhParamsFile: '/path/to/dhparams.pem', // Optional: DH parameters }, }, }; } } ``` **Multiple TLS Certificates (SNI Support):** ```typescript public serveOptions(): AsenaServeOptions { return { serveOptions: { tls: [ { cert: Bun.file('/path/to/domain1-cert.pem'), key: Bun.file('/path/to/domain1-key.pem'), serverName: 'domain1.com', }, { cert: Bun.file('/path/to/domain2-cert.pem'), key: Bun.file('/path/to/domain2-key.pem'), serverName: 'domain2.com', }, ], }, }; } ``` ### Performance Configuration Tune server performance and resource limits. ```typescript @Config() class AppConfig implements AsenaConfig { public serveOptions(): AsenaServeOptions { return { serveOptions: { maxRequestBodySize: 10 * 1024 * 1024, // 10MB max body size idleTimeout: 120, // 120 seconds idle timeout }, }; } } ``` **Options:** - **`maxRequestBodySize`** - Maximum allowed request body size in bytes. Requests exceeding this limit will be rejected. - **`idleTimeout`** - Maximum time (in seconds) an HTTP connection can remain idle before being closed. Bun's default is **10 seconds** (the 120 s default belongs to `wsOptions.idleTimeout`). ### Development Mode Enable development features for better debugging. ```typescript @Config() class AppConfig implements AsenaConfig { public serveOptions(): AsenaServeOptions { return { serveOptions: { development: process.env.NODE_ENV !== 'production', id: 'my-app-server', // Used for hot reload identification }, }; } } ``` ### WebSocket Configuration (wsOptions) Configure WebSocket-specific settings for real-time communication. ```typescript interface WSOptions { maxPayloadLimit?: number; // See the caveat below - currently not applied backpressureLimit?: number; // Backpressure threshold closeOnBackpressureLimit?: boolean; // Close on backpressure idleTimeout?: number; // WebSocket idle timeout publishToSelf?: boolean; // Receive own published messages sendPings?: boolean; // See the caveat below - superseded by sendPingStrategy sendPingStrategy?: 'adapter' | 'native'; // Keep-alive mechanism (default 'adapter') heartbeatInterval?: number; // Heartbeat period in ms, 'adapter' strategy only perMessageDeflate: // Compression configuration (required) | boolean | { compress?: boolean | WebSocketCompressor; decompress?: boolean | WebSocketCompressor; }; } ``` **Complete WebSocket Configuration:** ```typescript @Config() class AppConfig implements AsenaConfig { public serveOptions(): AsenaServeOptions { return { wsOptions: { // Size and Buffer Limits maxPayloadLimit: 16 * 1024 * 1024, // 16MB (default) backpressureLimit: 1024 * 1024, // 1MB backpressure threshold closeOnBackpressureLimit: false, // Don't auto-close on backpressure // Timeout idleTimeout: 120, // 120 seconds idle timeout // Publishing publishToSelf: false, // Don't receive own messages // Keep-Alive ('adapter' is the default; 'native' delegates to Bun) sendPingStrategy: 'adapter', heartbeatInterval: 30_000, // no heartbeat is sent without this // Compression (required field) perMessageDeflate: true, // Enable compression with defaults }, }; } } ``` **WebSocket Configuration Details:** | Option | Default | Description | |--------|---------|-------------| | `maxPayloadLimit` | 16 MB | Intended as the maximum message size. **Currently has no effect** - see the caveat below. | | `backpressureLimit` | 16 MB (Bun's default) | Threshold for backpressure detection. Triggers `drain` event. | | `closeOnBackpressureLimit` | `false` | Whether to close connection when backpressure limit is reached. | | `idleTimeout` | 120 seconds | Auto-close connections exceeding idle period. | | `publishToSelf` | `false` | Whether socket receives its own published messages. | | `sendPings` | — | **Ignored.** Superseded by `sendPingStrategy`; the adapters overwrite whatever you pass. | | `sendPingStrategy` | `'adapter'` | `'adapter'` uses the framework's own `ws.ping()` heartbeat; `'native'` hands keep-alive to Bun. | | `heartbeatInterval` | — | Heartbeat period in ms for the `'adapter'` strategy. With no value, no heartbeat is sent. | | `perMessageDeflate` | `false` | Enable per-message compression (reduces bandwidth). **Required field.** | ::: warning `maxPayloadLimit` is not applied The adapters forward `wsOptions` to `Bun.serve()` verbatim, but Bun's option is named `maxPayloadLength`. `maxPayloadLimit` is therefore an unknown key that Bun silently drops, and the cap stays at Bun's 16 MB default. Do not rely on it to bound message size. ::: **Advanced Compression Configuration:** ```typescript public serveOptions(): AsenaServeOptions { return { wsOptions: { perMessageDeflate: { compress: true, // Enable compression decompress: false, // Disable decompression }, }, }; } ``` **Available compressor values:** - `true` / `false` - Enable/disable with defaults - `WebSocketCompressor` - Custom compressor configuration ## onError() Method Custom error handler for application-wide error handling. This method is called whenever an unhandled error occurs during request processing. ```typescript onError?(error: Error, context: C): Response | Promise ``` **Parameters:** - `error` - The error that occurred - `context` - The Asena context for the current request **Returns:** `Response` or `Promise` ### Basic Error Handler ```typescript @Config() class AppConfig implements AsenaConfig { public onError(error: Error, context: AsenaContext) { console.error('Application error:', error); return new Response( JSON.stringify({ error: error.message }), { status: 500, headers: { 'Content-Type': 'application/json' }, } ); } } ``` ### Production Error Handler Hide sensitive error details in production. ```typescript @Config() class AppConfig implements AsenaConfig { public onError(error: Error, context: AsenaContext) { const isDevelopment = process.env.NODE_ENV !== 'production'; // Log full error details console.error('[ERROR]', { message: error.message, stack: error.stack, url: context.req.url, }); // Return appropriate response if (isDevelopment) { return new Response( JSON.stringify({ error: error.message, stack: error.stack, url: context.req.url, }), { status: 500, headers: { 'Content-Type': 'application/json' }, } ); } return new Response( JSON.stringify({ error: 'Internal Server Error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, } ); } } ``` ### Error Handler with Dependency Injection Integrate with dependency injection for advanced error handling. ```typescript @Config() class AppConfig implements AsenaConfig { @Inject(LoggerService) private logger!: LoggerService; @Inject(ErrorReportingService) private errorReporter!: ErrorReportingService; public async onError(error: Error, context: AsenaContext) { // Log to logging service await this.logger.error('Unhandled error', { error: error.message, stack: error.stack, url: context.req.url, }); // Report to external service (Sentry, etc.) await this.errorReporter.report(error); // Return user-friendly error return new Response( JSON.stringify({ error: 'Something went wrong. Please try again later.', }), { status: 500, headers: { 'Content-Type': 'application/json' }, } ); } } ``` ## onNotFound() Method Answers a request that matched no route. Separate from `onError` on purpose: nothing threw, the router simply had nowhere to send the request, so neither handler has to ask which case it is looking at. ```typescript onNotFound?(context: C, request: NotFoundRequest): Response | Promise ``` **Parameters:** - `context` - The request context - `request` - `{ path, method }`, normalised by the adapter **Returns:** `Response` or `Promise` ### Basic Usage ```typescript import { Config } from '@asenajs/asena/decorators'; import type { NotFoundRequest } from '@asenajs/asena/adapter'; import { ConfigService, type Context } from '@asenajs/hono-adapter'; @Config() export class AppConfig extends ConfigService { public onNotFound(context: Context, request: NotFoundRequest) { return context.send({ type: 'about:blank', title: 'Not Found', status: 404, instance: request.path }, 404); } } ``` `request.path` carries the path only - no origin, no query string - and `request.method` is the upper-case verb. Both adapters produce identical values, so the same body works under either. ### Defaults With no `onNotFound` declared, both adapters answer `{"error":"Not Found"}` with status `404` and `Content-Type: application/json`. Global middlewares run **before** `onNotFound` on both adapters, so a 404 still carries CORS headers and anything else you apply to every request. If the hook throws, the adapter logs it and falls back to the default 404 rather than taking the server down. It is deliberately **not** routed to `onError`. ::: warning Not for domain 404s When the route exists but the record does not, throw instead - that reaches `onError` like any other application decision: ```typescript if (!user) { throw new HttpException(404, { code: 'USER_NOT_FOUND' }); } ``` ::: ## globalMiddlewares() Method Configure global middleware that applies to all or specific routes. Supports both simple array syntax and pattern-based filtering. ```typescript globalMiddlewares?(): Promise | GlobalMiddlewareEntry[] ``` **Returns:** Array of middleware classes or `GlobalMiddlewareEntry` objects ::: danger It must be a method, not a property Asena reads global middleware by calling `globalMiddlewares()`. A `middlewares = [...]` property on the config class is never read - the server starts normally and the middleware simply never runs. The server warns at startup when it finds such a property, but the warning is scrollback: if middleware seems to be skipped, check the shape of this hook first. ::: ### Simple Global Middleware Apply middleware to all routes. ```typescript @Config() class AppConfig implements AsenaConfig { public globalMiddlewares() { return [ LoggerMiddleware, CorsMiddleware, CompressionMiddleware, ]; } } ``` ### Pattern-Based Middleware Apply middleware to specific route patterns using `include` and `exclude` filters. ```typescript @Config() class AppConfig implements AsenaConfig { public globalMiddlewares() { return [ // Apply to all routes LoggerMiddleware, // Apply only to /api/* and /admin/* routes { middleware: AuthMiddleware, routes: { include: ['/api/*', '/admin/*'], }, }, // Apply to all routes except /health and /metrics { middleware: RateLimitMiddleware, routes: { exclude: ['/health', '/metrics'], }, }, // Complex pattern: only /api/* but not /api/public/* { middleware: JwtMiddleware, routes: { include: ['/api/*'], exclude: ['/api/public/*'], }, }, ]; } } ``` ### GlobalMiddlewareEntry ```typescript type GlobalMiddlewareEntry = | MiddlewareClass | { middleware: MiddlewareClass; routes?: { include?: string[]; // Glob patterns to include exclude?: string[]; // Glob patterns to exclude }; }; ``` **Pattern Matching:** A `*` matches any characters **including `/`**, so a pattern covers every path below it. `**` is not a separate construct - it compiles to the same regex as `*`. | Path | `/api/*` | `/api/**` | |:-----|:---------|:----------| | `/api/users` | ✅ | ✅ | | `/api/v1/users` | ✅ | ✅ | | `/api` (no trailing segment) | ❌ | ❌ | Other forms: an exact path (`/health`) matches literally, trailing slashes are normalized, and `:param` patterns (`/users/:id`) match a single segment. ::: danger `/api/*` is recursive A wildcard does **not** stop at the next `/`. If you write `{ middleware: AuthMiddleware, routes: { include: ['/api/*'] } }` it protects `/api/v1/admin/users` too - which is usually what you want, but is the opposite of a single-segment glob. Conversely `include: ['/api/*']` does **not** cover the bare `/api` path; list it explicitly if you need it. ::: ### Execution Order Middleware executes in the order defined in the array: ```typescript public globalMiddlewares() { return [ LoggerMiddleware, // 1. Runs first AuthMiddleware, // 2. Runs second ValidationMiddleware, // 3. Runs third RateLimitMiddleware, // 4. Runs last ]; } ``` ### Async Global Middleware The method can return a Promise for async configuration. ```typescript @Config() class AppConfig implements AsenaConfig { @Inject(ConfigService) private configService!: ConfigService; public async globalMiddlewares() { const config = await this.configService.load(); const middlewares = [LoggerMiddleware]; if (config.enableAuth) { middlewares.push(AuthMiddleware); } if (config.enableRateLimit) { middlewares.push(RateLimitMiddleware); } return middlewares; } } ``` ## transport() Method Configure the transport layer: cross-pod **WebSocket** messaging and/or **microservice** messaging. Two return forms are supported: ```typescript transport?(): WebSocketTransport | AsenaTransportConfig | Promise ``` 1. **Bare `WebSocketTransport`** (backward compatible) — configures only the WebSocket transport. When not specified, Asena uses `BunLocalTransport` which calls `server.publish()` directly — zero overhead for single-pod deployments. 2. **`AsenaTransportConfig` object** — configures WebSocket and microservice transports separately, plus messaging interceptors: ```typescript interface AsenaTransportConfig { websocket?: WebSocketTransport; // cross-pod WS microservice?: MicroserviceTransport | Record; // single or named map interceptors?: MessagingInterceptor[]; // e.g. otelMessaging() } ``` ### Single-Pod (Default) No configuration needed. Asena uses `BunLocalTransport` automatically. ### Multi-Pod with RedisTransport For multi-pod deployments, return a `RedisTransport` instance to synchronize WebSocket messages across pods via Redis pub/sub: ::: code-group ```typescript [With Injected Redis] import { Config } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { ConfigService } from '@asenajs/hono-adapter'; // or '@asenajs/ergenecore' import { RedisTransport } from '@asenajs/asena-redis'; @Config() export class AppConfig extends ConfigService { @Inject('AppRedis') private redis: AppRedis; public transport() { return new RedisTransport(this.redis); } } ``` ```typescript [Standalone] import { Config } from '@asenajs/asena/decorators'; import { ConfigService } from '@asenajs/hono-adapter'; // or '@asenajs/ergenecore' import { RedisTransport } from '@asenajs/asena-redis'; @Config() export class AppConfig extends ConfigService { public transport() { return new RedisTransport({ url: 'redis://localhost:6379' }); } } ``` ::: ### Microservice Messaging (Object Form) Use the object form to add a microservice transport — with or without a WebSocket transport: ```typescript import { Config } from '@asenajs/asena/decorators'; import { RedisTransport, RedisMicroserviceTransport } from '@asenajs/asena-redis'; import { otelMessaging } from '@asenajs/asena-otel'; @Config() export class AppConfig extends ConfigService { public transport() { return { websocket: new RedisTransport({ url: 'redis://localhost:6379' }), // optional microservice: new RedisMicroserviceTransport( { url: 'redis://localhost:6379' }, // connection { serviceName: 'order-service' }, // required options ), interceptors: [otelMessaging({ system: 'redis' })], // optional }; } } ``` A single microservice transport is registered under the name `default`. Multi-broker projects can pass a named map instead and bind controllers per transport — see [Microservices - Multiple Named Transports](/docs/concepts/microservices#multiple-named-transports). ::: info For details on how transport works with WebSocket pub/sub, see [WebSocket - Multi-Pod](/docs/concepts/websocket#multi-pod-websocket). For RedisTransport setup and configuration, see [Redis Package](/docs/packages/redis#multi-pod-websocket-transport). For microservice messaging concepts, see [Microservices](/docs/concepts/microservices). ::: ## Complete Example A real-world configuration example combining all features: ::: code-group ```typescript [Ergenecore] import { Config, Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import type { ConfigService, Context } from '@asenajs/ergenecore'; import type { AsenaServeOptions } from '@asenajs/asena/adapter'; @Service() class LoggerService { public error(message: string, meta: any) { console.error(`[ERROR] ${message}`, meta); } } @Config() export class AppConfig implements ConfigService { @Inject(LoggerService) private logger!: LoggerService; public serveOptions(): AsenaServeOptions { const isProduction = process.env.NODE_ENV === 'production'; return { serveOptions: { // port/hostname are not read from here - see Network Configuration development: !isProduction, maxRequestBodySize: 10 * 1024 * 1024, // 10MB idleTimeout: isProduction ? 30 : 120, tls: isProduction ? { cert: Bun.file(process.env.TLS_CERT!), key: Bun.file(process.env.TLS_KEY!), } : undefined, }, wsOptions: { perMessageDeflate: isProduction, maxPayloadLimit: 5 * 1024 * 1024, // 5MB backpressureLimit: 1024 * 1024, // 1MB closeOnBackpressureLimit: false, idleTimeout: 120, publishToSelf: false, }, }; } public async onError(error: Error, context: Context) { await this.logger.error('Unhandled error', { error: error.message, stack: error.stack, url: context.req.url, }); const isProduction = process.env.NODE_ENV === 'production'; if (isProduction) { return context.send({ error: 'Internal Server Error' }, 500); } return context.send({ error: error.message, stack: error.stack, }, 500); } public globalMiddlewares() { return [ LoggerMiddleware, { middleware: AuthMiddleware, routes: { include: ['/api/**'], exclude: ['/api/public/**'], }, }, { middleware: RateLimitMiddleware, routes: { exclude: ['/health'], }, }, ]; } } ``` ```typescript [Hono] import { Config, Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import type { ConfigService, Context } from '@asenajs/hono-adapter'; import type { AsenaServeOptions } from '@asenajs/asena/adapter'; @Service() class LoggerService { public error(message: string, meta: any) { console.error(`[ERROR] ${message}`, meta); } } @Config() export class AppConfig implements ConfigService { @Inject(LoggerService) private logger!: LoggerService; public serveOptions(): AsenaServeOptions { const isProduction = process.env.NODE_ENV === 'production'; return { serveOptions: { // port/hostname are not read from here - see Network Configuration development: !isProduction, maxRequestBodySize: 10 * 1024 * 1024, // 10MB idleTimeout: isProduction ? 30 : 120, tls: isProduction ? { cert: Bun.file(process.env.TLS_CERT!), key: Bun.file(process.env.TLS_KEY!), } : undefined, }, wsOptions: { perMessageDeflate: isProduction, maxPayloadLimit: 5 * 1024 * 1024, // 5MB backpressureLimit: 1024 * 1024, // 1MB closeOnBackpressureLimit: false, idleTimeout: 120, publishToSelf: false, }, }; } public async onError(error: Error, context: Context) { await this.logger.error('Unhandled error', { error: error.message, stack: error.stack, url: context.req.url, }); const isProduction = process.env.NODE_ENV === 'production'; if (isProduction) { return context.send({ error: 'Internal Server Error' }, 500); } return context.send({ error: error.message, stack: error.stack, }, 500); } public globalMiddlewares() { return [ LoggerMiddleware, { middleware: AuthMiddleware, routes: { include: ['/api/**'], exclude: ['/api/public/**'], }, }, { middleware: RateLimitMiddleware, routes: { exclude: ['/health'], }, }, ]; } } ``` ::: ## Technical Details ### Bootstrap Lifecycle The `@Config` decorator is processed during the application bootstrap sequence: 1. **Phase: CONTAINER_INIT** - IoC container initializes 2. **Phase: IOC_ENGINE_INIT** - Component discovery begins 3. **Phase: USER_COMPONENTS_SCAN** - Config class is discovered and registered 4. **Phase: USER_COMPONENTS_INIT** - Config instance is created 5. **Phase: APPLICATION_SETUP** - Config methods are applied: - `serveOptions()` is called and passed to adapter - `onError()` is registered as error handler - `onNotFound()` is registered as the unmatched-route handler - `globalMiddlewares()` is called and middleware are registered - `transport()` is called and the WebSocket / microservice transports are wired 6. **Phase: SERVER_READY** - Server starts with applied configuration ### Singleton Validation AsenaJS enforces a single `@Config` instance per application. If multiple `@Config` classes are detected, an error is thrown during bootstrap. ```typescript // ✅ Valid: Single config @Config() class AppConfig implements AsenaConfig { } // ❌ Invalid: Multiple configs (throws error during bootstrap) @Config() class AppConfig1 implements AsenaConfig { } @Config() class AppConfig2 implements AsenaConfig { } // Error! ``` ### Dependency Injection in Config Config classes are regular components in the IoC container, so you can use `@Inject` to inject other services: ```typescript @Config() class AppConfig implements AsenaConfig { @Inject(DatabaseService) private db!: DatabaseService; @Inject(LoggerService) private logger!: LoggerService; public async serveOptions(): Promise { const settings = await this.db.getSettings(); return { serveOptions: { port: settings.port, hostname: settings.hostname, }, }; } } ``` ::: warning Async `serveOptions()` - runtime yes, types no The adapters `await` the result, so returning a Promise works at runtime. But `AsenaConfig.serveOptions?(): AsenaServeOptions` declares a synchronous return, so a class that `implements AsenaConfig` / `extends ConfigService` cannot legally declare the method `async`. Prefer resolving async values before the server starts and passing them in, or widen the type at the call site. ::: ## Best Practices ### ✅ Do's 1. **Use Environment Variables** ```typescript port: parseInt(process.env.PORT || '3000', 10), hostname: process.env.HOSTNAME || 'localhost', ``` 2. **Separate Development and Production Configuration** ```typescript const isProduction = process.env.NODE_ENV === 'production'; development: !isProduction, idleTimeout: isProduction ? 30 : 120, ``` 3. **Keep Config Simple** - Config should focus on configuration, not business logic - Use services for complex logic, inject them if needed 4. **Enable Compression in Production** ```typescript wsOptions: { perMessageDeflate: process.env.NODE_ENV === 'production', } ``` 5. **Log Errors Properly** ```typescript public onError(error: Error, context: AsenaContext) { console.error('[ERROR]', { message: error.message, stack: error.stack, url: context.req.url, }); // Return response... } ``` ### ❌ Don'ts 1. **Don't Create Multiple @Config Classes** ```typescript // ❌ Wrong: Only one @Config allowed per application @Config() class Config1 { } @Config() class Config2 { } // Error! ``` 2. **Don't Try to Set Framework-Managed Properties** ```typescript // ❌ Wrong: These cause TypeScript compile errors serveOptions: { fetch: () => new Response(), // Compile error! routes: { '/': new Response() }, // Compile error! websocket: { /* ... */ }, // Compile error! } ``` 3. **Don't Hardcode Secrets** ```typescript // ❌ Wrong: Never hardcode secrets tls: { key: Bun.file('/path/to/key.pem'), passphrase: 'mySecretPassword123', // Use env vars! } ``` 4. **Don't Forget perMessageDeflate When Using WebSockets** ```typescript // ❌ Wrong: perMessageDeflate is required in WSOptions wsOptions: { maxPayloadLimit: 1024, // Missing perMessageDeflate! } // ✅ Correct wsOptions: { perMessageDeflate: true, maxPayloadLimit: 1024, } ``` ## Troubleshooting ### "Only one config is allowed" **Error:** Multiple `@Config` classes are defined in your application. **Solution:** Keep only one `@Config` class. Use conditionals for environment-specific configs: ```typescript @Config() class AppConfig implements AsenaConfig { public serveOptions(): AsenaServeOptions { if (process.env.NODE_ENV === 'production') { return { /* production config */ }; } return { /* development config */ }; } } ``` ### Type Errors with serveOptions **Error:** TypeScript complains about `fetch`, `routes`, `websocket`, or `error` properties. **Solution:** These properties are excluded from `AsenaServerOptions`: ```typescript // ❌ Wrong: TypeScript will show errors serveOptions: { fetch: () => new Response(), // Not allowed! } // ✅ Correct serveOptions: { port: 3000, hostname: 'localhost', tls: { /* ... */ }, } ``` ### WebSocket Configuration Not Working **Problem:** WebSocket options don't seem to apply. **Solution:** Use `wsOptions`, not `serveOptions.websocket`: ```typescript // ❌ Wrong { serveOptions: { websocket: { /* ... */ }, // Not allowed! } } // ✅ Correct { wsOptions: { perMessageDeflate: true, maxPayloadLimit: 1024 * 1024, } } ``` ## Related Documentation - [Middleware](/docs/concepts/middleware) - Learn about middleware patterns - [Error Handling](/docs/guides/error-handling) - Advanced error handling strategies - [WebSocket](/docs/concepts/websocket) - WebSocket implementation guide - [CLI Configuration](/docs/cli/configuration) - Asena CLI build configuration --- **Next Steps:** - Learn about [Middleware patterns](/docs/concepts/middleware) - Explore [Error Handling](/docs/guides/error-handling) strategies - Set up [WebSocket](/docs/concepts/websocket) communication --- # Error Handling Section: Guides Source: https://asena.sh/raw/guides/error-handling.md # Error Handling Error handling is a critical part of building robust web applications. Asena provides a powerful and flexible error handling system that works seamlessly with both Ergenecore and Hono adapters. ## Why Error Handling Matters Proper error handling ensures: - **User Experience**: Clear, consistent error messages help users understand what went wrong - **Debugging**: Structured errors with context make troubleshooting easier - **Security**: Proper error responses prevent sensitive information leakage - **Maintainability**: Centralized error handling reduces code duplication ## Philosophy Asena's error handling philosophy: 1. **Explicit over implicit**: Throw errors explicitly, handle them centrally 2. **Type-safe**: Use TypeScript classes for compile-time safety 3. **Adapter-agnostic**: Same patterns work across Ergenecore and Hono 4. **Production-ready**: Built-in support for logging and monitoring --- ## Basic Error Handling ### Throwing HTTP Exceptions The simplest way to handle errors in Asena is to throw an `HttpException` (Ergenecore) or `HTTPException` (Hono). #### Ergenecore Adapter ::: code-group ```typescript [Ergenecore] import { Controller } from '@asenajs/asena/decorators'; import { Get } from '@asenajs/asena/decorators/http'; import { HttpException } from '@asenajs/ergenecore'; import type { Context } from '@asenajs/ergenecore'; @Controller('/users') export class UserController { @Get('/:id') async getUser(context: Context) { const id = context.getParam('id'); const user = await findUserById(id); if (!user) { // Throw HttpException with status code and message throw new HttpException(404, 'User not found'); } return context.send(user); } } ``` ```typescript [Hono] import { Controller } from '@asenajs/asena/decorators'; import { Get } from '@asenajs/asena/decorators/http'; import { HTTPException } from 'hono/http-exception'; import type { Context } from '@asenajs/hono-adapter'; @Controller('/users') export class UserController { @Get('/:id') async getUser(context: Context) { const id = context.getParam('id'); const user = await findUserById(id); if (!user) { // Throw HTTPException with status code and response const response = context.send({ error: 'User not found' }, 404); throw new HTTPException(404, { res: response as Response }); } return context.send(user); } } ``` ::: ### HttpException API The `HttpException` class accepts three parameters: ```typescript new HttpException(status, body, options?) ``` | Parameter | Type | Required | Description | |:----------|:-----|:---------|:------------| | `status` | `HttpStatusCode \| number` | Yes | HTTP status code. The `ClientErrorStatusCode` / `ServerErrorStatusCode` enums are accepted. | | `body` | `string \| object` | No (default `''`) | Response body. An object is serialized and gets `Content-Type: application/json` automatically. | | `options` | `HttpExceptionInit` | No | Extends `ResponseInit` (headers, statusText) with `cause?: Error` for wrapping the original failure. | **Examples:** ```typescript // Simple string message throw new HttpException(404, 'Not Found'); // JSON object response throw new HttpException(400, { error: 'Invalid input', field: 'email' }); // With custom headers throw new HttpException(429, 'Too Many Requests', { headers: { 'Retry-After': '60' } }); // With status text throw new HttpException(503, 'Service Unavailable', { statusText: 'Maintenance Mode' }); ``` ::: tip Your handler always gets first refusal Both adapters turn the exception into a proper HTTP response without you catching it, and both offer it to `onError` first: 1. `onError` is called. If it returns a `Response`, that is the answer. 2. If there is no handler, it returns nothing, or it throws, the exception answers itself from its own status and body. Anything that is not an `HttpException` becomes a 500. So an ExceptionMapper that logs or enriches thrown exceptions works the same on both adapters. Before 0.9.0 Ergenecore answered an `HttpException` straight from `getResponse()` and only consulted `onError` for everything else, so the same ExceptionMapper worked on Hono and was bypassed here. If you worked around that by throwing a plain domain error, you can now throw `HttpException` directly. ::: ::: warning Match the exception with `isHttpException()`, not `instanceof` A project that resolves two copies of an adapter - or two copies of `hono`, which is a peer dependency - ends up with two distinct exception classes, and `instanceof` silently answers false for one of them. Every deliberate 401/403/404 then collapses to your generic 500 branch while the API keeps responding. ```typescript import { isHttpException } from '@asenajs/asena/adapter'; public onError(error: Error, context: Context) { if (isHttpException(error)) { return context.send({ error: error.message }, error.status); } return context.send({ error: 'Internal Server Error' }, 500); } ``` ::: --- ## Global Error Handler For production applications, you'll want centralized error handling to ensure consistent error responses and proper logging. ### Using onError() Hook Both adapters support the `onError()` hook in your `ServerConfig` class. #### Basic Global Error Handler ```typescript import { Config } from '@asenajs/asena/decorators'; import { ConfigService, type Context } from '@asenajs/ergenecore'; @Config() export class ServerConfig extends ConfigService { public onError(error: Error, context: Context): Response | Promise { // Log the error console.error('Error occurred:', error); // Return custom response return context.send({ success: false, message: error.message, timestamp: new Date().toISOString() }, 500); } } ``` ### Advanced: ExceptionMapper Pattern For complex applications, use the **ExceptionMapper pattern** to handle different error types with dependency injection. #### Step 1: Create ExceptionMapper ```typescript // src/exceptions/ExceptionMapper.ts import { Scope } from '@asenajs/asena/decorators/ioc'; import { Component } from '@asenajs/asena/decorators'; import type { Context } from '@asenajs/hono-adapter'; import { HTTPException } from 'hono/http-exception'; import { isValidationError } from '@asenajs/asena/adapter'; import { ClientErrorStatusCode, ServerErrorStatusCode } from '@asenajs/asena/web-types'; @Component({ name: 'ExceptionMapper', scope: Scope.SINGLETON }) export class ExceptionMapper { public map(error: Error, context: Context): Response | Promise { const requestPath = context.req.path; const requestMethod = context.req.method; // Handle request validation errors. // Checked BEFORE HTTPException on purpose: ValidationError extends HTTPException, // so the generic branch below would otherwise swallow it if (isValidationError(error)) { const errors = error.issues.map((issue) => ({ field: issue.path.join('.'), message: issue.message })); return context.send({ success: false, message: 'Validation error', errors }, ClientErrorStatusCode.BadRequest); } // Handle HTTPException (from Hono or middleware) if (error instanceof HTTPException) { console.warn(`HTTP Exception: ${error.message}`, { path: requestPath, method: requestMethod, status: error.status }); return context.send(error.message, error.status); } // Handle custom domain errors if (error instanceof AuthError) { return context.send({ success: false, message: 'Authentication failed' }, ClientErrorStatusCode.Unauthorized); } // Default error response for unexpected errors console.error(`Internal Server Error: ${error.message}`, { path: requestPath, method: requestMethod, stack: error.stack }); return context.send({ success: false, message: 'Internal server error', error: process.env.NODE_ENV === 'development' ? error.message : undefined }, ServerErrorStatusCode.InternalServerError); } } ``` #### Step 2: Register in ServerConfig ```typescript // src/config/ServerConfig.ts import { Inject } from '@asenajs/asena/decorators/ioc'; import { Config } from '@asenajs/asena/decorators'; import { ConfigService, type Context } from '@asenajs/hono-adapter'; import type { ExceptionMapper } from '../exceptions/ExceptionMapper'; @Config() export class ServerConfig extends ConfigService { @Inject('ExceptionMapper') private mapper: ExceptionMapper; public onError(error: Error, context: Context): Response | Promise { return this.mapper.map(error, context); } } ``` ::: tip Production Pattern The ExceptionMapper pattern is used in production applications for centralized error handling with IoC integration. It provides a single place to manage all error types. ::: --- ## Custom Error Classes Create custom error classes for domain-specific errors in your application. ### Basic Custom Error ```typescript // src/errors/AuthError.ts export class AuthError extends Error { public constructor(message: string) { super(message); this.name = 'AuthError'; } } ``` ### Custom Error with Response For more control, store a custom response in your error: ```typescript // src/errors/AuthError.ts export class AuthError extends Error { private _response?: Response | Promise; public constructor(message: string, response?: Response | Promise) { super(message); this.name = 'AuthError'; this._response = response; } public get response(): Response | Promise | undefined { return this._response; } } ``` ### Using Custom Errors #### In Controllers ```typescript import { Controller } from '@asenajs/asena/decorators'; import { Post } from '@asenajs/asena/decorators/http'; import type { Context } from '@asenajs/hono-adapter'; import { AuthError } from '../errors/AuthError'; @Controller('/auth') export class AuthController { @Post('/login') async login(context: Context) { const { username, password } = await context.getBody<{ username: string; password: string }>(); const user = await validateCredentials(username, password); if (!user) { throw new AuthError('Invalid credentials'); } return context.send({ token: generateToken(user) }); } } ``` #### In Middleware ```typescript import { Middleware } from '@asenajs/asena/decorators'; import type { Context, MiddlewareService } from '@asenajs/hono-adapter'; import type { Next } from 'hono'; import { HTTPException } from 'hono/http-exception'; @Middleware() export class AuthMiddleware implements MiddlewareService { public async handle(context: Context, next: Next): Promise { const authHeader = context.req.header('authorization'); if (!authHeader) { const response = context.send({ error: 'Unauthorized', message: 'Missing Authorization header' }, 401); throw new HTTPException(401, { res: response as Response }); } await next(); } } ``` ### Mapping Custom Errors Handle custom errors in your `ExceptionMapper`: ```typescript public map(error: Error, context: Context): Response { // ... other error handlers // Handle AuthError if (error instanceof AuthError) { // Use custom response if provided if (error.response) { return error.response; } // Default auth error response return context.send({ success: false, message: error.message }, 401); } // ... default handler } ``` --- ## Not Found A request that matched no route is not an error - nothing threw, the router simply had nowhere to send it. It has its own hook, so `onError` only ever sees something your code raised and never has to ask which it is looking at. ```typescript import type { NotFoundRequest } from '@asenajs/asena/adapter'; @Config() export class AppConfig extends ConfigService { public onNotFound(context: Context, request: NotFoundRequest) { return context.send({ type: 'about:blank', title: 'Not Found', status: 404, instance: request.path }, 404); } public onError(error: Error, context: Context) { // No 404 branch needed here return context.send({ error: 'Internal Server Error' }, 500); } } ``` `request.path` is the path only - no origin, no query string - and `request.method` is the upper-case verb. Both are normalised by the adapter, so the same handler body works on Ergenecore and the Hono adapter alike. With no `onNotFound` declared, **both** adapters answer: ```json { "error": "Not Found" } ``` with status `404` and `Content-Type: application/json`, and write one INFO line - `Route not found:` with `{ path, method, status }`. A hook that answers the request itself replaces both. See [Adapter logging](#adapter-logging). ::: info Not the same as a domain 404 `onNotFound` is about routing. When the route exists but the record does not, throw - that is a real application decision and belongs in `onError`: ```typescript const user = await this.db.findUser(id); if (!user) { throw new HttpException(404, { code: 'USER_NOT_FOUND', id }); } ``` ::: ::: warning Upgrading from 0.8 `NotFoundError`, `isNotFoundError` and the `NOT_FOUND_ERROR` brand are removed. An `onError` that branched on `isNotFoundError()` should move that branch into `onNotFound`. On the Hono adapter the default 404 body also changes from `text/plain` to the JSON envelope above. ::: ::: tip Static file 404s are separate `StaticServeService.onNotFound` handles a file missing *inside* a `@StaticServe` route. It is unrelated to the config hook, which only fires when no route matched at all. ::: ### Adapter logging One rule, both adapters: **the framework's default log fires exactly when the framework's default response fires.** | | your hook answered | no hook, or it declined or threw | |:--|:--|:--| | response | yours | the framework's | | log | none | `5xx` ERROR + stack · `4xx` DEBUG (INFO when the logger has no `debug`) · `404` INFO | If your `onError` returns a `Response`, you own the response and therefore the record - log it there, with your correlation id, and the adapter stays out of the way. If it returns nothing or throws, the adapter is the one answering, so it writes the original error rather than letting it disappear. Same for `onNotFound`. The level split exists so a wall of 401s from a bot cannot flood the error stream: only `5xx` carries a stack. An unmatched route logs `Route not found:` with `{ path, method, status }` at INFO - low enough that a scanner walking `/wp-admin` and `/.env` cannot fill the warning stream, high enough that a mistyped route in a deployed client is visible without turning on debug. Pass `logErrors: false` (`createHonoAdapter` / `createErgenecoreAdapter`) to silence all of it, including the 404 line. There is no setting that forces a log line for a request your own handler answered. ::: warning Upgrading from ergenecore 1.5.x / hono-adapter 1.7.x Those versions logged only when **no** `onError` was registered - which in a real application meant never, since almost every application configures one. A 500 answered the client and wrote nothing at all, stack included. You will now see error output you did not see before: whenever the framework is the one answering. If your own handler answers and also logs, nothing is duplicated. ::: ## Validation Errors ### Request Validation Errors A failed request validation reaches your error handler like any other error, so it can share the same response envelope as the rest of your API. Match it with `isValidationError()` rather than `instanceof ZodError`: the framework wraps the failure in an adapter-specific `ValidationError` that carries HTTP status 400, and the guard works with both adapters. ```typescript import { isValidationError } from '@asenajs/asena/adapter'; public map(error: Error, context: Context): Response { if (isValidationError(error)) { // error.issues is adapter-agnostic: { path, message, code } const errors = error.issues.map((issue) => ({ field: issue.path.join('.'), message: issue.message, code: issue.code })); return context.send({ success: false, message: 'Validation failed', errors }, 400); } // ... other handlers } ``` ::: tip Reaching the original ZodError `error.cause` holds the underlying `ZodError` if you need something `issues` does not carry - `z.treeifyError(error.cause)`, for instance. Note that Zod 4 removed `ZodError.errors`; the field is now `issues`. ::: ::: info When no handler answers it A `ValidationError` is always thrown, whether or not you declare `onError`. If nothing answers it - no handler, or a handler that returns nothing - both adapters fall back to the same envelope: ```json { "error": "Validation failed", "details": { "formErrors": [], "fieldErrors": { "email": ["..."] } }, "target": "json" } ``` `target` is which part of the request failed: `json`, `query`, `param`, `form` or `header`. The failure is logged like any other 4xx. See [Validation](/docs/concepts/validation#validation-error-responses). ::: ### Custom Validation Error Response ```typescript { "success": false, "message": "Validation failed", "errors": [ { "field": "email", "message": "Invalid email format", "code": "invalid_string" }, { "field": "password", "message": "String must contain at least 8 character(s)", "code": "too_small" } ] } ``` ## Best Practices ### 1. Consistent Error Response Format Always return errors in a consistent format: ```typescript { "success": false, "message": "Human-readable error message", "error": "ERROR_CODE", "details": { /* optional additional context */ }, "timestamp": "2025-01-15T10:30:00.000Z" } ``` ### 2. Security Considerations ::: danger Never Leak Sensitive Information - **Don't expose stack traces** in production - **Don't return internal error messages** to clients - **Don't include database queries** or system paths - **Do sanitize error messages** before sending to clients ::: **Bad:** ```typescript return context.send({ error: error.stack, // ❌ Exposes internal details query: sql // ❌ Exposes database structure }, 500); ``` **Good:** ```typescript return context.send({ success: false, message: 'An error occurred. Please try again later.', ...(process.env.NODE_ENV === 'development' && { debug: error.message }) }, 500); ``` ### 3. Use Specific Status Codes Choose the right HTTP status code for each error: | Status Code | When to Use | |:------------|:------------| | `400` | Bad Request - Invalid input data | | `401` | Unauthorized - Authentication required | | `403` | Forbidden - User doesn't have permission | | `404` | Not Found - Resource doesn't exist | | `409` | Conflict - Resource already exists | | `422` | Unprocessable Entity - Validation failed | | `429` | Too Many Requests - Rate limit exceeded | | `500` | Internal Server Error - Unexpected server error | | `503` | Service Unavailable - Temporary downtime | ### 4. Error Boundaries Create error boundaries at different levels: ```typescript // Application-level (ServerConfig) public onError(error: Error, context: Context): Response { return this.mapper.map(error, context); } // Route-level (Controller) @Get('/:id') async getUser(context: Context) { try { // Risky operation return await this.userService.getUser(id); } catch (error) { // Handle specific errors if (error instanceof DatabaseError) { throw new HttpException(503, 'Database temporarily unavailable'); } throw error; // Let global handler deal with it } } // Service-level (Business Logic) async getUser(id: string) { if (!id) { throw new ValidationError('User ID is required'); } const user = await this.db.findUser(id); if (!user) { throw new HttpException(404, { code: 'USER_NOT_FOUND', id }); } return user; } ``` ### 5. Graceful Degradation Handle errors gracefully without crashing the application: ```typescript @Get('/dashboard') async getDashboard(context: Context) { try { const [users, posts, stats] = await Promise.allSettled([ this.userService.getUsers(), this.postService.getPosts(), this.statsService.getStats() ]); return context.send({ users: users.status === 'fulfilled' ? users.value : [], posts: posts.status === 'fulfilled' ? posts.value : [], stats: stats.status === 'fulfilled' ? stats.value : null, warnings: [ users.status === 'rejected' && 'Failed to load users', posts.status === 'rejected' && 'Failed to load posts', stats.status === 'rejected' && 'Failed to load stats' ].filter(Boolean) }); } catch (error) { throw new HttpException(500, 'Dashboard unavailable'); } } ``` ### 6. Error Context Always include request context in error logs: ```typescript logger.error('Payment processing failed', { userId: user.id, orderId: order.id, amount: order.total, path: context.req.path, method: context.req.method, userAgent: context.req.header('user-agent'), timestamp: new Date().toISOString() }); ``` --- ## Common Patterns ### Pattern 1: Try-Catch in Controllers ```typescript @Post('/charge') async processPayment(context: Context) { try { const { amount, token } = await context.getBody<{ amount: number; token: string }>(); const charge = await this.paymentService.charge(amount, token); return context.send({ success: true, charge }); } catch (error) { if (error instanceof PaymentError) { throw new HttpException(402, { error: 'Payment failed', reason: error.reason }); } throw error; // Let global handler deal with unexpected errors } } ``` ### Pattern 2: Early Returns ```typescript @Get('/:id') async getUser(context: Context) { const id = context.getParam('id'); if (!id) { throw new HttpException(400, 'User ID is required'); } if (!isValidUUID(id)) { throw new HttpException(400, 'Invalid user ID format'); } const user = await this.userService.getUser(id); if (!user) { throw new HttpException(404, 'User not found'); } return context.send(user); } ``` ### Pattern 3: Error Enrichment ```typescript public map(error: Error, context: Context): Response { // Enrich error with request context const enrichedError = { ...error, requestId: context.getValue('requestId'), userId: context.getValue('user')?.id, path: context.req.path, method: context.req.method }; // Send to monitoring service monitoringService.captureException(enrichedError); // Return safe response to client return context.send({ success: false, message: error.message, requestId: enrichedError.requestId }, 500); } ``` --- ## Related - [Middleware](/docs/concepts/middleware) - Error handling in middleware - [Validation](/docs/concepts/validation) - Handling validation errors - [Logger Package](/docs/packages/logger) - Structured logging with AsenaLogger - [Context API](/docs/concepts/context) - Understanding request context --- # Deployment Section: Guides Source: https://asena.sh/raw/guides/deployment.md # Deployment ::: info Coming Soon This guide is currently under development. Check back soon for comprehensive deployment documentation including: - Building for Production - Docker Deployment - Environment Configuration - Performance Optimization - Security Best Practices - CI/CD Pipelines - Monitoring and Logging ::: ## Quick Start For now, you can build your application with: ```bash asena build ``` Then run the production build: ```bash bun dist/index.asena.js ``` ## Related Documentation - [CLI Build Command](/docs/cli/commands#build) - [CLI Configuration](/docs/cli/configuration) - [Server Configuration](/docs/guides/configuration) --- **Stay Updated:** Follow us on [GitHub](https://github.com/AsenaJs/Asena) for updates. --- # Testing Section: Testing Source: https://asena.sh/raw/testing/overview.md # Testing Asena provides built-in testing utilities that automatically mock dependencies for components using dependency injection. These utilities are designed exclusively for [Bun's test runner](https://bun.sh/docs/cli/test). ::: info Built for Bun Asena's testing utilities work exclusively with Bun's native test runner. They use Bun's `mock()` function from `bun:test` and are not compatible with other testing frameworks. ::: ## Bun Test Runner Asena's testing utilities integrate with [Bun's built-in test runner](https://bun.sh/docs/cli/test), which provides: - **Native Performance** - No transpilation, runs TypeScript directly - **Jest-Compatible API** - Familiar `describe`, `test`, `expect` syntax - **Built-in Mocking** - Native `mock()` function - **Watch Mode** - Automatic re-running on file changes - **Zero Configuration** - Works out of the box with TypeScript ```bash # Run all tests bun test # Watch mode bun test --watch # Specific test file bun test ./tests/auth.test.ts # With coverage bun test --coverage ``` ## Automatic Dependency Mocking The `mockComponent` function automatically discovers and mocks all injected dependencies: ```typescript import { describe, test, expect } from 'bun:test'; import { mockComponent } from '@asenajs/asena/test'; import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; @Service() class UserService { async createUser(name: string, email: string): Promise<{ id: string; name: string; email: string }> { // implementation } } @Service() class AuthService { @Inject(UserService) private userService!: UserService; async register(name: string, email: string, password: string) { const user = await this.userService.createUser(name, email); return { user, token: 'jwt-token' }; } } // Test describe('AuthService', () => { test('should register user', async () => { const { instance, mocks } = mockComponent(AuthService); mocks.userService.createUser.mockResolvedValue({ id: 'user-123', name: 'John Doe', email: 'john@example.com' }); const result = await instance.register('John', 'john@example.com', 'pass'); expect(result.user.id).toBe('user-123'); expect(mocks.userService.createUser).toHaveBeenCalledWith('John', 'john@example.com'); }); }); ``` ## Two Levels of Testing Asena ships utilities for both ends of the spectrum, all from `@asenajs/asena/test`: | | Use it when | Container | Adapter | |---|---|---|---| | **[`mockComponent`](/docs/testing/mock-component)** | Testing one class's logic in isolation | Bypassed | None | | **[`createWebTest`](/docs/testing/web-test)** | Testing a controller's routing, middlewares and validation | Real, non-web deps auto-mocked | Real | | **[`createTestApp`](/docs/testing/test-app)** | Testing the whole application end to end | Real | Real | `mockComponent` is the fastest and covers most service-level tests. Reach for the harness when the thing you want to assert on *is* the framework's behaviour — a route matching, a middleware short-circuiting, a validator rejecting a bad payload. ```typescript // Unit: no framework involved const { instance, mocks } = mockComponent(AuthService); // Slice: real routing and validation, mocked services const { app, mocks } = await createWebTest({ adapter, controllers: [UserController] }); await app.get('/api/users/1').expectStatus(200); // Full: everything real, swap what you need await using app = await createTestApp({ adapter, components: [UserController, UserService], overrides: { UserService: myDouble }, }); ``` ## Import Path ```typescript // Unit-level mocking import { mockComponent, mockComponentAsync, createDeepMock, createTestUlakStub } from '@asenajs/asena/test'; // Integration harness import { createTestApp, createWebTest, silentLogger } from '@asenajs/asena/test'; ``` ## What You Can Test ### Services with Dependency Injection ```typescript import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; @Service() class StripeClient { async charge(amount: number) { // implementation } } @Service() class LoggerService { log(message: string) { // implementation } } @Service() class PaymentService { @Inject(StripeClient) private stripe!: StripeClient; @Inject(LoggerService) private logger!: LoggerService; async processPayment(amount: number) { this.logger.log(`Processing payment: ${amount}`); return await this.stripe.charge(amount); } } const { instance, mocks } = mockComponent(PaymentService); // Both stripe and logger are automatically mocked ``` ### Controllers ```typescript import { Controller } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { Get } from '@asenajs/asena/decorators/http'; import type { Context } from '@asenajs/ergenecore'; @Service() class UserService { async getUser(id: string) { // implementation } } @Controller('/users') class UserController { @Inject(UserService) private userService!: UserService; @Get('/:id') async getUser(context: Context) { const id = context.getParam('id'); return this.userService.getUser(id); } } const { instance, mocks } = mockComponent(UserController); ``` ### WebSocket Services ```typescript import { WebSocket } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { AsenaWebSocketService, type Socket } from '@asenajs/asena/web-socket'; @Service() class MessageService { async saveMessage(message: string) { // implementation } } @WebSocket('/chat') class ChatSocket extends AsenaWebSocketService { @Inject(MessageService) private messageService!: MessageService; async onMessage(ws: Socket, message: string) { await this.messageService.saveMessage(message); } } const { instance, mocks } = mockComponent(ChatSocket); ``` ## Main Testing Utilities ### mockComponent Synchronous component instantiation with automatic dependency mocking. ```typescript const { instance, mocks } = mockComponent(AuthService); ``` Returns an object with: - `instance` - The component instance with all dependencies mocked - `mocks` - Object containing all mock dependencies See the [MockComponent API](/docs/testing/mock-component) documentation for detailed usage. ### mockComponentAsync Asynchronous version for components with `postConstruct` hooks. ```typescript @Service() class DatabaseService { @Inject(ConnectionPool) private pool!: ConnectionPool; async initialize() { // async initialization } } const { instance, mocks } = await mockComponentAsync(DatabaseService, { postConstruct: async (inst) => { await inst.initialize(); } }); ``` ## Advanced Features ### Selective Mocking Mock only specific dependencies: ```typescript const { instance, mocks } = mockComponent(PaymentService, { injections: ['stripe'] // Only mock stripe, logger remains undefined }); expect(mocks.stripe).toBeDefined(); expect(mocks.logger).toBeUndefined(); ``` ### Custom Overrides Provide custom mock implementations: ```typescript import { mock } from 'bun:test'; const customUserService = { createUser: mock(async (name: string, email: string) => ({ id: 'custom-id', name, email })) }; const { instance, mocks } = mockComponent(AuthService, { overrides: { userService: customUserService } }); ``` ### Expression Transformations Supports `@Inject` expression transformations: ```typescript import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; @Service() class UserService { async createUser(name: string, email: string): Promise<{ id: string; name: string; email: string }> { // implementation } } @Service() class AuthService { @Inject(UserService, (service) => service.createUser) private createUserFn!: (name: string, email: string) => Promise; } const { instance, mocks } = mockComponent(AuthService); mocks.createUserFn.mockResolvedValue({ id: 'user-123' }); ``` ## How It Works 1. **Metadata Discovery** - Reads the same metadata that Asena's IoC Container uses 2. **Mock Generation** - Uses Bun's native `mock()` function to create mocks 3. **Injection** - Injects mocks into the component instance 4. **Expression Support** - Applies expression transformations if defined ## Next Steps - **[MockComponent API](/docs/testing/mock-component)** - Complete API reference and advanced usage - **[Examples](/docs/testing/examples)** - Real-world testing patterns for controllers, services, and WebSockets - **[Bun Test Documentation](https://bun.sh/docs/cli/test)** - Learn more about Bun's test runner --- # MockComponent API Section: Testing Source: https://asena.sh/raw/testing/mock-component.md # MockComponent API Asena's built-in testing utilities provide automated dependency mocking for components using dependency injection. The `mockComponent` and `mockComponentAsync` functions automatically discover and mock all injected dependencies. ## Quick Start ```typescript import { mockComponent } from '@asenajs/asena/test'; import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { mock } from 'bun:test'; @Service() class UserService { async createUser(name: string, email: string): Promise<{ id: string; name: string; email: string }> { // implementation } } @Service() class AuthService { @Inject(UserService) private userService!: UserService; async register(name: string, email: string, password: string) { const user = await this.userService.createUser(name, email); return { user, token: 'jwt-token' }; } } // Test describe('AuthService', () => { test('should register user', async () => { const { instance, mocks } = mockComponent(AuthService); // Configure mock behavior mocks.userService.createUser.mockResolvedValue({ id: 'user-123', name: 'John Doe', email: 'john@example.com' }); // Test const result = await instance.register('John', 'john@example.com', 'pass'); expect(result.user.id).toBe('user-123'); expect(mocks.userService.createUser).toHaveBeenCalledWith('John', 'john@example.com'); }); }); ``` ## API Reference ### mockComponent Creates a component instance with all dependencies automatically mocked. ```typescript function mockComponent( ComponentClass: new (...args: any[]) => T, options?: MockComponentOptions ): MockedComponent ``` **Parameters:** - `ComponentClass` - The component class to instantiate - `options` - Optional configuration (see [MockComponentOptions](#mockcomponentoptions)) **Returns:** [MockedComponent<T>](#mockedcomponentt) **Example:** ```typescript const { instance, mocks } = mockComponent(PaymentService); ``` ### mockComponentAsync Asynchronous version of `mockComponent` for components with async `postConstruct` hooks. ```typescript async function mockComponentAsync( ComponentClass: new (...args: any[]) => T, options?: MockComponentOptions ): Promise> ``` **Parameters:** Same as `mockComponent` **Returns:** Promise<[MockedComponent<T>](#mockedcomponentt)> **Example:** ```typescript import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; @Service() class ConnectionPool { async connect() { // implementation } } @Service() class DatabaseService { @Inject(ConnectionPool) private pool!: ConnectionPool; async initialize() { await this.pool.connect(); } } const { instance, mocks } = await mockComponentAsync(DatabaseService, { postConstruct: async (inst) => { await inst.initialize(); } }); ``` ### MockComponentOptions Configuration options for component mocking. ```typescript interface MockComponentOptions { // Only mock specific fields (optional) injections?: string[]; // Provide custom mocks instead of auto-generated ones (optional) overrides?: Record; // Lifecycle hook called after injection (optional, can be async) postConstruct?: (instance: any) => void | Promise; } ``` **Properties:** #### `injections` Array of field names to mock. Other fields will not be mocked. ```typescript const { instance, mocks } = mockComponent(PaymentService, { injections: ['stripe'] // Only mock stripe }); expect(mocks.stripe).toBeDefined(); expect(mocks.logger).toBeUndefined(); ``` #### `overrides` Custom mock objects to use instead of auto-generated mocks. ```typescript import { mock } from 'bun:test'; const customMock = { createUser: mock(async () => ({ id: 'custom-id' })) }; const { instance, mocks } = mockComponent(AuthService, { overrides: { userService: customMock } }); // mocks.userService is now your custom mock expect(mocks.userService).toBe(customMock); ``` An override is the **final** value injected into the field: - For expression-based injections (e.g. `@Inject(ulak('/chat'))` or `@Inject(UserService, (s) => s.createUser)`), the expression is **skipped entirely** — your override is used as-is. - Presence is checked with `Object.hasOwn`, so falsy values (`0`, `''`, `null`, `undefined`) are injected as-is rather than ignored. #### `postConstruct` Lifecycle hook executed after dependencies are injected. ```typescript const { instance, mocks } = mockComponent(AuthService, { postConstruct: (instance) => { console.log('Component ready for testing'); } }); ``` ::: tip Async Support The `postConstruct` hook can be async. Use `mockComponentAsync` when you need to await the hook. ::: ### MockedComponent<T> Return type of `mockComponent` and `mockComponentAsync`. ```typescript interface MockedComponent { instance: T; // Component instance with injected mocks mocks: Record; // Object containing all mock dependencies } ``` **Properties:** - **`instance`** - The component instance with all dependencies injected - **`mocks`** - Object where keys are field names and values are mock objects ## Advanced Usage Patterns ### Selective Mocking Mock only specific dependencies while leaving others undefined. ```typescript import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; @Service() class StripeClient { async charge(amount: number) { // implementation } } @Service() class LoggerService { log(message: string) { // implementation } } @Service() class PaymentService { @Inject(StripeClient) private stripe!: StripeClient; @Inject(LoggerService) private logger!: LoggerService; } const { instance, mocks } = mockComponent(PaymentService, { injections: ['stripe'] // Only mock stripe, logger remains undefined }); expect(mocks.stripe).toBeDefined(); expect(mocks.logger).toBeUndefined(); ``` ### Custom Overrides Provide your own mock implementations for specific dependencies. ```typescript import { mock } from 'bun:test'; const customUserService = { createUser: mock(async (name: string, email: string) => ({ id: 'custom-id', name, email })), deleteUser: mock(async (id: string) => true) }; const { instance, mocks } = mockComponent(AuthService, { overrides: { userService: customUserService } }); // mocks.userService is now your custom mock expect(mocks.userService).toBe(customUserService); ``` ### Combining Options You can combine `injections`, `overrides`, and `postConstruct` together. ```typescript const { instance, mocks } = mockComponent(AuthService, { injections: ['userService', 'emailService'], overrides: { userService: customUserService }, postConstruct: (inst) => { inst.setTestMode(true); } }); ``` ### Expression Transformations `mockComponent` supports `@Inject` expression transformations automatically. When an expression field is not overridden, the expression is evaluated against a **deep mock**: every property access yields a Bun mock function and every call returns another chainable deep mock, so any expression works without a running application. ```typescript import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; @Service() class UserService { async createUser(name: string, email: string): Promise<{ id: string; name: string; email: string }> { // implementation } } @Service() class AuthService { @Inject(UserService, (service) => service.createUser) private createUserFn!: (name: string, email: string) => Promise; } const { instance, mocks } = mockComponent(AuthService); // The expression is applied automatically - createUserFn is a real Bun mock mocks.createUserFn.mockResolvedValue({ id: 'user-123' }); expect(mocks.createUserFn).toHaveBeenCalledWith('John', 'john@example.com'); ``` If the field is provided via `overrides`, the expression is skipped and your override is injected as-is. ### Testing Services with Ulak Injections Services that inject scoped namespaces with the [`ulak()` helper](/docs/concepts/ulak) are testable without a running WebSocket broker. ```typescript import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { ulak, type Ulak } from '@asenajs/asena/messaging'; @Service('UserService') export class UserService { @Inject(ulak('/ws/public/stats')) private statsChannel: Ulak.NameSpace<'/ws/public/stats'>; async createAnonUser(name: string) { // ...create the user... await this.statsChannel.broadcast({ action: 'update', data: { newUser: 1 } }); } } ``` **Variant A — automatic deep mock.** No configuration needed; the namespace methods are assertable Bun mocks: ```typescript import { describe, expect, test } from 'bun:test'; import { mockComponent } from '@asenajs/asena/test'; test('broadcasts stats on user creation', async () => { const { instance, mocks } = mockComponent(UserService); await instance.createAnonUser('John'); expect(mocks.statsChannel.broadcast).toHaveBeenCalledWith({ action: 'update', data: { newUser: 1 } }); }); ``` **Variant B — typed stub via `createTestUlakStub`.** Use an explicit override when you want a fully typed `Ulak.NameSpace` mock: ```typescript import { createTestUlakStub, mockComponent } from '@asenajs/asena/test'; test('broadcasts stats on user creation', async () => { const statsChannel = createTestUlakStub('/ws/public/stats'); const { instance } = mockComponent(UserService, { overrides: { statsChannel } }); await instance.createAnonUser('John'); expect(statsChannel.broadcast).toHaveBeenCalledWith({ action: 'update', data: { newUser: 1 } }); }); ``` ::: tip `createTestUlakStub` implements the full `Ulak.NameSpace` interface (`broadcast`, `to`, `toSocket`, `toMany`, `getSocketCount`) with Bun mocks, so it stays in sync with the framework at compile time. ::: ### Testing Components with Inheritance `mockComponent` properly handles prototype chains. ```typescript import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; @Service() class LoggerService { log(message: string) { // implementation } } @Service() class DatabaseService { query(sql: string) { // implementation } } @Service() class BaseService { @Inject(LoggerService) protected logger!: LoggerService; } @Service() class UserService extends BaseService { @Inject(DatabaseService) private database!: DatabaseService; } const { instance, mocks } = mockComponent(UserService); // Both inherited and own dependencies are mocked expect(mocks.logger).toBeDefined(); expect(mocks.database).toBeDefined(); ``` ## Technical Details ### How It Works 1. **Metadata Discovery** - Reads the same metadata that Asena's IoC Container uses (`ComponentConstants.DependencyKey`, `ComponentConstants.DependencyClassKey` and `ComponentConstants.ExpressionKey`) 2. **Mock Generation** - Uses Bun's native `mock()` function to create mocks - Automatically detects async methods and creates `mock(async () => null)` - Sync methods get `mock(() => undefined)` 3. **Injection** - Injects mocks into the component instance 4. **Expression Support** - Evaluates expression transformations against a deep mock (unless the field is overridden, in which case the override is injected as-is) ### What each field receives The mock a field gets depends on how it was injected: | Injection | Mock | |---|---| | `@Inject(UserService)` | An object shaped like the class — every method is a `bun:test` mock | | `@Inject(ulak('/chat'))` and other expression injections | The expression evaluated against a deep mock, so any call chain works and stays assertable | | `@Inject('UserService')` | A plain `{}` — a string carries no class reference, so no method shape can be derived | ::: warning String injections need overrides Only class-based injections can be auto-shaped. For `@Inject('UserService')` pass the double yourself: ```typescript mockComponent(LegacyService, { overrides: { userService: { findById: mock(async () => ({ id: '1' })) } }, }); ``` ::: ### Zero Dependencies This feature follows Asena's zero-dependency philosophy: - Uses only Bun's native `mock()` function from `bun:test` - No external testing libraries required - Fully compatible with Bun's test runner ### Import Path ```typescript import { mockComponent, mockComponentAsync, createMockFromClass, createDeepMock, createTestUlakStub } from '@asenajs/asena/test'; ``` Package export configuration: ```json { "exports": { "./test": { "import": "./dist/lib/test/index.js", "types": "./dist/lib/test/index.d.ts" } } } ``` ## Related - **[Testing Overview](/docs/testing/overview)** - Introduction to testing in Asena - **[createTestApp](/docs/testing/test-app)** - Full-application testing with real HTTP - **[createWebTest](/docs/testing/web-test)** - Controller-slice testing (note: `mocks` there is keyed by *service* name, not field name) - **[Examples](/docs/testing/examples)** - Real-world testing patterns - **[Dependency Injection](/docs/concepts/dependency-injection)** - Understanding DI in Asena - **[Bun Test Documentation](https://bun.sh/docs/cli/test)** - Learn more about Bun's test runner --- # createTestApp Section: Testing Source: https://asena.sh/raw/testing/test-app.md # createTestApp `createTestApp` boots a **complete** Asena application inside a test: the IoC container, every bootstrap phase, the adapter and its real routing pipeline. It is the equivalent of Spring Boot's `@SpringBootTest`. Where [`mockComponent`](/docs/testing/mock-component) tests a single class in isolation, `createTestApp` tests how your components behave once the framework has wired them together and a real request comes in. ```typescript import { createTestApp, silentLogger } from '@asenajs/asena/test'; import { createHonoAdapter } from '@asenajs/hono-adapter'; import { describe, test, expect } from 'bun:test'; describe('UserController', () => { test('lists users', async () => { const [adapter] = createHonoAdapter({ logger: silentLogger }); await using app = await createTestApp({ adapter, components: [UserController, UserService], }); await app.get('/api/users').expectStatus(200).expectJsonContains({ total: 3 }); }); }); ``` ## Options ```typescript interface TestAppOptions { adapter: AsenaAdapter; // required components: Class[]; // required - skips filesystem scanning entirely overrides?: Record; // service name -> replacement instance logger?: ServerLogger; // default: silentLogger port?: number; // default: 0 (Bun picks a free port) dispatch?: 'server' | 'socket'; // default: 'server' } ``` ### `components` Passing an explicit component list skips config-based filesystem scanning, so a test boots only what it names. Every class the app needs at start-up must be present — controllers, services, middlewares, validators, configs. ### `port` Defaults to `0`, which asks Bun for a free ephemeral port. Read the bound port back from `app.port`: ```typescript const app = await createTestApp({ adapter, components }); console.log(app.port); // e.g. 43117 console.log(app.baseUrl); // http://localhost:43117 ``` ::: tip No more random-port collisions Hand-rolled helpers usually pick `10000 + Math.random() * 50000` and occasionally collide when suites run in parallel. `port: 0` removes the race entirely — the kernel hands out a port that is guaranteed free. ::: ## Replacing components with mocks `overrides` maps a **registered service name** to a replacement instance — Spring's `@MockBean`: ```typescript const userService = { getAll: mock(async () => [{ id: '1', name: 'Ada' }]), }; await using app = await createTestApp({ adapter, components: [UserController, UserService], overrides: { UserService: userService }, }); await app.get('/api/users').expectStatus(200).expectJson([{ id: '1', name: 'Ada' }]); expect(userService.getAll).toHaveBeenCalledTimes(1); ``` Overrides are seeded **before** any user component is registered, so: - the real class is never constructed, and its `@PostConstruct` never runs - every dependent captures the double, because Asena builds injection closures eagerly at registration time ### What can and cannot be overridden | | | |---|---| | ✅ Services, repositories, components | The normal case | | ❌ Core services | `Container`, `ServerLogger`, `__Ulak__`, `EventEmitter`, … are wired during bootstrap phases 1–5 and have already captured their dependencies. Attempting it throws with a clear message. | | ❌ Controllers | A plain object carries no `@Controller` metadata, so an overridden controller's routes would never be registered. Override the services it depends on instead. | | ⚠️ `@Strategy` arrays | Overriding an interface name to displace one member of a strategy array is not supported. | If a component is registered under a custom name (`@Service('Mailer')`), override it by **that** name. ## Fluent HTTP assertions `app.get()` / `.post()` / … return a `TestHttpCall`. Nothing is sent until the call is awaited, assertions run in the order they were chained, and the send is memoized so awaiting twice does not issue a second request. ```typescript await app.post('/api/users', { body: JSON.stringify({ name: 'Ada' }) }) .expectStatus(201) .expectHeader('content-type', /json/) .expectJsonContains({ name: 'Ada' }); ``` | Method | Assertion | |---|---| | `expectStatus(code)` | Status code. On failure the message includes the method, URL and response body. | | `expectHeader(name, value)` | Header equals a string, or matches a `RegExp`. | | `expectJson(expected)` | Whole JSON body deep-equals `expected`. | | `expectJsonContains(partial)` | JSON body contains at least these properties. | | `expectBody(expected)` | Raw text body equals a string, or matches a `RegExp`. | | `expect(fn)` | Escape hatch — receives the buffered response. | Awaiting the call resolves to a `TestHttpResponse` whose body is already buffered, so it can be read as many times and as many ways as you like: ```typescript const response = await app.get('/api/users').expectStatus(200); expect(response.json()).toHaveLength(3); expect(response.text()).toContain('Ada'); expect(response.headers.get('x-total')).toBe('3'); expect(response.raw).toBeInstanceOf(Response); ``` ## Cleanup `app.stop()` is idempotent. The app also implements `Symbol.asyncDispose`, so `await using` cleans up automatically even when a test throws: ```typescript test('...', async () => { await using app = await createTestApp({ adapter, components }); // no afterEach needed }); ``` ## Dispatch modes ### `'server'` (default) Listens on a TCP port. Identical to production. ### `'socket'` Listens on a **unix domain socket** instead. It is still the adapter's real routing pipeline — Bun's own router, real cookies, real validators, real WebSocket upgrades — but no TCP port is used at all, so parallel suites can never collide. ```typescript await using app = await createTestApp({ adapter, components, dispatch: 'socket', }); expect(app.port).toBe(0); expect(app.socketPath).toMatch(/\.sock$/); await app.get('/api/users').expectStatus(200); ``` WebSocket URLs differ between modes, so build them with `app.wsUrl()` and the same test works in both: ```typescript const socket = new WebSocket(app.wsUrl('/ws/chat')); // 'server' -> ws://localhost:43117/ws/chat // 'socket' -> ws+unix:///tmp/asena-test-1234-1.sock:/ws/chat ``` ::: warning Adapter support Socket dispatch requires an adapter that honours `AsenaStartOptions.unix`. The official `@asenajs/hono-adapter` and `@asenajs/ergenecore` both do. ::: ## The container ```typescript const service = await app.resolve('UserService'); expect(app.container.has('UserRepository')).toBe(true); ``` ## Known behaviours - **Cron and schedules run for real.** `cronRunner.startAll()` executes as part of start-up, so a `@Schedule` component in your test set will fire. - **`lib/test` requires Bun.** The utilities import `bun:test` at module scope. ## Related - **[createWebTest](/docs/testing/web-test)** — controller-slice testing with automatic mocks - **[MockComponent API](/docs/testing/mock-component)** — unit-level dependency mocking - **[Testing Overview](/docs/testing/overview)** — introduction to testing in Asena --- # createWebTest Section: Testing Source: https://asena.sh/raw/testing/web-test.md # createWebTest `createWebTest` boots **only the web layer** of your application. Controllers, their middlewares and their validators run for real; every other dependency they inject is replaced by a generated mock shaped like the real class. It is the equivalent of Spring's `@WebMvcTest`. That means a controller test never drags in a database, a Redis connection or an HTTP client — but the routing, middleware chain and request validation you are actually testing are the production ones. ```typescript import { createWebTest, silentLogger } from '@asenajs/asena/test'; import { createHonoAdapter } from '@asenajs/hono-adapter'; import { describe, test, expect } from 'bun:test'; describe('UserController', () => { test('returns a user', async () => { const [adapter] = createHonoAdapter({ logger: silentLogger }); const { app, mocks } = await createWebTest({ adapter, controllers: [UserController] }); mocks.UserService.findById.mockResolvedValue({ id: '1', name: 'Ada' }); await app.get('/api/users/1').expectStatus(200).expectJson({ id: '1', name: 'Ada' }); expect(mocks.UserService.findById).toHaveBeenCalledWith('1'); await app.stop(); }); }); ``` ## Options ```typescript interface WebTestOptions { adapter: AsenaAdapter; // required controllers: Class | Class[]; // required - the controllers under test components?: Class[]; // extra REAL components overrides?: Record; // explicit doubles; win over auto-mocks logger?: ServerLogger; port?: number; dispatch?: 'server' | 'socket'; } ``` Returns `{ app, mocks }`. `app` is a full [`TestApp`](/docs/testing/test-app) — same fluent HTTP client, same `stop()`, same `await using` support. ## What runs for real | Registered for real | Why | |---|---| | The controllers you pass | They are what you are testing | | Controller-level `middlewares` | The framework resolves them **by name** from the container at start-up and throws if they are missing | | Route-level `middlewares`, `validator`, `staticServe` | Same reason | | Anything in `components` | You asked for it | | Core services (`ulak(...)`, the event emitter, the container, …) | They are wired during bootstrap, before user components exist | Everything else a real component injects gets an auto-generated mock. ::: tip Validators are real This is deliberate and often surprising the first time: if a route has a validator requiring a UUID, `app.get('/api/users/1')` returns **400**, not 200. That is the production behaviour, and testing it is the point of a slice test. ::: ## The `mocks` object `mocks` is keyed by **service name** — not by field name. ```typescript const { mocks } = await createWebTest({ adapter, controllers: [UserController] }); mocks.UserService // ✅ registered service name mocks.userService // ❌ undefined - that is the field name ``` ::: warning Different from mockComponent [`mockComponent`](/docs/testing/mock-component) keys `mocks` by **field name**, because it works on one class where field names are unambiguous. `createWebTest` keys by **service name**, matching container and `@MockBean` semantics — and giving one shared double per service. ::: Each service gets exactly **one** mock, shared across every component that injects it: ```typescript const { mocks } = await createWebTest({ adapter, controllers: [UserController, AdminController], // both inject UserService }); // Both controllers received the very same object mocks.UserService.findById.mockResolvedValue({ id: '1' }); ``` `mocks` also contains any explicit `overrides` you passed, so it is a single place to reach every double in play. ## Mock shape Auto-mocks are generated from the injected class, so every method exists as a `bun:test` mock: ```typescript @Service() class UserService { public async findById(id: string) { /* ... */ } public async deleteById(id: string) { /* ... */ } } // mocks.UserService === { findById: Mock, deleteById: Mock } ``` Async methods default to resolving `null`; sync methods default to returning `undefined`. ### String injections cannot be shaped `@Inject('UserService')` stores only a name, so there is no class to derive methods from. The mock falls back to `{}` and a warning is logged: ```typescript const { logger, entries } = createCapturingLogger(); const { mocks } = await createWebTest({ adapter, controllers: [LegacyController], logger }); mocks.UserService; // {} ``` Pass an explicit override for those fields: ```typescript await createWebTest({ adapter, controllers: [LegacyController], overrides: { UserService: { findById: mock(async () => ({ id: '1' })) } }, }); ``` ## Promoting a mock back to the real thing Anything listed in `components` is registered for real and disappears from `mocks`: ```typescript const { mocks } = await createWebTest({ adapter, controllers: [UserController], components: [UserService], // now real }); mocks.UserService; // undefined ``` ## Explicit overrides Explicit `overrides` always win over the generated mock: ```typescript const double = { findById: mock(async () => ({ id: '1', name: 'Explicit' })) }; const { mocks } = await createWebTest({ adapter, controllers: [UserController], overrides: { UserService: double }, }); expect(mocks.UserService).toBe(double); ``` ## Known behaviours - **`@PostConstruct` runs against mocks.** A real component whose dependency was auto-mocked will see async mock methods resolve `null` during its lifecycle hook. This matches `@WebMvcTest` semantics. - **Only `@Controller` classes are accepted** in `controllers`. Pass services and middlewares through `components`. ## Related - **[createTestApp](/docs/testing/test-app)** — full-application testing and the fluent HTTP client - **[MockComponent API](/docs/testing/mock-component)** — unit-level dependency mocking - **[Testing Overview](/docs/testing/overview)** — introduction to testing in Asena --- # Testing Examples Section: Testing Source: https://asena.sh/raw/testing/examples.md # Testing Examples This page provides practical testing examples for common Asena components. ## Testing Controllers ### Basic Controller Testing ```typescript import { describe, test, expect } from 'bun:test'; import { mockComponent } from '@asenajs/asena/test'; import { Controller } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { Get, Post } from '@asenajs/asena/decorators/http'; import type { Context } from '@asenajs/ergenecore'; @Service() class UserService { async getUser(id: string) { // implementation } async createUser(name: string, email: string) { // implementation } } @Controller('/users') class UserController { @Inject(UserService) private userService!: UserService; @Get('/:id') async getUser(context: Context) { const id = context.getParam('id'); const user = await this.userService.getUser(id); return context.send(user); } @Post('/') async createUser(context: Context) { const { name, email } = await context.getBody<{ name: string; email: string }>(); const user = await this.userService.createUser(name, email); return context.send(user, 201); } } describe('UserController', () => { test('GET /:id should return user', async () => { const { instance, mocks } = mockComponent(UserController); const mockUser = { id: 'user-123', name: 'John Doe' }; mocks.userService.getUser.mockResolvedValue(mockUser); // Mock context - stub the methods the handler actually calls const mockContext = { getParam: mock(() => 'user-123'), send: mock((data) => data) } as unknown as Context; const result = await instance.getUser(mockContext); expect(result).toEqual(mockUser); expect(mocks.userService.getUser).toHaveBeenCalledWith('user-123'); }); test('POST / should create user', async () => { const { instance, mocks } = mockComponent(UserController); const mockUser = { id: 'user-123', name: 'John', email: 'john@example.com' }; mocks.userService.createUser.mockResolvedValue(mockUser); const mockContext = { getBody: mock(async () => ({ name: 'John', email: 'john@example.com' })), send: mock((data, status) => ({ data, status })) } as unknown as Context; // send() is typed as returning a Response, so unwrap the stub's shape const result = (await instance.createUser(mockContext)) as unknown as { data: unknown; status: number; }; expect(result.data).toEqual(mockUser); expect(result.status).toBe(201); expect(mocks.userService.createUser).toHaveBeenCalledWith('John', 'john@example.com'); }); }); ``` ## Testing Services ### Service with Multiple Dependencies ```typescript import { describe, test, expect } from 'bun:test'; import { mockComponent } from '@asenajs/asena/test'; import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; @Service() class DatabaseService { async query(sql: string) { // implementation } } @Service() class CacheService { async get(key: string) { // implementation } async set(key: string, value: any) { // implementation } } @Service() class LoggerService { log(message: string) { // implementation } } @Service() class UserService { @Inject(DatabaseService) private database!: DatabaseService; @Inject(CacheService) private cache!: CacheService; @Inject(LoggerService) private logger!: LoggerService; async getUser(id: string) { this.logger.log(`Fetching user ${id}`); // Check cache first const cached = await this.cache.get(`user:${id}`); if (cached) { return cached; } // Query database const user = await this.database.query(`SELECT * FROM users WHERE id = ${id}`); // Cache result await this.cache.set(`user:${id}`, user); return user; } } describe('UserService', () => { test('should return cached user when available', async () => { const { instance, mocks } = mockComponent(UserService); const mockUser = { id: 'user-123', name: 'John' }; mocks.cache.get.mockResolvedValue(mockUser); const result = await instance.getUser('user-123'); expect(result).toEqual(mockUser); expect(mocks.cache.get).toHaveBeenCalledWith('user:user-123'); expect(mocks.database.query).not.toHaveBeenCalled(); expect(mocks.logger.log).toHaveBeenCalledWith('Fetching user user-123'); }); test('should query database when cache miss', async () => { const { instance, mocks } = mockComponent(UserService); const mockUser = { id: 'user-123', name: 'John' }; mocks.cache.get.mockResolvedValue(null); mocks.database.query.mockResolvedValue(mockUser); const result = await instance.getUser('user-123'); expect(result).toEqual(mockUser); expect(mocks.cache.get).toHaveBeenCalledWith('user:user-123'); expect(mocks.database.query).toHaveBeenCalled(); expect(mocks.cache.set).toHaveBeenCalledWith('user:user-123', mockUser); }); }); ``` ### Testing Error Handling ```typescript import { describe, test, expect } from 'bun:test'; import { mockComponent } from '@asenajs/asena/test'; describe('UserService - Error Handling', () => { test('should handle database errors', async () => { const { instance, mocks } = mockComponent(UserService); mocks.cache.get.mockResolvedValue(null); mocks.database.query.mockRejectedValue(new Error('Database connection failed')); await expect(instance.getUser('user-123')).rejects.toThrow('Database connection failed'); }); }); ``` ## Testing WebSockets ### WebSocket Service Testing ```typescript import { describe, test, expect, mock } from 'bun:test'; import { mockComponent } from '@asenajs/asena/test'; import { WebSocket } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { AsenaWebSocketService, type Socket } from '@asenajs/asena/web-socket'; @Service() class MessageService { async saveMessage(userId: string, message: string) { // implementation } } @WebSocket('/chat') class ChatSocket extends AsenaWebSocketService { @Inject(MessageService) private messageService!: MessageService; async onOpen(ws: Socket) { const userId = ws.data.values?.userId; ws.subscribe(`user:${userId}`); } async onMessage(ws: Socket, message: string) { const userId = ws.data.values?.userId; await this.messageService.saveMessage(userId, message); this.to(`user:${userId}`, { type: 'message', data: message }); } async onClose(ws: Socket) { const userId = ws.data.values?.userId; ws.unsubscribe(`user:${userId}`); } } describe('ChatSocket', () => { test('onOpen should subscribe to user room', async () => { const { instance, mocks } = mockComponent(ChatSocket); const mockSocket = { data: { values: { userId: 'user-123' } }, subscribe: mock(() => {}) } as unknown as Socket; await instance.onOpen(mockSocket); expect(mockSocket.subscribe).toHaveBeenCalledWith('user:user-123'); }); test('onMessage should save and broadcast message', async () => { const { instance, mocks } = mockComponent(ChatSocket); const mockSocket = { data: { values: { userId: 'user-123' } } } as unknown as Socket; // Mock the 'to' method instance.to = mock(() => {}); await instance.onMessage(mockSocket, 'Hello!'); expect(mocks.messageService.saveMessage).toHaveBeenCalledWith('user-123', 'Hello!'); expect(instance.to).toHaveBeenCalledWith('user:user-123', { type: 'message', data: 'Hello!' }); }); test('onClose should unsubscribe from room', async () => { const { instance, mocks } = mockComponent(ChatSocket); const mockSocket = { data: { values: { userId: 'user-123' } }, unsubscribe: mock(() => {}) } as unknown as Socket; await instance.onClose(mockSocket); expect(mockSocket.unsubscribe).toHaveBeenCalledWith('user:user-123'); }); }); ``` ### Testing Ulak Messaging Services that inject scoped namespaces via the [`ulak()` helper](/docs/concepts/ulak) work with `mockComponent` out of the box — no running WebSocket broker needed. The injected namespace becomes a deep mock whose methods are assertable Bun mocks. ```typescript import { describe, test, expect, mock } from 'bun:test'; import { createTestUlakStub, mockComponent } from '@asenajs/asena/test'; import { Service } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { ulak, type Ulak } from '@asenajs/asena/messaging'; @Service('ChatService') class ChatService { @Inject(ulak('/chat')) private chat: Ulak.NameSpace<'/chat'>; async sendMessage(roomId: string, message: string) { await this.chat.to(roomId, { message }); } async broadcastAnnouncement(text: string) { await this.chat.broadcast({ type: 'announcement', text }); } } describe('ChatService', () => { test('sendMessage should target the room', async () => { // Automatic deep mock - no setup required const { instance, mocks } = mockComponent(ChatService); await instance.sendMessage('room-1', 'Hello!'); expect(mocks.chat.to).toHaveBeenCalledWith('room-1', { message: 'Hello!' }); }); test('broadcastAnnouncement should reach everyone', async () => { // Typed stub override - full Ulak.NameSpace interface with Bun mocks const chat = createTestUlakStub('/chat'); const { instance } = mockComponent(ChatService, { overrides: { chat } }); await instance.broadcastAnnouncement('Server maintenance at 22:00'); expect(chat.broadcast).toHaveBeenCalledWith({ type: 'announcement', text: 'Server maintenance at 22:00' }); }); }); ``` ## Testing Middleware ### Custom Middleware Testing ```typescript import { describe, test, expect, mock } from 'bun:test'; import { mockComponent } from '@asenajs/asena/test'; import { Middleware } from '@asenajs/asena/decorators'; import { Inject } from '@asenajs/asena/decorators/ioc'; import { MiddlewareService, type Context } from '@asenajs/ergenecore'; @Service() class AuthService { async validateToken(token: string) { // implementation } } @Middleware() class AuthMiddleware extends MiddlewareService { @Inject(AuthService) private authService!: AuthService; async handle(context: Context, next: () => Promise) { const token = context.headers['authorization']; if (!token) { return context.send({ error: 'Unauthorized' }, 401); } const user = await this.authService.validateToken(token); if (!user) { return context.send({ error: 'Invalid token' }, 401); } context.setValue('user', user); return next(); } } describe('AuthMiddleware', () => { test('should reject request without token', async () => { const { instance, mocks } = mockComponent(AuthMiddleware); const mockContext = { headers: {}, send: mock((data, status) => ({ data, status })) } as unknown as Context; const mockNext = mock(async () => {}); await instance.handle(mockContext, mockNext); expect(mockContext.send).toHaveBeenCalledWith({ error: 'Unauthorized' }, 401); expect(mockNext).not.toHaveBeenCalled(); }); test('should reject request with invalid token', async () => { const { instance, mocks } = mockComponent(AuthMiddleware); mocks.authService.validateToken.mockResolvedValue(null); const mockContext = { headers: { authorization: 'Bearer invalid-token' }, send: mock((data, status) => ({ data, status })) } as unknown as Context; const mockNext = mock(async () => {}); await instance.handle(mockContext, mockNext); expect(mockContext.send).toHaveBeenCalledWith({ error: 'Invalid token' }, 401); expect(mockNext).not.toHaveBeenCalled(); }); test('should allow request with valid token', async () => { const { instance, mocks } = mockComponent(AuthMiddleware); const mockUser = { id: 'user-123', name: 'John' }; mocks.authService.validateToken.mockResolvedValue(mockUser); const mockContext = { headers: { authorization: 'Bearer valid-token' }, setValue: mock(() => {}) } as unknown as Context; const mockNext = mock(async () => {}); await instance.handle(mockContext, mockNext); expect(mockContext.setValue).toHaveBeenCalledWith('user', mockUser); expect(mockNext).toHaveBeenCalled(); }); }); ``` ## Integration Testing Patterns `mockComponent` stops at the class boundary. When you need the framework itself in the loop — routing, middlewares, validators, real HTTP — use the harness. ### Full application [`createTestApp`](/docs/testing/test-app) boots everything and gives you a fluent HTTP client: ```typescript import { createTestApp, silentLogger } from '@asenajs/asena/test'; import { createHonoAdapter } from '@asenajs/hono-adapter'; import { describe, test, expect, mock } from 'bun:test'; describe('User API', () => { test('creates a user', async () => { const [adapter] = createHonoAdapter({ logger: silentLogger }); await using app = await createTestApp({ adapter, components: [AppConfig, UserController, UserService, CreateUserValidator], }); await app .post('/api/users', { body: JSON.stringify({ name: 'Ada', email: 'ada@example.com' }) }) .expectStatus(201) .expectJsonContains({ name: 'Ada' }); }); }); ``` ### Replacing a dependency with a mock `overrides` swaps a registered service for a double, the way Spring's `@MockBean` does. The real class is never constructed: ```typescript test('surfaces repository failures as 500', async () => { const [adapter] = createHonoAdapter({ logger: silentLogger }); await using app = await createTestApp({ adapter, components: [AppConfig, UserController, UserService], overrides: { UserService: { getAll: mock(async () => { throw new Error('database is down'); }), }, }, }); await app.get('/api/users').expectStatus(500); }); ``` ### Controller slice [`createWebTest`](/docs/testing/web-test) keeps the controller, its middlewares and its validators real, and auto-mocks everything else: ```typescript test('returns 404 for an unknown user', async () => { const [adapter] = createHonoAdapter({ logger: silentLogger }); const { app, mocks } = await createWebTest({ adapter, controllers: [UserController] }); mocks.UserService.getById.mockResolvedValue(null); await app.get('/api/users/3f1a9c7e-9b5d-4c2a-8f6e-1d2b3c4d5e6f').expectStatus(404); await app.stop(); }); ``` Remember that validators are real here — an id that fails validation returns **400** before the controller runs, which is exactly the behaviour worth testing. ### Testing without occupying a port `dispatch: 'socket'` runs the same adapter pipeline over a unix domain socket, so parallel suites cannot collide on ports: ```typescript await using app = await createTestApp({ adapter, components: [AppConfig, UserController, UserService], dispatch: 'socket', }); await app.get('/api/users').expectStatus(200); // WebSocket URLs differ per mode - let the harness build them const socket = new WebSocket(app.wsUrl('/ws/chat')); ``` ## Related - **[Testing Overview](/docs/testing/overview)** - Introduction to testing in Asena - **[MockComponent API](/docs/testing/mock-component)** - Complete API reference - **[createTestApp](/docs/testing/test-app)** - Full-application testing - **[createWebTest](/docs/testing/web-test)** - Controller-slice testing - **[Dependency Injection](/docs/concepts/dependency-injection)** - Understanding DI in Asena - **[Controllers](/docs/concepts/controllers)** - Controller documentation - **[Services](/docs/concepts/services)** - Service documentation - **[WebSocket](/docs/concepts/websocket)** - WebSocket documentation - **[Middleware](/docs/concepts/middleware)** - Middleware documentation