Files
afilmory/be/AGENTS.md
2025-10-25 00:59:25 +08:00

46 KiB

Hono Framework Developer Guide for AI Agents

This document provides a comprehensive guide to the Hono-based enterprise framework for AI coding assistants. This framework is NestJS-inspired with Hono performance, featuring decorators, dependency injection, and a modular architecture.

📋 Table of Contents

Framework Overview

What is This Framework?

This is a custom web framework built on top of Hono that provides:

  • Decorator-based routing (similar to NestJS)
  • Dependency injection via tsyringe
  • Request-scoped context using AsyncLocalStorage
  • Extensible enhancers (Guards, Pipes, Interceptors, Filters)
  • Type-safe validation with Zod
  • Lifecycle hooks for startup/shutdown management

Key Framework Packages

Package Purpose
@afilmory/framework Core framework with decorators, DI, HTTP context, logger, etc.
@afilmory/db Drizzle ORM schema and migrations
@afilmory/env Runtime environment validation
@afilmory/redis Redis client factory with strong typing
@afilmory/task-queue Task queue implementation with in-memory and Redis drivers
@afilmory/websocket WebSocket gateway with Redis pub/sub

Core Concepts

1. Modules

Modules are the fundamental building blocks that organize your application into cohesive feature sets.

Module Structure:

import { Module } from '@afilmory/framework'

@Module({
  imports: [OtherModule], // Import other modules
  controllers: [UserController], // HTTP endpoints
  providers: [UserService], // Injectable services
})
export class UserModule {}

Key Points:

  • Modules are singletons - only instantiated once
  • imports - Include other modules to access their exported providers
  • controllers - Define HTTP route handlers
  • providers - Services, repositories, utilities available for DI
  • Use forwardRef(() => Module) for circular dependencies

2. Controllers

Controllers handle HTTP requests and define routes using decorators.

Basic Controller:

import { Controller, Get, Post, Body, Param, Query } from '@afilmory/framework'

@Controller('users') // Base path: /users
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Get('/')
  async findAll(@Query('limit') limit?: string) {
    return this.userService.findAll(Number(limit) || 10)
  }

  @Get('/:id')
  async findOne(@Param('id') id: string) {
    return this.userService.findById(id)
  }

  @Post('/')
  async create(@Body() createUserDto: CreateUserDto) {
    return this.userService.create(createUserDto)
  }
}

Key Points:

  • Controllers must have @Controller(prefix) decorator
  • The prefix is the base path for all routes in the controller
  • Route methods use HTTP decorators: @Get(), @Post(), @Put(), @Patch(), @Delete()
  • Constructor injection automatically resolves dependencies

3. Providers (Services)

Providers are injectable classes that contain business logic.

import { injectable } from 'tsyringe'

@injectable()
export class UserService {
  constructor(
    private readonly dbAccessor: DbAccessor,
    private readonly redis: RedisAccessor,
  ) {}

  async findById(id: string) {
    const db = this.dbAccessor.get()
    return db.query.users.findFirst({
      where: eq(schema.users.id, id),
    })
  }

  async create(data: CreateUserInput) {
    const db = this.dbAccessor.get()
    const [user] = await db.insert(schema.users).values(data).returning()
    return user
  }
}

Key Points:

  • Providers must have @injectable() decorator from tsyringe
  • Registered in module's providers array
  • Use constructor injection for dependencies
  • Should contain reusable business logic

4. Request Context (HttpContext)

The framework provides a request-scoped context using Node's AsyncLocalStorage.

Accessing Context:

import { HttpContext } from '@afilmory/framework'

// In any service, guard, interceptor, or pipe
@injectable()
export class AuditService {
  logRequest() {
    const honoContext = HttpContext.getValue('hono')
    const path = honoContext.req.path
    const method = honoContext.req.method
    console.log(`Request: ${method} ${path}`)
  }
}

// Or get the entire context
const context = HttpContext.get()
const honoContext = context.hono

Setting Custom Values:

// Extend the context type
declare module '@afilmory/framework' {
  interface HttpContextValues {
    userId?: string
    requestId?: string
  }
}

// In a guard or interceptor
HttpContext.setValue('userId', '123')
HttpContext.assign({ userId: '123', requestId: 'abc' })

Key Points:

  • Context is automatically managed per request
  • Available in guards, pipes, interceptors, filters, and services
  • Use HttpContext.getValue('hono') to access Hono's Context
  • Can be extended with custom properties via module augmentation

Architecture Patterns

Application Bootstrap

Standard Bootstrap Pattern:

import 'reflect-metadata'
import { serve } from '@hono/node-server'
import { createApplication } from '@afilmory/framework'

async function bootstrap() {
  // Create the application
  const app = await createApplication(AppModule, {
    globalPrefix: '/api', // Optional: all routes prefixed with /api
  })

  // Register global enhancers
  app.useGlobalPipes(ValidationPipe)
  app.useGlobalGuards(AuthGuard)
  app.useGlobalInterceptors(LoggingInterceptor)
  app.useGlobalFilters(AllExceptionsFilter)

  // Get the underlying Hono instance
  const hono = app.getInstance()

  // Start the server
  serve({
    fetch: hono.fetch,
    port: 3000,
    hostname: '0.0.0.0',
  })
}

bootstrap()

Key Points:

  • reflect-metadata must be imported at the top
  • createApplication is async and returns HonoHttpApplication
  • Global enhancers apply to all routes
  • Access Hono instance via app.getInstance() for middleware

