typeroad
v4.0.0-alpha.0
Published
TypeScript backend framework — DI Container, Filter Chain, EventBus, Provider Pattern
Maintainers
Readme
TypeRoad
TypeScript backend framework for multi-tenant Express APIs — DI Container, Filter Chain, EventBus, Provider Pattern.
v4.0.0-alpha.0 — Full TypeScript rewrite of the onRoad framework.
Table of Contents
- Installation
- Quick Start
- Architecture Overview
- DI Container & Decorators
- Controllers, Services & Repositories
- Entity System
- Filter Chain
- Security (Roles & Public)
- Sentinel & EventBus
- Providers
- Inter-Service Transport
- Request Context (AsyncLocalStorage)
- Logging
- Plugins
- Storage
- Health Endpoint
- Graceful Shutdown
- Testing
- Subpath Exports
Installation
npm install typeroadPeer dependencies (install as needed):
npm install express reflect-metadata pino uuid
# Optional — only if using HttpTransport:
npm install axiosTypeRoad is ESM-only (
"type": "module"). Yourtsconfig.jsonmust use"module": "NodeNext"or"Node16"and enable"experimentalDecorators": true.
Quick Start
import "reflect-metadata"
import { OnRoadExpress, Controller, Service, Repository } from "typeroad"
import { SequelizeConnectionManager } from "typeroad/database"
import { CorsFilter, JwtFilter, TenantFilter, RoleFilter } from "typeroad/filters"
// 1. Define your layers
@Repository()
class OrdemRepository { /* ... */ }
@Service()
class OrdemService { /* ... */ }
@Controller("/ordem")
class OrdemController {
/* ... */
}
// 2. Create the app
const app = new OnRoadExpress({
connections: [
new SequelizeConnectionManager({ dialect: "postgres", host: "localhost", database: "mydb" }),
],
})
// 3. Register filters
app.useFilters([
new CorsFilter({ origins: ["http://localhost:3000"] }),
new JwtFilter({ secret: process.env.JWT_SECRET! }),
new TenantFilter(),
new RoleFilter(),
])
// 4. Register modules
app.register([OrdemController, OrdemService, OrdemRepository])
// 5. Build & start
await app.buildServer({ port: 3001 })Architecture Overview
┌──────────────────────────────────────────────────┐
│ OnRoadExpress │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ Container │ │ FilterChain│ │ EventBus │ │
│ │ (DI + IoC) │ │ (Middleware)│ │ (Sentinel) │ │
│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │
│ │ │ │ │
│ ┌─────▼───────────────▼───────────────▼──────┐ │
│ │ Request Handler (route) │ │
│ │ requestContext.run({ tenant, appToken }) │ │
│ │ ┌──────────┐ ┌─────────┐ ┌────────────┐ │ │
│ │ │Controller│→│ Service │→│ Repository │ │ │
│ │ └──────────┘ └─────────┘ └────────────┘ │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ Providers │ │
│ │ Messaging │ Realtime │ TaskSched │ Socket│ │
│ └──────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ TransportFactory │ │
│ │ HttpTransport │ MessagingTransport │ │
│ └──────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘DI Container & Decorators
TypeRoad uses a decorator-based DI container with automatic class scanning.
Decorators
| Decorator | Scope | Purpose |
|-----------|-------|---------|
| @Injectable() | Transient (default) | Generic injectable class |
| @Controller("/path") | Request | Express route handler |
| @Service() | Request | Business logic layer |
| @Repository() | Request | Data access layer |
Scopes
| Scope | Behavior |
|-------|----------|
| SINGLETON | One instance for the entire application |
| REQUEST | One instance per request scope |
| TRANSIENT | New instance on every resolve |
Usage
import { Injectable, Scope } from "typeroad"
@Injectable({ scope: Scope.SINGLETON })
class CacheManager {
private store = new Map<string, unknown>()
get(key: string) { return this.store.get(key) }
set(key: string, value: unknown) { this.store.set(key, value) }
}Register all classes via app.register([...]) — the container auto-scans metadata.
Controllers, Services & Repositories
AbstractController
import { Controller, AbstractController, type RouteConfig } from "typeroad"
@Controller("/ordem")
class OrdemController extends AbstractController<OrdemService> {
constructor() {
super({
service: OrdemService,
routes: {
findAll: { method: "get" },
create: { method: "post" },
update: { method: "put", path: "/:id" },
remove: { method: "delete", path: "/:id" },
},
})
}
async findAll(req: Request, res: Response) {
const data = await this.service.findAll(req.query)
res.json({ content: data })
}
async create(req: Request, res: Response) {
const sentinel = new Sentinel(req, res)
const result = await this.service.create(req.body, sentinel)
await sentinel.finishRequest(result)
}
}AbstractService
import { Service, AbstractService } from "typeroad"
@Service()
class OrdemService extends AbstractService<OrdemRepository> {
constructor() {
super({ repository: OrdemRepository })
}
async findAll(query: unknown) {
return this.repository.findAll(query)
}
}SequelizeRepository / MongooseRepository
import { Repository, SequelizeRepository } from "typeroad"
@Repository()
class OrdemRepository extends SequelizeRepository {
constructor() {
super({ modelName: "Ordem" })
}
}Entity System
Decorate model classes with metadata for auto-registration:
import { Entity, Column, DataType, HasMany } from "typeroad/entity"
@Entity({ tableName: "ordens", timestamps: true })
class Ordem {
@Column({ type: DataType.UUID, primaryKey: true })
id!: string
@Column({ type: DataType.STRING, allowNull: false })
descricao!: string
@Column({ type: DataType.ENUM("aberta", "fechada") })
status!: string
@HasMany(() => Tarefa, { foreignKey: "ordemId" })
tarefas!: Tarefa[]
}The EntityRegistry collects all decorated entities for model initialization.
Filter Chain
Filters are ordered middleware executed before route handlers. TypeRoad includes 5 built-in filters:
| Filter | Order | Purpose |
|--------|-------|---------|
| CorsFilter | 10 | CORS headers |
| JwtFilter | 20 | JWT token validation |
| TenantFilter | 30 | Multi-tenant resolution |
| RoleFilter | 40 | Role-based access |
| RequestContextFilter | 50 | Attach logger/context to request |
Registration (3 forms)
// 1. Bare instance
app.useFilters([new CorsFilter({ origins: ["*"] })])
// 2. Class reference (uses @Filter decorator config)
app.useFilters([CorsFilter])
// 3. Object form (manual config overrides decorator)
app.useFilters([{
filter: new JwtFilter({ secret: "..." }),
order: 15,
exclude: ["/health", "/public"],
}])Custom Filter
import { OnRoadFilter, Filter, FilterChain } from "typeroad/filters"
@Filter({ order: 25 })
class AuditFilter extends OnRoadFilter {
async execute(req: Request, res: Response, chain: FilterChain) {
console.log(`[${req.method}] ${req.path}`)
await chain.next(req, res) // pass to next filter
}
}
// Auto-discovered when passed to app.register()
app.register([AuditFilter])Filters support exclude (skip specific paths) and condition (dynamic enable/disable).
Security (Roles & Public)
@Roles
Restrict access at class or method level:
import { Roles, Public } from "typeroad/security"
@Roles("admin", "manager")
@Controller("/config")
class ConfigController extends AbstractController<ConfigService> {
// Inherits class-level roles: admin, manager
@Roles("admin") // Method-level overrides class-level
async deleteAll(req: Request, res: Response) { /* ... */ }
@Public() // No role check — open access
async getVersion(req: Request, res: Response) { /* ... */ }
}- Method-level
@Rolesoverrides class-level (not additive). @Public()sets roles to[](empty array = no restriction).getRoles(instance, methodName)returnsundefinedif no decorator is present (no enforcement).
OnRoadExpress enforces roles automatically in buildServer() by reading req.decoded.roles or req.user.roles.
Sentinel & EventBus
The Sentinel manages the request lifecycle — transactions, errors, and events.
async create(req: Request, res: Response) {
const sentinel = new Sentinel(req, res)
// Start database transaction
sentinel.transaction = await sequelize.transaction()
try {
const result = await this.service.create(req.body, sentinel)
// Queue side-effects
sentinel.appendMo({ type: "ordemCriada", data: result })
sentinel.appendNotification({ userId: "abc", title: "Nova OS", body: "..." })
// Commit + fire afterCommit events + send response
await sentinel.finishRequest(result)
} catch (err) {
sentinel.appendError(err as Error)
await sentinel.finishRequest(null) // triggers rollback + 500
}
}EventBus Handlers
sentinel.eventBus.on("afterCommit", async (payload) => {
// Publish matching objects to RTDB
// Send notifications
// Propagate to other APIs
})
sentinel.eventBus.on("matchingObject", (mo) => {
// Process individual matching object
})Providers
TypeRoad defines 4 abstract provider interfaces. Each has an isReady() method and lifecycle hooks.
| Provider | Purpose | Example Implementation |
|----------|---------|----------------------|
| MessagingProvider | Inter-API async messaging | RabbitMQ (CloudAMQP) |
| RealtimeProvider | Push updates to frontends | Firebase RTDB |
| TaskSchedulerProvider | Delayed/scheduled tasks | Google Cloud Tasks |
| SocketProvider | WebSocket communication | Socket.io |
Registration
const app = new OnRoadExpress()
app.setMessagingProvider(new RabbitMQMessagingProvider(process.env.AMQP_URL!))
app.setRealtimeProvider(new FirebaseRealtimeProvider(firebaseApp))
app.setTaskSchedulerProvider(new CloudTasksProvider(config))
app.setSocketProvider(new SocketIOProvider(httpServer))All providers fail gracefully during buildServer() — if one fails to initialize, the app continues without it.
Implementing a Provider
import { MessagingProvider, type MessagingMessage, type MessageWorker } from "typeroad"
class RabbitMQMessagingProvider extends MessagingProvider {
isReady(): boolean { return this.connected }
async initialize(config: Record<string, unknown>): Promise<void> {
// Connect to RabbitMQ...
}
async publish(message: MessagingMessage): Promise<void> {
// Publish to exchange...
}
async subscribe(route: string, worker: MessageWorker): Promise<void> {
// Bind queue and consume...
}
async shutdown(): Promise<void> {
// Close connection...
}
}Inter-Service Transport
Transport classes enable API-to-API communication with zero boilerplate.
HttpTransport
// Register API endpoints
app.registerApiClients({
Manutencao: "http://api-manutencao:3001",
Processo: "http://api-processo:3002",
})
// Inside a service — just call it
const transport = app.transportFactory.createHttp("Manutencao")
const result = await transport.send("/ordem/sync", { data: payload })MessagingTransport
const transport = app.transportFactory.createMessaging()
await transport.send("ordem.created", { ordemId: "123" })No Sentinel or tenant needs to be passed — the transport reads them automatically from the active RequestContext via AsyncLocalStorage.
Request Context (AsyncLocalStorage)
Every route handler is wrapped in requestContext.run() by OnRoadExpress, providing request-scoped data without parameter threading:
import { getRequestContext } from "typeroad"
// Anywhere in the call stack during a request:
const ctx = getRequestContext()
console.log(ctx.tenant) // current tenant
console.log(ctx.appToken) // x-app-token header
console.log(ctx.logger) // request-scoped logger (if configured)This is what enables InterServiceTransport, HttpTransport, and MessagingTransport to automatically include tenant and x-app-token headers without receiving them as constructor arguments.
If called outside a request handler, getRequestContext() throws a descriptive error.
Logging
TypeRoad uses pino via the PinoLogger class implementing the OnRoadLogger interface.
import { PinoLogger } from "typeroad/logging"
const app = new OnRoadExpress({
logger: new PinoLogger({ level: "debug" }),
})
// Anywhere:
app.logger.info("Server started", { port: 3001 })
app.logger.warn("Slow query", { duration: 2500 })
app.logger.error("Connection failed", { host: "db" })OnRoadLogger Interface
interface OnRoadLogger {
info(message: string, meta?: Record<string, unknown>): void
warn(message: string, meta?: Record<string, unknown>): void
error(message: string, meta?: Record<string, unknown>): void
debug(message: string, meta?: Record<string, unknown>): void
child(bindings: Record<string, unknown>): OnRoadLogger
}Swap PinoLogger for any custom implementation of OnRoadLogger.
Plugins
Plugins extend OnRoadExpress capabilities. A plugin receives the full app reference during installation.
import type { OnRoadPlugin } from "typeroad"
class MetricsPlugin implements OnRoadPlugin {
name = "metrics"
async install(app: { app: Express; eventBus: RequestEventBus }): Promise<void> {
app.eventBus.on("afterCommit", (payload) => {
// Track metrics...
})
}
}
await app.use(new MetricsPlugin())@onroad/plugin-notification
The notification plugin is a separate package: @onroad/plugin-notification.
import { NotificationPlugin } from "@onroad/plugin-notification"
await app.use(new NotificationPlugin({
apiUrl: process.env.NOTIFICATION_API_URL!,
timeout: 5000,
}))It listens to afterCommit events and sends notification payloads to the notification API using native fetch with Promise.allSettled for resilience.
Storage
Abstract StorageProvider for file/attachment operations (e.g., GCS, S3):
import { StorageProvider } from "typeroad/storage"
class GCSStorageProvider extends StorageProvider {
async upload(buffer: Buffer, opts: UploadOptions): Promise<string> { /* ... */ }
async getSignedUrl(key: string): Promise<string> { /* ... */ }
async delete(key: string): Promise<void> { /* ... */ }
}Health Endpoint
GET /health is automatically registered by buildServer():
{
"status": "ok",
"timestamp": "2025-01-15T10:30:00.000Z",
"providers": {
"messaging": true,
"realtime": true,
"taskScheduler": null,
"socket": null
}
}true= provider registered and readyfalse= provider registered but not readynull= provider not registered
Graceful Shutdown
await app.shutdown()shutdown() disconnects all providers sequentially (messaging, realtime, taskScheduler, socket), closes the HTTP server, and logs each step. Each provider failure is isolated — one failing provider does not block the others.
Testing
TypeRoad uses Vitest with 223 tests across 9 test files:
npm test # Run all tests
npm run test:watch # Watch mode
npm run test:coverage # Coverage report| Test File | Tests | Covers |
|-----------|-------|--------|
| container.test.ts | 16 | DI Container, decorators, scopes |
| entity.test.ts | 14 | Entity, Column, associations |
| database.test.ts | 13 | ConnectionManagers |
| filters.test.ts | 33 | FilterChain, all 5 built-in filters |
| security.test.ts | 25 | @Roles, @Public, enforcement |
| sentinel.test.ts | 36 | Sentinel, EventBus, transactions |
| logging.test.ts | 20 | PinoLogger, OnRoadLogger |
| providers.test.ts | 41 | All 4 providers, graceful-fail, shutdown |
| transport.test.ts | 25 | HttpTransport, MessagingTransport, TransportFactory, RequestContext |
Project Structure
src/
├── index.ts # Public API exports
├── OnRoadExpress.ts # Main orchestrator
├── container/ # DI Container + decorators
├── context/ # RequestContext (AsyncLocalStorage)
├── core/ # AbstractController/Service/Repository, Sentinel, EventBus
├── entity/ # @Entity, @Column, @Field, associations
├── filters/ # FilterChain, @Filter, built-in filters (Cors, JWT, Tenant, Role, RequestContext)
├── security/ # @Roles, @Public
├── database/ # ConnectionManager abstract + Sequelize/Mongo implementations
├── transport/ # InterServiceTransport, HttpTransport, MessagingTransport, TransportFactory
├── messaging/ # MatchingObject
├── providers/ # MessagingProvider, RealtimeProvider, TaskSchedulerProvider, SocketProvider
├── logging/ # OnRoadLogger interface + PinoLogger
├── plugins/ # OnRoadPlugin interface
├── storage/ # StorageProvider abstract
└── types/ # Express Request augmentationSubpath Exports
Import only what you need:
import { OnRoadExpress } from "typeroad" // Main
import { AbstractController, Sentinel } from "typeroad/core" // Core classes
import { Entity, Column, DataType } from "typeroad/entity" // Entity decorators
import { FilterChain, CorsFilter } from "typeroad/filters" // Filters
import { Roles, Public } from "typeroad/security" // Security
import { SequelizeConnectionManager } from "typeroad/database" // Database
import { HttpTransport, TransportFactory } from "typeroad/transport" // Transport
import { MatchingObject } from "typeroad/messaging" // Messaging
import { PinoLogger } from "typeroad/logging" // Logging
import { StorageProvider } from "typeroad/storage" // StorageLicense
UNLICENSED — HashCodeTI-Brasil
