A modular, protocol-agnostic backend framework for Node.js.
Luna is a protocol-first backend framework for Node.js written in TypeScript. Most frameworks are designed around HTTP — Luna abstracts the transport entirely. Every incoming message, whether it arrives via HTTP, WebSocket, or gRPC, is normalized into a single LunaMessage and handled by the same pipeline.
You write your business logic once. The adapter decides how the world reaches it.
Every handler receives a LunaMessage regardless of the protocol:
interface LunaMessage {
context: string // 'http' | 'ws' | 'grpc'
payload: unknown // request body / event data / RPC input
metadata: {
params?: Record<string, string> // URL params (HTTP)
query?: Record<string, unknown> // query string (HTTP)
headers?: Record<string, unknown> // request headers (HTTP)
event?: string // event name (WebSocket)
socketId?: string // connection ID (WebSocket)
grpcMetadata?: Record<string, unknown> // gRPC metadata
}
}Every request goes through the same pipeline, regardless of which adapter received it:
Incoming request (HTTP / WebSocket / gRPC)
│
▼
┌─────────────┐
│ Adapter │ translates protocol → LunaMessage
└──────┬──────┘
│
▼
┌─────────────┐
│ Guards │ canActivate() → block or pass
└──────┬──────┘
│
▼
┌─────────────┐
│ Pipes │ transform / validate the message
└──────┬──────┘
│
▼
┌──────────────────┐
│ Interceptors │ wrap execution (before + after)
│ ┌────────────┐ │
│ │ Handler │ │ your controller method
│ └────────────┘ │
└──────┬───────────┘
│
▼
┌──────────────────┐
│ Exception Filter │ catches any error from any stage
└──────┬───────────┘
│
▼
Response
Middleware stacks at global → controller → method level for every stage. First matching exception filter wins.
Luna uses a module-based dependency injection system. Each module declares its providers, imports, and exports:
AppModule
├── imports: [UsersModule, DatabaseModule]
│
├── UsersModule
│ providers: [UsersService, UsersController]
│ exports: [UsersService] ← visible to importers
│
└── DatabaseModule
providers: [{ provide: 'DB', useFactory: ... }]
exports: ['DB']
Providers stay private to their module unless exported. A controller in UsersModule can inject UsersService directly — no import needed within the same module.
npm install @lunafw/core @lunafw/common @lunafw/platform-expressAdd to tsconfig.json:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}// users.service.ts
import { Injectable } from '@lunafw/core'
@Injectable()
export class UsersService {
private users = [{ id: '1', name: 'Alice' }]
findAll() { return this.users }
findOne(id: string) {
return this.users.find(u => u.id === id)
}
create(data: { name: string }) {
const user = { id: String(this.users.length + 1), ...data }
this.users.push(user)
return user
}
}// users.controller.ts
import { Injectable } from '@lunafw/core'
import { Body, Controller, NotFoundException, On, Param } from '@lunafw/common'
import { UsersService } from './users.service'
@Injectable()
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@On('get', '/')
findAll() {
return this.usersService.findAll()
}
@On('get', '/:id')
findOne(@Param('id') id: string) {
const user = this.usersService.findOne(id)
if (!user) throw new NotFoundException(`User ${id} not found`)
return user
}
@On('post', '/')
create(@Body() dto: { name: string }) {
return this.usersService.create(dto)
}
}// users.module.ts
import { Module } from '@lunafw/core'
import { UsersService } from './users.service'
import { UsersController } from './users.controller'
@Module({
providers: [UsersService, UsersController],
exports: [UsersService],
})
export class UsersModule {}// app.module.ts
import { Module } from '@lunafw/core'
import { UsersModule } from './users/users.module'
@Module({ imports: [UsersModule] })
export class AppModule {}// 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(`Listening on :${adapter.getPort()}`)
}
bootstrap()GET http://localhost:3000/users → [{ id: '1', name: 'Alice' }]
GET http://localhost:3000/users/1 → { id: '1', name: 'Alice' }
GET http://localhost:3000/users/99 → 404 { statusCode: 404, message: 'User 99 not found' }
POST http://localhost:3000/users → { id: '2', name: '...' }
The Express adapter supports terminal wildcards with the same behavior on
Express 4 and Express 5. Use an anonymous wildcard when the default parameter
name wildcard is sufficient, or append a name after *:
@Controller('files')
export class FilesController {
@On('get', '/*filepath')
findFile(@Param('filepath') filepath: string) {
return { filepath }
}
}GET /files/images/avatar.png sets filepath to
'images/avatar.png'. Wildcards must be the final part of a route and may span
multiple path segments. @On('get', '*') and @On('get', '/*') create a
controller-wide catch-all and expose the matched value as @Param('wildcard').
Concrete routes are registered before wildcard routes, so a route such as
/health takes precedence over /*path regardless of declaration order.
| Package | Description |
|---|---|
@lunafw/core |
DI container, module system, lifecycle hooks |
@lunafw/common |
LunaFactory, all decorators, middleware pipeline |
@lunafw/platform-express |
HTTP adapter built on Express |
@lunafw/platform-ws |
WebSocket adapter built on ws |
@lunafw/platform-grpc |
gRPC adapter built on @grpc/grpc-js |
@lunafw/config |
.env-based configuration module |
@lunafw/testing |
In-memory testing utilities |
| Example | Description |
|---|---|
examples/http-and-ws |
HTTP + WebSocket on the same app with guards, interceptors, and filters |
examples/rest-api |
Full REST API with Prisma, ConfigModule, and ValidationPipe |
Please read the contribution guidelines before opening an issue. The tracker is for bug reports and feature requests only.