Module Organization

Root Module Pattern:

import { Module } from '@afilmory/framework'
import { DatabaseModule } from './database/database.module'
import { RedisModule } from './redis/redis.module'
import { UserModule } from './modules/user/user.module'
import { AuthModule } from './modules/auth/auth.module'

@Module({
  imports: [
    DatabaseModule, // Infrastructure modules first
    RedisModule,
    UserModule, // Feature modules
    AuthModule,
  ],
})
export class AppModule {}

Key Points:

  • Root module typically has no controllers/providers
  • Import infrastructure modules (DB, Redis) first
  • Feature modules come after infrastructure
  • Each feature should be self-contained

Infrastructure Modules (Database & Redis)

Database Module Pattern:

import { Module } from '@afilmory/framework'
import { DbAccessor } from './database.provider'

@Module({
  providers: [DbAccessor],
})
export class DatabaseModule {}

// Provider
@injectable()
export class DbAccessor {
  private db: ReturnType<typeof drizzle> | null = null

  constructor() {
    // Initialize connection pool
    const pool = new Pool({ connectionString: env.DATABASE_URL })
    this.db = drizzle(pool, { schema })
  }

  get() {
    if (!this.db) {
      throw new Error('Database not initialized')
    }
    return this.db
  }
}

Redis Module Pattern:

import { Module } from '@afilmory/framework'
import { RedisAccessor } from './redis.provider'

@Module({
  providers: [RedisAccessor],
})
export class RedisModule {}

// Provider
@injectable()
export class RedisAccessor {
  private client: Redis

  constructor() {
    this.client = new Redis(env.REDIS_URL)
  }

  get(): Redis {
    return this.client
  }
}

Key Points:

  • Infrastructure providers use accessor pattern with .get() method
  • There aren't exports in module, this is different from NestJS
  • Initialize connections in constructor
  • Implement lifecycle hooks (OnModuleDestroy) for cleanup

Decorators Reference

Module Decorators

// Define a module
@Module({
  imports: [FeatureModule], // Other modules to import
  controllers: [MyController], // HTTP endpoints
  providers: [MyService], // Injectable services
})
export class MyModule {}

// Forward reference for circular dependencies
@Module({
  imports: [forwardRef(() => CircularModule)],
})
export class MyModule {}

Controller & Route Decorators

// Controller base path
@Controller('api/v1/users')
export class UserController {}

// HTTP method decorators
@Get('/path')      // GET request
@Post('/path')     // POST request
@Put('/path')      // PUT request
@Patch('/path')    // PATCH request
@Delete('/path')   // DELETE request
@Options('/path')  // OPTIONS request
@Head('/path')     // HEAD request

Parameter Decorators

class MyController {
  @Get('/:id')
  async handler(
    @Param('id') id: string, // Route parameter
    @Query('search') search?: string, // Query string parameter
    @Body() body: CreateDto, // Request body (auto-parsed JSON)
    @Headers('authorization') auth?: string, // Specific header
    @Headers() allHeaders: Headers, // All headers
    @Req() request: HonoRequest, // Hono request object
    @ContextParam() context: Context, // Hono context
    context: Context, // Inferred context (if no decorator)
  ) {
    // Handler logic
  }
}

Parameter with Pipes:

// Apply pipe to specific parameter
@Get('/:id')
async findOne(@Param('id', ParseIntPipe) id: number) {
  // id is now a number (transformed by pipe)
}

// Multiple pipes
@Post('/')
async create(@Body(ValidationPipe, TransformPipe) data: CreateDto) {
  // data is validated then transformed
}

Enhancer Decorators

// Guards - Authorization/Authentication
@UseGuards(AuthGuard, RolesGuard)
@Get('/protected')
async protectedRoute() {}

// Pipes - Validation/Transformation
@UsePipes(ValidationPipe, TransformPipe)
@Post('/data')
async create() {}

// Interceptors - Modify request/response
@UseInterceptors(LoggingInterceptor, CacheInterceptor)
@Get('/data')
async getData() {}

// Exception Filters - Error handling
@UseFilters(HttpExceptionFilter, ValidationExceptionFilter)
@Post('/risky')
async riskyOperation() {}

Scope:

  • Method level: Apply to specific route handler
  • Controller level: Apply to all routes in controller
  • Global level: Apply to all routes in application (via app.useGlobal*())

Validation Decorators (Zod)

import { z } from 'zod'
import { createZodSchemaDto } from '@afilmory/framework'

// Define schema
const CreateUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1),
  age: z.number().int().positive().optional(),
})

// Create DTO class
class CreateUserDto extends createZodSchemaDto(CreateUserSchema) {}

// Use in controller
@Controller('users')
export class UserController {
  @Post('/')
  async create(@Body() data: CreateUserDto) {
    // data is validated and typed
  }
}

Request Pipeline

Execution Order

When a request hits an endpoint, the framework processes it through these phases:

Request
  ↓
1. HttpContext.run() - Establish request scope
  ↓
2. Guards - Check permissions (global → controller → method)
  ↓
3. Interceptors (before) - Pre-processing (global → controller → method)
  ↓
4. Pipes - Parameter validation/transformation
  ↓
5. Controller Handler - Your business logic
  ↓
6. Interceptors (after) - Post-processing (reverse order)
  ↓
