mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-24 23:05:05 +00:00
1830 lines
46 KiB
Markdown
1830 lines
46 KiB
Markdown
# 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](#framework-overview)
|
|
- [Core Concepts](#core-concepts)
|
|
- [Architecture Patterns](#architecture-patterns)
|
|
- [Decorators Reference](#decorators-reference)
|
|
- [Request Pipeline](#request-pipeline)
|
|
- [Dependency Injection](#dependency-injection)
|
|
- [Common Implementation Patterns](#common-implementation-patterns)
|
|
- [Testing Strategy](#testing-strategy)
|
|
|
|
## 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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
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.
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
// 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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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)
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
@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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
@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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
// In module
|
|
@Module({
|
|
providers: [
|
|
UserService, // Singleton by default
|
|
EmailService,
|
|
],
|
|
})
|
|
export class UserModule {}
|
|
```
|
|
|
|
**Constructor Injection:**
|
|
|
|
```typescript
|
|
@injectable()
|
|
export class UserService {
|
|
constructor(
|
|
private readonly db: DbAccessor,
|
|
private readonly cache: RedisAccessor,
|
|
private readonly logger: Logger,
|
|
) {}
|
|
}
|
|
```
|
|
|
|
### Accessing the Container
|
|
|
|
```typescript
|
|
// 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:**
|
|
|
|
```typescript
|
|
// 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:**
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
@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
|
|
|
|
```typescript
|
|
@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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
@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
|
|
|
|
```typescript
|
|
// 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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
@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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
// 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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
@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:**
|
|
|
|
```typescript
|
|
@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:**
|
|
|
|
```typescript
|
|
@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:**
|
|
|
|
```typescript
|
|
// 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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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.
|