@canlooks/roost
v0.0.1
Published
A backend micro service framework
Readme
@canlooks/roost
A lightweight, decorator-driven microservice framework for Node.js. Built on dependency injection, declarative routing, and schema-based validation — designed to minimize boilerplate and maximize clarity.
Installation
npm install @canlooks/roostRequires TypeScript with experimentalDecorators and emitDecoratorMetadata enabled in tsconfig.json:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}Quick Start
import {Roost, Controller, Action} from '@canlooks/roost'
@Controller('api')
class ApiController {
@Action('hello')
hello(name: string) {
return `Hello, ${name}!`
}
}
const app = await Roost.create({
anonymous: [ApiController]
})
const [result] = await app.invoke('api/hello', 'World')
console.log(result) // "Hello, World!"Core Concepts
Application Bootstrap
Roost.create() is the sole entry point. It wires the IoC container, plugin pipeline, and route registry.
const app = await Roost.create({
named: {cache: CacheService}, // named components
anonymous: [UserController], // auto-registered components
plugins: [loggingPlugin], // lifecycle plugins
dtoOptions: {allErrors: true} // ajv options for validation
})Components
A component is any class registered with the framework. Components go through a lifecycle pipeline during registration:
instantiate → @Config → @Expect → @Controller/@Action → @Module → @Inject → @InitializeRouting
Routes are declared with @Controller and @Action decorators. Three routing modes are supported.
Path Routing
@Controller('users')
class UserController {
@Action('list')
list() {
return ['alice', 'bob']
}
@Action(':id')
getById(@Params() params: Params) {
return {id: params.id}
}
}
const app = await Roost.create({anonymous: [UserController]})
await app.invoke('users/list') // → ['alice', 'bob']
await app.invoke('users/42') // → { id: '42' }Path wildcards:
| Pattern | Matches |
|---------|--------------------------------------------|
| * | Exactly one segment |
| ** | Zero or more segments |
| :name | One segment, captured as a named parameter |
Pattern Routing
@Controller({type: 'rpc'})
class RpcController {
@Action({method: 'add'})
add(a: number, b: number) {
return a + b
}
}
await app.invoke({type: 'rpc', method: 'add'}, 3, 4) // → [7]Regular Expression Routing
@Controller()
class ApiController {
@Action(/^\/api\/v\d+\/health$/)
health() {
return {status: 'ok'}
}
}Route Invocation
// By path (string)
const results = await app.invoke('users/42', ...args)
// By pattern (object)
const results = await app.invoke({type: 'rpc', method: 'add'}, ...args)Dependency Injection
@Inject supports four injection strategies:
class UserService {
// Inject the app instance itself
@Inject(app)
app!: Roost
// Inject by component class (auto-registered + singleton)
@Inject(CacheService)
cache!: CacheService
// Inject by registered name
@Inject('logger')
logger!: Logger
// Lazy-load via dynamic import
@Inject(() => import('./heavy-module'))
heavy!: HeavyModule
}Modules
@Module declares sub-component dependencies. The framework recursively registers all declared components.
class AuthService {
}
class Logger {
}
// Anonymous array form
@Module([AuthService, Logger])
class AppModule {
}
// Named object form
@Module({auth: AuthService, log: Logger})
class AppModule {
}Configuration
@Config injects default property values at registration time.
@Config({timeout: 3000, retries: 3})
class ApiClient {
timeout!: number
retries!: number
}
// Or as a higher-order function:
Config(ApiClient, {timeout: 5000})Values are deeply cloned per instance via structuredClone, so object configs are safe from cross-instance mutation.
Lifecycle Hooks
@Initialize marks methods that run after all dependencies are injected.
class DatabaseService {
@Inject('config')
config!: Config
@Initialize()
async connect() {
await this.driver.connect(this.config.url)
}
}Multiple @Initialize methods run in parallel via Promise.all.
DTO Validation
Built on ajv. Define schemas with decorators and validate data at registration time or invocation time.
Schema Definition
@DTO()
class CreateUserDTO {
@Str({minLength: 2, maxLength: 50})
name!: string
@Num({minimum: 0, maximum: 150})
age!: number
@Bool()
active!: boolean
@Required()
email!: string
@Nullable()
nickname!: string | null
@Enum('admin', 'user', 'guest')
role!: string
}Nested Objects
@DTO()
class AddressDTO {
@Str() street!: string
@Str() city!: string
}
@DTO()
class UserDTO {
@Obj(AddressDTO)
address!: AddressDTO
}Arrays
@DTO()
class OrderDTO {
@Arr({type: 'string'})
tags!: string[]
@Arr({items: {type: 'number'}, minItems: 1, uniqueItems: true})
itemIds!: number[]
}Property Validation (@Expect)
Validates component properties at registration time.
@Controller('users')
class UserController {
@Expect(CreateUserDTO)
body!: any
@Expect(CreateUserDTO, {required: false})
optionalBody!: any
@Expect(CreateUserDTO, {nullable: true})
nullableBody!: any | null
}Shorthand:
@Expect.Optional(CreateUserDTO) // → { required: false }
@Expect.Nullable(CreateUserDTO) // → { nullable: true }Parameter Validation (@Verify)
Validates method arguments at invocation time.
@Controller('math')
class MathController {
@Action('square')
square(@Verify({type: 'number', minimum: 0}) n: number) {
return n * n
}
}Composable Schemas
Reuse schema definitions across @Expect, @Verify, and @Obj:
const NumberSchema = {type: 'number', minimum: 0} as const
const StringSchema = {type: 'string', minLength: 1} as const
@DTO()
class ItemDTO {
@Obj(NumberSchema)
price!: number
@Obj(StringSchema)
label!: string
}Plugins
Plugins hook into the application lifecycle.
const loggingPlugin: Plugin = {
name: 'logger',
onCreate(app) {
console.log('[roost] app created')
},
onStaticInjected(app) {
console.log('[roost] static injection done')
},
onReady(app) {
console.log('[roost] app ready')
},
onError(app, err) {
console.error('[roost] error:', err.message)
}
}
const app = await Roost.create({
plugins: [loggingPlugin]
})Lifecycle order:
onCreate → register components → onStaticInjected → await registration → onReadyErrors during registration trigger onError before propagating.
Container (IoC)
The container stores singleton component instances. Components are lazily instantiated on first access.
// Register
await app.register.registerComponent(MyService)
await app.register.registerComponent('cache', RedisCache)
// Retrieve
const service = await app.container.get(MyService)
const cache = await app.container.get('cache')Complete Example
import {
Roost,
Controller,
Action,
Module,
Inject,
Config,
Initialize,
Params,
DTO,
Str,
Required,
Verify
} from '@canlooks/roost'
// ── DTO ──────────────────────────────────────────
@DTO()
class LoginDTO {
@Str({minLength: 3})
username!: string
@Str({minLength: 6})
@Required()
password!: string
}
// ── Services ─────────────────────────────────────
class AuthService {
async login(username: string, password: string) {
return {token: 'jwt-token', user: username}
}
}
// ── Controller ───────────────────────────────────
@Controller('auth')
class AuthController {
@Inject(AuthService)
auth!: AuthService
@Action('login')
async login(
@Verify(LoginDTO) credentials: any,
@Params() params: Params,
) {
return this.auth.login(credentials.username, credentials.password)
}
}
// ── Plugin ───────────────────────────────────────
const perfPlugin: Plugin = {
name: 'perf',
onReady() {
console.log('ready in', performance.now().toFixed(0), 'ms')
}
}
// ── Bootstrap ────────────────────────────────────
const app = await Roost.create({
anonymous: [AuthService, AuthController],
plugins: [perfPlugin]
})
const [result] = await app.invoke('/auth/login', {
username: 'alice',
password: 'secret123'
})
console.log(result) // { token: 'jwt-token', user: 'alice' }API Reference
Roost
| Member | Description |
|--------------------------------|---------------------------|
| static create(options) | Bootstrap the application |
| container: Container | IoC container |
| register: Register | Component registration |
| invoker: Invoker | Route invocation |
| invoke: Invoker.invoke | Route invocation |
| pluginManager: PluginManager | Plugin event emitter |
| dto: DTOManager | DTO validation manager |
| pathMap | Registered path routes |
| patternMap | Registered pattern routes |
| regularMap | Registered regex routes |
CreateOptions
| Property | Type | Description |
|--------------|---------------------------------|----------------------------|
| named | Record<string, ComponentType> | Named components |
| anonymous | ComponentType[] | Auto-registered components |
| plugins | Plugin[] | Lifecycle plugins |
| dtoOptions | AjvOptions | Options passed to ajv |
Decorators
| Decorator | Target | Description |
|-------------------------------------|-----------|------------------------------------|
| @Controller(path \| pattern) | Class | Declare a route controller |
| @Action(path \| pattern \| regex) | Method | Declare an action handler |
| @Module(components) | Class | Declare sub-component dependencies |
| @Inject(target) | Property | Inject a dependency |
| @Config(config) | Class | Inject default property values |
| @Initialize() | Method | Run after dependencies injected |
| @Params() | Parameter | Inject path parameters |
| @Option() | Parameter | Inject action option |
| @DTO(options?) | Class | Declare a DTO schema class |
| @Obj(schema) | Property | Object/nested schema type |
| @Num(options?) | Property | Number type |
| @Int(options?) | Property | Integer type |
| @Str(options?) | Property | String type |
| @Bool() | Property | Boolean type |
| @Arr(items \| options) | Property | Array type |
| @Required() | Property | Mark field as required |
| @Nullable() | Property | Allow null values |
| @Enum(...values) | Property | Enum constraint |
| @Const(value) | Property | Constant value constraint |
| @Default(value) | Property | Default value |
| @Expect(dto, options?) | Property | Validate property against DTO |
| @Verify(dto, options?) | Parameter | Validate argument against DTO |
Plugin
interface Plugin {
name: string
onCreate?(app: Roost): any
onStaticInjected?(app: Roost): any
onReady?(app: Roost): any
onError?(app: Roost, err: any): any
}License
MIT