7. Exception Filters - Error handling (if error thrown)
  ↓
Response

1. Guards

Guards determine whether a request should be handled by the route.

Guard Implementation:

import { injectable } from 'tsyringe'
import { CanActivate, ExecutionContext, UnauthorizedException, HttpContext } from '@afilmory/framework'

@injectable()
export class AuthGuard implements CanActivate {
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const httpContext = context.switchToHttp().getContext()
    const honoContext = httpContext.hono

    const token = honoContext.req.header('authorization')

    if (!token) {
      throw new UnauthorizedException('Missing authorization token')
    }

    // Validate token
    const user = await this.validateToken(token)

    if (!user) {
      return false // Returns 403 Forbidden
    }

    // Store user in context for later use
    HttpContext.assign({ user })

    return true
  }

  private async validateToken(token: string) {
    // Token validation logic
  }
}

Usage:

@Controller('admin')
@UseGuards(AuthGuard, AdminGuard) // All routes protected
export class AdminController {
  @Get('/dashboard')
  async getDashboard() {
    // Only reached if guards pass
  }

  @Get('/public')
  async getPublic() {
    // Still protected by controller-level guards
  }
}

Key Points:

  • Return false → 403 Forbidden (automatic)
  • Throw exception → Custom error response
  • Guards run in order: global → controller → method
  • Use for authentication, authorization, rate limiting

2. Pipes

Pipes transform and validate input data.

Pipe Implementation:

import { injectable } from 'tsyringe'
import { PipeTransform, ArgumentMetadata, BadRequestException } from '@afilmory/framework'

@injectable()
export class ParseIntPipe implements PipeTransform<string, number> {
  transform(value: string, metadata: ArgumentMetadata): number {
    const parsed = Number.parseInt(value, 10)

    if (Number.isNaN(parsed)) {
      throw new BadRequestException(`Validation failed: "${value}" is not an integer`)
    }

    return parsed
  }
}

Built-in Validation Pipe:

import { createZodValidationPipe } from '@afilmory/framework'

// Create configured validation pipe
const ValidationPipe = createZodValidationPipe({
  transform: true, // Transform to DTO class instances
  whitelist: true, // Strip unknown properties
  errorHttpStatusCode: 422, // Status code for validation errors
  forbidUnknownValues: true, // Reject non-objects for body
  stopAtFirstError: false, // Return all validation errors
})

// already registered globally
app.useGlobalPipes(ValidationPipe)

Key Points:

  • Pipes run after guards, before handler
  • Order: global → method → parameter
  • Use for validation, transformation, sanitization
  • Parameter pipes run last (most specific)

3. Interceptors

Interceptors wrap the request/response flow and can modify both.

Interceptor Implementation:

import { injectable } from 'tsyringe'
import { Interceptor, ExecutionContext, CallHandler, FrameworkResponse } from '@afilmory/framework'

@injectable()
export class LoggingInterceptor implements Interceptor {
  async intercept(context: ExecutionContext, next: CallHandler): Promise<FrameworkResponse> {
    const httpContext = context.switchToHttp().getContext()
    const { req } = httpContext.hono

    const start = Date.now()
    console.log(`→ ${req.method} ${req.path}`)

    // Call the handler and subsequent interceptors
    const response = await next.handle()

    const duration = Date.now() - start
    console.log(`← ${req.method} ${req.path} ${duration}ms`)

    return response
  }
}

Response Transform Interceptor:

@injectable()
export class TransformInterceptor implements Interceptor {
  async intercept(context: ExecutionContext, next: CallHandler): Promise<FrameworkResponse> {
    const response = await next.handle()

    // Transform response body
    const data = await response.clone().json()

    return new Response(
      JSON.stringify({
        success: true,
        data,
        timestamp: new Date().toISOString(),
      }),
      {
        status: response.status,
        headers: response.headers,
      },
    )
  }
}

Key Points:

  • Wrap handler execution with next.handle()
  • Can modify request before handler
  • Can modify response after handler
  • Run in order: global → controller → method (then reverse)
  • Use for logging, caching, response transformation

4. Exception Filters

Filters catch and handle exceptions thrown during request processing.

Filter Implementation:

import { injectable } from 'tsyringe'
import { ExceptionFilter, ArgumentsHost, HttpException } from '@afilmory/framework'

@injectable()
export class AllExceptionsFilter implements ExceptionFilter {
  async catch(exception: Error, host: ArgumentsHost) {
    const httpContext = host.switchToHttp().getContext()
    const { hono } = httpContext

    let status = 500
    let message = 'Internal server error'
    let details: any = {}

    if (exception instanceof HttpException) {
      status = exception.getStatus()
      const response = exception.getResponse()

      if (typeof response === 'object') {
        details = response
      } else {
        message = String(response)
      }
    } else {
      message = exception.message
      details.stack = exception.stack
    }

    return new Response(
      JSON.stringify({
        statusCode: status,
        message,
        ...details,
        path: hono.req.path,
        timestamp: new Date().toISOString(),
      }),
      {
        status,
        headers: { 'content-type': 'application/json' },
      },
    )
  }
}

Key Points:

  • Filters run when exception is thrown
  • Can return custom Response or undefined
  • If filter returns undefined, next filter runs
  • Use for error logging, error formatting, monitoring

Dependency Injection

Basic DI Usage

Service Registration:

// In module
@Module({
  providers: [
    UserService, // Singleton by default
    EmailService,
  ],
})
export class UserModule {}

