@lunafw/common
v1.7.0
Published
Common abstractions, decorators, guards, pipes and interceptors for the Luna framework
Maintainers
Readme
Installation
npm install @lunafw/core @lunafw/commonLunaFactory
LunaFactory.createApplication bootstraps the DI container, registers all controllers with the given adapter(s), and returns the application object.
// main.ts
import { LunaFactory } from '@lunafw/common'
import { ExpressAdapter } from '@lunafw/platform-express'
import { AppModule } from './app.module'
const bootstrap = async () => {
const adapter = new ExpressAdapter({ port: 3000 })
const app = await LunaFactory.createApplication(AppModule, adapter)
await app.start()
console.log(`Server on :${adapter.getPort()}`)
}
bootstrap()Multiple adapters
Run HTTP, WebSocket, and gRPC simultaneously from the same module tree:
import { ExpressAdapter } from '@lunafw/platform-express'
import { WsAdapter } from '@lunafw/platform-ws'
import { GrpcAdapter } from '@lunafw/platform-grpc'
const app = await LunaFactory.createApplication(AppModule, [
new ExpressAdapter({ port: 3000 }),
new WsAdapter({ port: 3001 }),
new GrpcAdapter({ port: 50051, protoPath: './users.proto', packageName: 'users' }),
])
await app.start()Every @On handler that matches a registered adapter receives requests from that protocol — a single controller method can handle both HTTP and WebSocket simultaneously.
LunaMessage
The universal message envelope. Every handler receives one, regardless of protocol:
interface LunaMessage {
context: string // 'http' | 'ws' | 'grpc'
payload: unknown // request body / event data / RPC input
metadata: {
// HTTP
params?: Record<string, string>
query?: Record<string, unknown>
headers?: Record<string, unknown>
// WebSocket
event?: string
socketId?: string
// gRPC
grpcMetadata?: Record<string, unknown>
}
}Controllers
@Controller
Marks a class as a controller and sets the route prefix. Must also be decorated with @Injectable() so the DI container can instantiate it.
import { Injectable } from '@lunafw/core'
import { Controller } from '@lunafw/common'
@Injectable()
@Controller('users') // routes live under /users (HTTP) or chat.* (WebSocket)
export class UsersController {}@On
Binds a method to a protocol event and an optional path. The adapter interprets both values according to its protocol:
| Adapter | event | path |
|---|---|---|
| HTTP (Express) | HTTP method ('get', 'post', 'patch', 'delete') | URL path ('/', '/:id') |
| WebSocket | Event name suffix | Ignored ('/' by convention) |
| gRPC | RPC method name from the proto | Ignored |
import { On } from '@lunafw/common'
@On('get', '/') // HTTP GET /users/
@On('post', '/') // HTTP POST /users/
@On('get', '/:id') // HTTP GET /users/:id
@On('patch', '/:id') // HTTP PATCH /users/:id
@On('delete', '/:id') // HTTP DELETE /users/:id
@On('send', '/') // WebSocket → listens for event "chat.send"
@On('GetUser') // gRPC → handles the GetUser RPCParameter decorators
Extract specific values from the message directly into handler parameters instead of reading from message.metadata manually.
import { Body, Headers, Message, Param, Query } from '@lunafw/common'
@Injectable()
@Controller('users')
export class UsersController {
// @Param — URL route parameter
@On('get', '/:id')
findOne(@Param('id') id: string) {
return this.usersService.findOne(id)
}
// @Body — full payload or a specific field
@On('post', '/')
create(@Body() dto: CreateUserDto) {
return this.usersService.create(dto)
}
// @Body with a key
@On('patch', '/:id/name')
rename(@Param('id') id: string, @Body('name') name: string) {
return this.usersService.rename(id, name)
}
// @Query — URL query string parameter
@On('get', '/')
list(@Query('page') page = '1', @Query('limit') limit = '20') {
return this.usersService.paginate(Number(page), Number(limit))
}
// @Headers — a specific header value
@On('post', '/')
createWithAuth(
@Body() dto: CreateUserDto,
@Headers('authorization') token: string,
) {
this.authService.verify(token)
return this.usersService.create(dto)
}
// @Message — the full LunaMessage
@On('delete', '/:id')
remove(@Param('id') id: string, @Message() message: LunaMessage) {
console.log('protocol:', message.context)
return this.usersService.remove(id)
}
}When no parameter decorators are used, the full LunaMessage is passed as the first argument (backward-compatible):
@On('get', '/:id')
findOne(message: LunaMessage) {
const { id } = message.metadata.params as { id: string }
return this.usersService.findOne(id)
}Middleware pipeline
For every incoming message the pipeline runs in this order:
Guards → Pipes → Interceptors → Handler
↑
Exception Filters wrap the whole chainEach stage stacks at global → controller → method level.
Guards
Guards run first and decide whether the request should reach the handler. Return false or throw to block; return true to pass.
import { Injectable } from '@lunafw/core'
import { LunaGuard, LunaMessage } from '@lunafw/common'
@Injectable()
export class AuthGuard implements LunaGuard {
canActivate(message: LunaMessage): boolean {
const headers = message.metadata.headers as Record<string, string>
return !!headers['authorization']
}
}Apply at controller level (all methods) or method level:
import { UseGuards } from '@lunafw/common'
@UseGuards(AuthGuard) // class resolved via DI
@Controller('users')
export class UsersController {
@UseGuards(new RolesGuard('admin')) // pre-built instance
@On('delete', '/:id')
remove(@Param('id') id: string) { ... }
}Execution order: controller guards first, then method guards.
Parameterized guards (via instance)
When a guard needs configuration, pass an instance directly instead of a class:
export class RolesGuard implements LunaGuard {
constructor(private readonly role: string) {}
canActivate(message: LunaMessage): boolean {
const headers = message.metadata.headers as Record<string, string>
return headers['x-role'] === this.role
}
}
// Different roles on different routes:
@UseGuards(new RolesGuard('editor'))
@On('patch', '/:id')
update(@Param('id') id: string, @Body() dto: UpdatePostDto) { ... }
@UseGuards(new RolesGuard('admin'))
@On('delete', '/:id')
remove(@Param('id') id: string) { ... }Pipes
Pipes transform or validate the LunaMessage before it reaches the handler. They run after guards and receive the message output by the previous pipe in the chain.
import { Injectable } from '@lunafw/core'
import { LunaMessage, LunaPipe } from '@lunafw/common'
@Injectable()
export class TrimBodyPipe implements LunaPipe {
transform(message: LunaMessage): LunaMessage {
if (typeof message.payload === 'object' && message.payload) {
const trimmed = Object.fromEntries(
Object.entries(message.payload as Record<string, unknown>).map(
([k, v]) => [k, typeof v === 'string' ? v.trim() : v],
),
)
return { ...message, payload: trimmed }
}
return message
}
}import { UsePipes } from '@lunafw/common'
@UsePipes(TrimBodyPipe) // applied to all methods in the controller
@Controller('users')
export class UsersController {
@UsePipes(new ValidationPipe(CreateUserDto)) // applied to this method only
@On('post', '/')
create(@Body() dto: CreateUserDto) { ... }
}Execution order: controller pipes first, then method pipes.
Built-in ValidationPipe
Luna ships a ValidationPipe in @lunafw/common that integrates with class-validator and class-transformer:
npm install class-validator class-transformerimport { IsEmail, IsString, MinLength } from 'class-validator'
export class CreateUserDto {
@IsString()
@MinLength(2)
name: string
@IsEmail()
email: string
}import { ValidationPipe } from '@lunafw/common'
@On('post', '/')
@UsePipes(new ValidationPipe(CreateUserDto))
create(@Body() dto: CreateUserDto) {
return this.usersService.create(dto)
}Invalid requests automatically return 400 Bad Request with descriptive field errors.
Zod validation
You can also implement a pipe with Zod:
import { z, ZodSchema } from 'zod'
import { BadRequestException, LunaMessage, LunaPipe } from '@lunafw/common'
class ZodPipe<T> implements LunaPipe {
constructor(private readonly schema: ZodSchema<T>) {}
transform(message: LunaMessage): LunaMessage {
const result = this.schema.safeParse(message.payload)
if (!result.success) {
throw new BadRequestException(result.error.issues[0]?.message ?? 'Validation failed')
}
return { ...message, payload: result.data }
}
}
const CreatePostSchema = z.object({ title: z.string().min(3), body: z.string() })
@On('post', '/')
@UsePipes(new ZodPipe(CreatePostSchema))
create(@Body() dto: { title: string; body: string }) { ... }Interceptors
Interceptors wrap handler execution and can act both before and after the handler via the next() call. They run after pipes.
import { Injectable } from '@lunafw/core'
import { LunaExecutionContext, LunaInterceptor } from '@lunafw/common'
@Injectable()
export class LoggingInterceptor implements LunaInterceptor {
async intercept(context: LunaExecutionContext, next: () => Promise<unknown>) {
const start = Date.now()
const result = await next() // call the handler chain
const ms = Date.now() - start
console.log(`[${context.getHandler()}] ${ms}ms`)
return result
}
}
@Injectable()
export class CacheInterceptor implements LunaInterceptor {
private cache = new Map<string, unknown>()
async intercept(context: LunaExecutionContext, next: () => Promise<unknown>) {
const message = context.getMessage()
const key = JSON.stringify(message.metadata.params)
if (this.cache.has(key)) return this.cache.get(key)
const result = await next()
this.cache.set(key, result)
return result
}
}import { UseInterceptors } from '@lunafw/common'
@UseInterceptors(LoggingInterceptor)
@Controller('users')
export class UsersController {
@UseInterceptors(CacheInterceptor)
@On('get', '/:id')
findOne(@Param('id') id: string) { ... }
}Execution order: controller interceptors wrap method interceptors wrap the handler.
controller-before → method-before → handler → method-after → controller-afterException Filters
Filters catch exceptions thrown anywhere in the pipeline — by guards, pipes, interceptors, or the handler itself — and turn them into a response.
Use @Catch to declare which exception types the filter handles. Omit the argument for a catch-all.
import { Injectable } from '@lunafw/core'
import { Catch, LunaExceptionFilter, LunaMessage } from '@lunafw/common'
// A domain error specific to your application
export class UserNotFoundError extends Error {
constructor(id: string) { super(`User ${id} not found`) }
}import { NotFoundException } from '@lunafw/common'
@Catch(UserNotFoundError)
@Injectable()
export class UserNotFoundFilter implements LunaExceptionFilter<UserNotFoundError> {
catch(exception: UserNotFoundError, _message: LunaMessage) {
// convert the domain error into an HTTP exception
throw new NotFoundException(exception.message)
}
}import { UseFilters } from '@lunafw/common'
@UseFilters(UserNotFoundFilter) // via DI
@Controller('users')
export class UsersController {
@UseFilters(new UserNotFoundFilter()) // or a pre-built instance
@On('get', '/:id')
findOne(@Param('id') id: string) {
throw new UserNotFoundError(id) // caught by the filter
}
}Filter lookup order: method filters → controller filters → global filters. First matching filter wins.
Catch-all filter
@Catch() // no argument = catches everything
@Injectable()
export class AllExceptionsFilter implements LunaExceptionFilter {
catch(exception: unknown, message: LunaMessage) {
console.error('Unhandled exception on', message.context, exception)
throw new InternalServerErrorException()
}
}HTTP Exceptions
All HTTP exceptions live in @lunafw/common and can be imported from there or from @lunafw/platform-express (re-exported for backward compatibility).
import {
BadRequestException, // 400
UnauthorizedException, // 401
ForbiddenException, // 403
NotFoundException, // 404
MethodNotAllowedException, // 405
RequestTimeoutException, // 408
ConflictException, // 409
GoneException, // 410
UnprocessableEntityException, // 422
TooManyRequestsException, // 429
InternalServerErrorException, // 500
NotImplementedException, // 501
ServiceUnavailableException, // 503
} from '@lunafw/common'
throw new NotFoundException('User not found')
// → HTTP 404 { statusCode: 404, message: "User not found" }Custom HTTP exceptions
import { HttpException } from '@lunafw/common'
export class PaymentRequiredException extends HttpException {
constructor(message = 'Payment Required') {
super(402, message)
}
}Global middleware
Apply guards, pipes, interceptors, and filters to every route without decorating each controller:
import { LunaFactory } from '@lunafw/common'
import { ExpressAdapter } from '@lunafw/platform-express'
const adapter = new ExpressAdapter({ port: 3000 })
const app = await LunaFactory.createApplication(AppModule, adapter)
app
.useGlobalGuards(new AuthGuard())
.useGlobalPipes(new ValidationPipe())
.useGlobalInterceptors(new LoggingInterceptor())
.useGlobalFilters(new AllExceptionsFilter())
await app.start()Execution order: global → controller → method for each stage.
@SetMetadata + Reflector
Attach arbitrary metadata to classes or methods and read it inside guards, interceptors, or filters.
import { SetMetadata } from '@lunafw/common'
// Create a shorthand decorator
export const Roles = (...roles: string[]) => SetMetadata('roles', roles)@Roles('admin')
@On('delete', '/:id')
remove(@Param('id') id: string) { ... }Read the metadata from a guard or interceptor via Reflector:
import { Injectable } from '@lunafw/core'
import { LunaExecutionContext, LunaInterceptor, Reflector } from '@lunafw/common'
@Injectable()
export class RolesInterceptor implements LunaInterceptor {
constructor(private readonly reflector: Reflector) {}
async intercept(context: LunaExecutionContext, next: () => Promise<unknown>) {
const roles = this.reflector.get<string[]>('roles', context.getMessage())
if (roles && !this.hasRole(context.getMessage(), roles)) {
throw new ForbiddenException('Insufficient role')
}
return next()
}
private hasRole(message: LunaMessage, roles: string[]): boolean {
const headers = message.metadata.headers as Record<string, string>
return roles.includes(headers['x-role'])
}
}