Constructor Injection:

@injectable()
export class UserService {
  constructor(
    private readonly db: DbAccessor,
    private readonly cache: RedisAccessor,
    private readonly logger: Logger,
  ) {}
}

Accessing the Container

// In application bootstrap
const app = await createApplication(AppModule)
const container = app.getContainer()

// Manually resolve a provider
const userService = container.resolve(UserService)

Important DI Patterns

Wrong - Import Type:

// This will cause DI errors!
import type { UserService } from './user.service'

@injectable()
export class OrderService {
  constructor(private readonly userService: UserService) {}
  //                                       ^^^ Type-only import won't work
}

Correct - Import Value:

// Import the actual class
import { UserService } from './user.service'

@injectable()
export class OrderService {
  constructor(private readonly userService: UserService) {}
}

Common Implementation Patterns

1. CRUD Controller Pattern

import { z } from 'zod'
import { createZodSchemaDto } from '@afilmory/framework'

// DTOs for request validation
const PaginationQuerySchema = z.object({
  page: z.string().regex(/^\d+$/).transform(Number).default('1'),
  limit: z.string().regex(/^\d+$/).transform(Number).default('10'),
})

const UserIdParamSchema = z.object({
  id: z.string().uuid(),
})

const CreateUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(100),
  age: z.number().int().positive().optional(),
})

const UpdateUserSchema = z.object({
  email: z.string().email().optional(),
  name: z.string().min(1).max(100).optional(),
  age: z.number().int().positive().optional(),
})

class PaginationQueryDto extends createZodSchemaDto(PaginationQuerySchema) {}
class UserIdParamDto extends createZodSchemaDto(UserIdParamSchema) {}
class CreateUserDto extends createZodSchemaDto(CreateUserSchema) {}
class UpdateUserDto extends createZodSchemaDto(UpdateUserSchema) {}

@Controller('users')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Get('/')
  async findAll(@Query() query: PaginationQueryDto) {
    return this.userService.findAll({
      page: query.page,
      limit: query.limit,
    })
  }

  @Get('/:id')
  async findOne(@Param() params: UserIdParamDto) {
    const user = await this.userService.findById(params.id)
    if (!user) {
      throw new NotFoundException(`User ${params.id} not found`)
    }
    return user
  }

  @Post('/')
  async create(@Body() createUserDto: CreateUserDto) {
    return this.userService.create(createUserDto)
  }

  @Patch('/:id')
  async update(@Param() params: UserIdParamDto, @Body() updateUserDto: UpdateUserDto) {
    return this.userService.update(params.id, updateUserDto)
  }

  @Delete('/:id')
  async remove(@Param() params: UserIdParamDto) {
    await this.userService.remove(params.id)
    return { deleted: true }
  }
}

Key Points:

  • Query Parameters: Use @Query() without parameter name to get all query params, then validate with DTO
  • Route Parameters: Use @Param() without parameter name to get all params, then validate with DTO
  • Schema Transformation: Use .transform() to convert string query params to numbers
  • Default Values: Use .default() for optional query parameters
  • Validation: All parameters are validated through Zod schemas before reaching the handler

2. Service with Database Pattern

@injectable()
export class UserService {
  constructor(private readonly db: DbAccessor) {}

  async findAll(options: { page: number; limit: number }) {
    const db = this.db.get()
    const offset = (options.page - 1) * options.limit

    const users = await db.query.users.findMany({
      limit: options.limit,
      offset,
    })

    return {
      data: users,
      page: options.page,
      limit: options.limit,
    }
  }

  async findById(id: string) {
    const db = this.db.get()
    return db.query.users.findFirst({
      where: eq(schema.users.id, id),
    })
  }

  async create(data: CreateUserInput) {
    const db = this.db.get()
    const [user] = await db.insert(schema.users).values(data).returning()
    return user
  }

  async update(id: string, data: UpdateUserInput) {
    const db = this.db.get()
    const [updated] = await db.update(schema.users).set(data).where(eq(schema.users.id, id)).returning()
    return updated
  }

  async remove(id: string) {
    const db = this.db.get()
    await db.delete(schema.users).where(eq(schema.users.id, id))
  }
}

3. Lifecycle Hooks Pattern

@injectable()
export class DatabaseService implements OnModuleInit, OnModuleDestroy {
  private pool: Pool | null = null

  async onModuleInit() {
    console.log('Initializing database connection...')
    this.pool = new Pool({ connectionString: env.DATABASE_URL })
    await this.pool.query('SELECT 1') // Test connection
    console.log('Database connected')
  }

  async onModuleDestroy() {
    console.log('Closing database connection...')
    await this.pool?.end()
    console.log('Database disconnected')
  }

  getPool(): Pool {
    if (!this.pool) {
      throw new Error('Database not initialized')
    }
    return this.pool
  }
}

Available Lifecycle Hooks:

interface OnModuleInit {
  onModuleInit(): Promise<void> | void
  // Called after module and its imports are registered
}

interface OnApplicationBootstrap {
  onApplicationBootstrap(): Promise<void> | void
  // Called after all modules are initialized
}

interface BeforeApplicationShutdown {
  beforeApplicationShutdown(signal?: string): Promise<void> | void
  // Called before shutdown begins
}

interface OnModuleDestroy {
  onModuleDestroy(): Promise<void> | void
  // Called during teardown
}

interface OnApplicationShutdown {
  onApplicationShutdown(signal?: string): Promise<void> | void
  // Called as final shutdown step
}

Graceful Shutdown:

const app = await createApplication(AppModule)
const hono = app.getInstance()

const server = serve({ fetch: hono.fetch, port: 3000 })

// Handle shutdown signals
process.on('SIGTERM', async () => {
  console.log('SIGTERM received, shutting down...')
  await app.close('SIGTERM')
  server.close()
  process.exit(0)
})

process.on('SIGINT', async () => {
  console.log('SIGINT received, shutting down...')
  await app.close('SIGINT')
  server.close()
  process.exit(0)
})

4. Caching Pattern with Redis

@injectable()
export class CacheService {
  constructor(private readonly redis: RedisAccessor) {}

  async get<T>(key: string): Promise<T | null> {
    const value = await this.redis.get().get(key)
    return value ? JSON.parse(value) : null
  }

  async set(key: string, value: any, ttlSeconds: number): Promise<void> {
    await this.redis.get().set(key, JSON.stringify(value), 'EX', ttlSeconds)
  }

  async del(key: string): Promise<void> {
    await this.redis.get().del(key)
  }
}

5. Error Handling Pattern

// Business exception
export class BizException extends HttpException {
  constructor(
    public readonly code: number,
    message: string,
    public readonly data?: any,
  ) {
    super(
      { statusCode: 400, code, message, data },
      400,
      message,
    )
  }
}

// Specific business errors
export const ErrorCodes = {
  USER_NOT_FOUND: 1001,
  INVALID_CREDENTIALS: 1002,
  EMAIL_ALREADY_EXISTS: 1003,
} as const

// Usage in service
@injectable()
export class UserService {
  async findById(id: string) {
    const user = await this.db.query.users.findFirst(...)
    if (!user) {
      throw new BizException(
        ErrorCodes.USER_NOT_FOUND,
        `User ${id} not found`,
      )
    }
    return user
  }
}

// Exception filter
@injectable()
export class BizExceptionFilter implements ExceptionFilter<BizException> {
  async catch(exception: BizException, host: ArgumentsHost) {
    const httpContext = host.switchToHttp().getContext()

    return new Response(
      JSON.stringify({
        success: false,
        code: exception.code,
        message: exception.message,
        data: exception.data,
        timestamp: new Date().toISOString(),
      }),
      {
        status: exception.getStatus(),
        headers: { 'content-type': 'application/json' },
      }
    )
  }
}

6. WebSocket Pattern

The @afilmory/websocket package provides a Redis-backed WebSocket gateway with channel subscriptions, pub/sub fan-out, and automatic heartbeat management.

WebSocket Module Setup:

import { Module } from '@afilmory/framework'
import { RedisModule } from '../redis/redis.module'
import { WebSocketGatewayProvider } from './websocket.provider'
import { WebSocketService } from './websocket.service'

@Module({
  imports: [RedisModule],
  providers: [WebSocketGatewayProvider, WebSocketService],
})
export class WebSocketModule {}

WebSocket Gateway Provider:

import { injectable } from 'tsyringe'
import { OnModuleInit, OnModuleDestroy, createLogger } from '@afilmory/framework'
import { RedisPubSubBroker, RedisWebSocketGateway } from '@afilmory/websocket'
import { RedisAccessor } from '../redis/redis.provider'

@injectable()
export class WebSocketGatewayProvider implements OnModuleInit, OnModuleDestroy {
  private gateway?: RedisWebSocketGateway
  private broker?: RedisPubSubBroker
  private subscriber?: Redis

  constructor(private readonly redis: RedisAccessor) {}

  async onModuleInit(): Promise<void> {
    const publisher = this.redis.get()
    const subscriber = publisher.duplicate()

    this.subscriber = subscriber
    this.broker = new RedisPubSubBroker({ publisher, subscriber })
  }

  async attachToHttpServer(server: Server): Promise<void> {
    if (!this.broker) {
      throw new Error('Broker not initialized')
    }

    this.gateway = new RedisWebSocketGateway({
      broker: this.broker,
      server,
      path: '/ws',
      heartbeatIntervalMs: 30000,
      allowClientPublish: false, // Disable client-initiated publish
      handshakeValidator: async (request) => {
        // Validate auth token from query params or headers
        const token = new URL(request.url!, 'http://localhost').searchParams.get('token')
        if (!token) {
          throw new Error('Missing authentication token')
        }
        // Validate token here
      },
      identifyClient: async (request) => {
        // Return unique client identifier
        return extractUserIdFromRequest(request)
      },
    })

    await this.gateway.start()
  }

  async onModuleDestroy(): Promise<void> {
    await this.gateway?.stop()
    await this.subscriber?.quit()
  }

  getGateway(): RedisWebSocketGateway {
    if (!this.gateway) {
      throw new Error('Gateway not initialized')
    }
    return this.gateway
  }
}

WebSocket Service:

@injectable()
export class WebSocketService {
  constructor(private readonly gatewayProvider: WebSocketGatewayProvider) {}

  async publishToChannel<T>(channel: string, payload: T): Promise<void> {
    const gateway = this.gatewayProvider.getGateway()
    await gateway.publish({ channel, payload })
  }

  async notifyUser(userId: string, notification: Notification): Promise<void> {
    await this.publishToChannel(`user:${userId}`, {
      type: 'notification',
      data: notification,
    })
  }

  async broadcastToAll(message: string): Promise<void> {
    await this.publishToChannel('broadcast', {
      type: 'announcement',
      message,
    })
  }
}

Bootstrap with WebSocket:

import { serve } from '@hono/node-server'
import { createApplication } from '@afilmory/framework'

async function bootstrap() {
  const app = await createApplication(AppModule)
  const hono = app.getInstance()

  const server = serve({ fetch: hono.fetch, port: 3000 })

  // Attach WebSocket gateway to HTTP server
  const container = app.getContainer()
  const wsProvider = container.resolve(WebSocketGatewayProvider)
  await wsProvider.attachToHttpServer(server)
}

bootstrap()

Client-Side Usage:

// Connect to WebSocket
const ws = new WebSocket('ws://localhost:3000/ws?token=YOUR_TOKEN')

ws.onopen = () => {
  // Subscribe to channels
  ws.send(
    JSON.stringify({
      type: 'subscribe',
      channels: ['user:123', 'broadcast'],
    }),
  )
}

ws.onmessage = (event) => {
  const message = JSON.parse(event.data)

  switch (message.type) {
    case 'ack':
      console.log('Subscribed to:', message.channels)
      break
    case 'message':
      console.log('Received:', message.channel, message.payload)
      break
    case 'error':
      console.error('Error:', message.code, message.message)
      break
  }
}

// Unsubscribe from channels
ws.send(
  JSON.stringify({
    type: 'unsubscribe',
    channels: ['broadcast'],
  }),
)

// Ping-pong for keepalive
ws.send(JSON.stringify({ type: 'ping' }))

Key Points:

  • Redis Pub/Sub: Uses Redis for message distribution across multiple server instances
  • Channel Subscriptions: Clients subscribe to channels and receive real-time updates
  • Automatic Heartbeat: Built-in ping/pong mechanism for connection health
  • Handshake Validation: Validate authentication before accepting connections
  • Client Identification: Custom logic to identify connected clients
  • Server-Side Publish: Services can publish messages through the gateway

7. Task Queue Pattern

The @afilmory/task-queue package provides a robust task queue system with support for retries, priority, delayed execution, and middleware.

Task Queue Module Setup:

import { Module } from '@afilmory/framework'
import { TaskQueueModule } from '@afilmory/task-queue'
import { TaskQueueManager } from './task-queue.manager'
import { TaskQueueService } from './task-queue.service'
import { TaskQueueController } from './task-queue.controller'

@Module({
  imports: [TaskQueueModule],
  controllers: [TaskQueueController],
  providers: [TaskQueueManager, TaskQueueService],
})
export class QueueModule {}

Task Queue Worker with Decorators:

import { injectable } from 'tsyringe'
import { OnModuleDestroy, OnModuleInit, createLogger } from '@afilmory/framework'
import { RedisQueueDriver, TaskContext, TaskProcessor, TaskQueue, TaskQueueManager } from '@afilmory/task-queue'
import { RedisAccessor } from '../redis/redis.provider'

@injectable()
export class TaskQueueWorker implements OnModuleInit, OnModuleDestroy {
  private readonly logger = createLogger('Tasks:worker')
  public queue!: TaskQueue

  constructor(
    private readonly manager: TaskQueueManager,
    private readonly redis: RedisAccessor,
  ) {}

  async onModuleInit(): Promise<void> {
    const driver = new RedisQueueDriver({
      redis: this.redis.get(),
      queueName: 'core:jobs',
      visibilityTimeoutMs: 45_000,
    })

    this.queue = this.manager.createQueue('core-jobs', {
      driver,
      start: false,
      logger: this.logger,
      middlewares: [
        async (context, next) => {
          this.logger.debug('Task started', { taskId: context.taskId, name: context.name })
          const start = Date.now()
          try {
            await next()
          } finally {
            this.logger.debug('Task finished', {
              taskId: context.taskId,
              name: context.name,
              duration: Date.now() - start,
            })
          }
        },
      ],
    })

    await this.queue.start({ pollIntervalMs: 200 })
  }

  @TaskProcessor('send-email', {
    options: {
      maxAttempts: 3,
      backoffStrategy: (attempt) => Math.min(60_000, 2 ** attempt * 1_000),
      retryableFilter: (error) => error instanceof NetworkError,
    },
  })
  async sendEmail(payload: EmailPayload, context: TaskContext<EmailPayload>): Promise<void> {
    context.logger.info('Sending email', { to: payload.to })
    await sendEmail(payload)
  }

  @TaskProcessor('process-image', {
    options: (instance) => ({
      maxAttempts: 5,
      backoffStrategy: (attempt) => attempt * 5_000,
    }),
  })
  async processImage(payload: ImagePayload, context: TaskContext<ImagePayload>): Promise<ImageResult> {
    context.logger.info('Processing image', { imageId: payload.imageId })
    return await processImage(payload)
  }

  @TaskProcessor({ name: 'deliver-webhook', queueProperty: 'queue' })
  async deliverWebhook(payload: WebhookPayload, context: TaskContext<WebhookPayload>): Promise<void> {
    try {
      await fetch(payload.url, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify(payload.data),
      })
    } catch (error: any) {
      if (error?.status === 429) {
        context.setRetry({ retry: true, delayMs: 60_000 })
      }
      throw error
    }
  }

  async onModuleDestroy(): Promise<void> {
    await this.queue?.shutdown()
  }
}

Handlers are bound automatically after onModuleInit finishes, so as long as the queue property is set before the method resolves, the decorator wires everything up. Options may be an object or a factory that receives the instance (helpful for per-environment tuning).

Task Queue Service:

@injectable()
export class TaskQueueService {
  constructor(private readonly worker: TaskQueueWorker) {}

  async enqueueEmail(email: EmailPayload): Promise<string> {
    const task = await this.worker.queue.enqueue({
      name: 'send-email',
      payload: email,
      priority: 5,
    })

    return task.id
  }

  async enqueueDelayedTask(payload: unknown, delayMs: number): Promise<string> {
    const task = await this.worker.queue.enqueue({
      name: 'deliver-webhook',
      payload,
      runAt: Date.now() + delayMs,
      priority: 0,
    })

    return task.id
  }

  async enqueueBatch(emails: EmailPayload[]): Promise<string[]> {
    return await Promise.all(emails.map((email) => this.enqueueEmail(email)))
  }

  async getQueueStats() {
    return await this.worker.queue.getStats()
  }
}

Task Queue Controller:

@Controller('queue')
export class TaskQueueController {
  constructor(private readonly queueService: TaskQueueService) {}

  @Post('/tasks/email')
  async enqueueEmail(@Body() dto: EmailDto) {
    const taskId = await this.queueService.enqueueEmail({
      to: dto.to,
      subject: dto.subject,
      body: dto.body,
    })

    return {
      taskId,
      status: 'queued',
      message: 'Email task enqueued successfully',
    }
  }

  @Post('/tasks/delayed')
  async enqueueDelayed(@Body() dto: { payload: unknown; delaySeconds: number }) {
    const taskId = await this.queueService.enqueueDelayedTask(dto.payload, dto.delaySeconds * 1000)

    return {
      taskId,
      status: 'scheduled',
      scheduledFor: new Date(Date.now() + dto.delaySeconds * 1000),
    }
  }

  @Get('/stats')
  async getStats() {
    return await this.queueService.getQueueStats()
  }
}

Advanced Task Handler with Custom Retry:

@TaskProcessor('complex-task', {
  options: {
    maxAttempts: 5,
    backoffStrategy: (attempt) => {
      const base = 2 ** attempt * 1_000
      const jitter = Math.random() * 1_000
      return Math.min(300_000, base + jitter)
    },
  },
})
async complexTaskHandler(payload: ComplexPayload, context: TaskContext<ComplexPayload>): Promise<void> {
  try {
    await performComplexOperation(payload)
  } catch (error) {
    if (error instanceof TemporaryError) {
      context.setRetry({ retry: true, delayMs: 30_000 })
      throw error
    }

    if (error instanceof PermanentError) {
      throw new TaskDropError('Permanent failure, cannot retry')
    }

    throw error
  }
}

Task Middleware:

// Logging middleware
const loggingMiddleware: TaskMiddleware = async (context, next) => {
  console.log(`[${context.name}] Starting task ${context.taskId}`)
  const start = Date.now()

  try {
    await next()
    console.log(`[${context.name}] Completed in ${Date.now() - start}ms`)
  } catch (error) {
    console.error(`[${context.name}] Failed:`, error)
    throw error
  }
}

// Metrics middleware
const metricsMiddleware: TaskMiddleware = async (context, next) => {
  const labels = { taskName: context.name }
  tasksTotal.inc(labels)

  const timer = tasksLatency.startTimer(labels)
  try {
    await next()
    tasksSuccess.inc(labels)
  } catch (error) {
    tasksFailure.inc(labels)
    throw error
  } finally {
    timer()
  }
}

// Apply middlewares
const queue = new TaskQueue({
  name: 'main',
  middlewares: [loggingMiddleware, metricsMiddleware],
})

Key Points:

  • Task Processors: Annotate methods with @TaskProcessor(); registration runs automatically after onModuleInit
  • Priority Queue: Tasks with higher priority are processed first
  • Delayed Execution: Schedule tasks to run at a specific time
  • Retry Strategies: Exponential backoff, linear backoff, custom logic via handler options or context.setRetry
  • Middleware: Add cross-cutting concerns like logging, metrics, tracing
  • Driver Support: In-memory driver for development, Redis driver for production
  • Visibility Timeout: Prevents tasks from being processed by multiple workers
  • Graceful Shutdown: Stop processing and wait for in-flight tasks to complete

8. OpenAPI & Scalar Docs

Generate synchronized documentation from the existing decorator metadata and surface it through a hosted Scalar UI.

  • Two-dimensional tagging: Operations carry module-path and controller tags (e.g. Root / User, User) so clients can cluster endpoints by feature.
  • Schema reuse: DTO Zod definitions become reusable components referenced by parameters and request bodies.
  • Interactive docs: The Scalar embed mirrors their recommended CDN usage, so no bundler work is required.
  • Customization: Use @ApiTags('Admin') on controllers or handlers to add business-facing groupings, and @ApiDoc({ summary, deprecated, tags }) to tweak individual operations inline.

Testing Strategy

Framework Testing

The framework itself has 100% test coverage. When implementing features:

1. Unit Tests for Services:

import { describe, it, expect, beforeEach } from 'vitest'
import { container } from 'tsyringe'

describe('UserService', () => {
  let service: UserService
  let mockDb: DbAccessor

  beforeEach(() => {
    // Setup mocks
    mockDb = {
      get: () => mockDbInstance,
    } as any

    container.register(DbAccessor, { useValue: mockDb })
    service = container.resolve(UserService)
  })

  it('should find user by id', async () => {
    const user = await service.findById('123')
    expect(user).toBeDefined()
  })
})

2. Integration Tests for Controllers:

import { describe, it, expect } from 'vitest'
import { createApplication } from '@afilmory/framework'

describe('UserController', () => {
  let app: HonoHttpApplication

  beforeEach(async () => {
    app = await createApplication(UserModule)
  })

  it('should return user list', async () => {
    const hono = app.getInstance()
    const res = await hono.request('/users')

    expect(res.status).toBe(200)
    const data = await res.json()
    expect(Array.isArray(data)).toBe(true)
  })

  afterEach(async () => {
    await app.close()
  })
})

3. E2E Tests:

describe('Authentication Flow', () => {
  it('should login and access protected route', async () => {
    // Login
    const loginRes = await fetch('http://localhost:3000/api/auth/login', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ email: 'test@example.com', password: 'pass' }),
    })
    const { access_token } = await loginRes.json()

    // Access protected route
    const profileRes = await fetch('http://localhost:3000/api/auth/profile', {
      headers: { authorization: `Bearer ${access_token}` },
    })
    expect(profileRes.status).toBe(200)
  })
})

Best Practices for AI Agents

When Creating New Features

  1. Start with the Module:

    • Create module file with @Module() decorator
    • Define imports, controllers, providers
  2. Create DTOs with Zod:

    • Define schemas with z.object()
    • Create DTO classes with extend createZodSchemaDto()
  3. Implement Service:

    • Add @injectable() decorator
    • Use constructor injection for dependencies
    • Implement business logic methods
  4. Implement Controller:

    • Add @Controller(prefix) decorator
    • Use HTTP method decorators
    • Use parameter decorators for input
    • Inject service via constructor
  5. Add Enhancers if Needed:

    • Guards for authorization
    • Pipes for custom validation
    • Interceptors for cross-cutting concerns
    • Filters for error handling
  6. Register in Root Module:

    • Add to imports array in root module

Common Pitfalls to Avoid

Don't:

  • Import types instead of classes for DI
  • Forget @injectable() decorator on services
  • Forget @Controller() decorator on controllers
  • Use relative imports for cross-module dependencies
  • Mutate request/response objects directly

Do:

  • Import actual classes for DI
  • Use decorators consistently
  • Use HttpContext for request-scoped data
  • Follow module boundaries
  • Return plain objects (framework handles Response creation)
  • Use lifecycle hooks for initialization/cleanup

Code Organization

src/
├── modules/
│   ├── user/
│   │   ├── user.module.ts
│   │   ├── user.controller.ts
│   │   ├── user.service.ts
│   │   ├── dto/
│   │   │   ├── create-user.dto.ts
│   │   │   └── update-user.dto.ts
│   │   └── entities/
│   │       └── user.entity.ts
│   └── auth/
│       ├── auth.module.ts
│       ├── auth.controller.ts
│       ├── auth.service.ts
│       └── guards/
│           └── auth.guard.ts
├── guards/           # Shared guards
├── interceptors/     # Shared interceptors
├── pipes/            # Shared pipes
├── filters/          # Shared filters
├── database/         # Database module
│   ├── database.module.ts
│   └── database.provider.ts
├── redis/            # Redis module
│   ├── redis.module.ts
│   └── redis.provider.ts
├── app.module.ts     # Root module
└── index.ts          # Bootstrap

Quick Reference

Essential Imports

// Framework core
import {
  Module,
  Controller,
  Get,
  Post,
  Put,
  Patch,
  Delete,
  Body,
  Query,
  Param,
  Headers,
  Req,
  ContextParam,
  UseGuards,
  UsePipes,
  UseInterceptors,
  UseFilters,
  HttpContext,
  HttpException,
  BadRequestException,
  UnauthorizedException,
  ForbiddenException,
  NotFoundException,
  createApplication,
  createZodValidationPipe,
  createZodSchemaDto,
} from '@afilmory/framework'

// DI
import { injectable } from 'tsyringe'

// Validation
import { z } from 'zod'

// Hono types
import type { Context } from 'hono'

Minimal Working Example

// app.module.ts
import { Module } from '@afilmory/framework'
import { AppController } from './app.controller'
import { AppService } from './app.service'

@Module({
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

// app.service.ts
import { injectable } from 'tsyringe'

@injectable()
export class AppService {
  getMessage() {
    return { message: 'Hello World!' }
  }
}

// app.controller.ts
import { Controller, Get } from '@afilmory/framework'
import { AppService } from './app.service'

@Controller('app')
export class AppController {
  constructor(private readonly service: AppService) {}

  @Get('/')
  async getMessage() {
    return this.service.getMessage()
  }
}

// index.ts
import 'reflect-metadata'
import { serve } from '@hono/node-server'
import { createApplication } from '@afilmory/framework'
import { AppModule } from './app.module'

async function bootstrap() {
  const app = await createApplication(AppModule)
  const hono = app.getInstance()
  serve({ fetch: hono.fetch, port: 3000 })
}

bootstrap()

This framework provides a robust foundation for building enterprise-grade HTTP services with TypeScript. Follow the patterns outlined here, and you'll create maintainable, testable, and scalable applications.