Package Exports
- @onroad/core
- @onroad/core/core
- @onroad/core/database
- @onroad/core/entity
- @onroad/core/filters
- @onroad/core/logging
- @onroad/core/messaging
- @onroad/core/migrations
- @onroad/core/providers/cloudtasks
- @onroad/core/providers/firebase
- @onroad/core/providers/rabbitmq
- @onroad/core/security
- @onroad/core/storage
- @onroad/core/transport
Readme
TypeRoad
TypeScript backend framework for multi-tenant Express APIs — DI Container, Filter Chain, EventBus, Provider Pattern.
v4.0.0-alpha.1 — 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
- Known Pitfalls
Installation
npm install @onroad/corePeer 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 "@onroad/core"
import { SequelizeConnectionManager } from "@onroad/core/database"
import { CorsFilter, JwtFilter, TenantFilter, RoleFilter } from "@onroad/core/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 "@onroad/core"
@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 "@onroad/core"
@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 "@onroad/core"
@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 "@onroad/core"
@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 "@onroad/core/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 "@onroad/core/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 "@onroad/core/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 "@onroad/core"
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 "@onroad/core"
// 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 "@onroad/core/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 "@onroad/core"
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 "@onroad/core/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 "@onroad/core" // Main
import { AbstractController, Sentinel } from "@onroad/core/core" // Core classes
import { Entity, Column, DataType } from "@onroad/core/entity" // Entity decorators
import { FilterChain, CorsFilter } from "@onroad/core/filters" // Filters
import { Roles, Public } from "@onroad/core/security" // Security
import { SequelizeConnectionManager } from "@onroad/core/database" // Database
import { HttpTransport, TransportFactory } from "@onroad/core/transport" // Transport
import { MatchingObject } from "@onroad/core/messaging" // Messaging
import { PinoLogger } from "@onroad/core/logging" // Logging
import { StorageProvider } from "@onroad/core/storage" // StorageKnown Pitfalls
Controller base path must not be empty
Always pass / or a non-empty path to @Controller(). An empty string is falsy and previously caused the route guard to silently drop all routes of that controller.
// ❌ Wrong — all routes silently skipped
@Controller("")
class UserController extends AbstractController<UserService> { ... }
// ✅ Correct — root-level routes
@Controller("/")
class UserController extends AbstractController<UserService> { ... }
// ✅ Correct — prefixed routes
@Controller("/users")
class UserController extends AbstractController<UserService> { ... }Since v4.0.0-alpha.1 the path guard was tightened (
basePath === undefined || basePath === null) and paths are normalized (//auth→/auth), so@Controller("/")+ route"/auth"resolves correctly to/authinstead of//auth.
Circular service dependencies
When two services reference each other (e.g. UserService ↔ CompanyService), eager instantiation inside configDependencies() causes a RangeError: Maximum call stack size exceeded at startup because the constructors recurse infinitely.
// ❌ Wrong — infinite recursion on startup
@Service()
class UserService extends AbstractService<UserRepository> {
companySVC!: CompanyService
configDependencies() {
this.companySVC = new CompanyService() // → CompanyService() → new UserService() → ...
}
}Fix: replace the eager field with a lazy getter. The getter body only runs on first access, never during construction, which breaks the cycle:
// ✅ Correct — lazy getter breaks the circular chain
@Service()
class UserService extends AbstractService<UserRepository> {
private _companySVC?: CompanyService
protected get companySVC(): CompanyService {
if (!this._companySVC) this._companySVC = new CompanyService()
return this._companySVC
}
}
@Service()
class CompanyService extends AbstractService<CompanyRepository> {
private _userSVC?: UserService
protected get userSVC(): UserService {
if (!this._userSVC) this._userSVC = new UserService()
return this._userSVC
}
}Why it was masked before: the
if (!basePath)bug meant controllers (and therefore services) were never instantiated duringbuildServer(). The circular dep crash only surfaced after fixing the route guard.
Overriding fullAssociation in Repositories
When using @onroad/core, the @Repository decorator automatically builds relations and injects the fullAssociation property populated with the appropriate Sequelize Models. Manually overriding this property in your repository class using raw ES classes will cause crashes during runtime (e.g., TypeError: include.model.getTableName is not a function) because Sequelize expects internal model objects, not raw classes.
// ❌ Wrong — overriding fullAssociation with raw ES classes crashes Sequelize
@Repository({ entity: User })
export class UserRepository extends BaseRepository<User> {
readonly fullAssociation = [{ model: FilterPref, as: "filters" }]
}Fix: Remove the hardcoded override. If you are extending a custom BaseRepository and need TypeScript to recognize the property without emitting an overriding value in the compiled JavaScript, use the declare modifier:
// ✅ Correct — Let the decorator inject the value and use `declare` for typings
export abstract class BaseRepository<TEntity = unknown> extends SequelizeRepository<TEntity> {
declare readonly fullAssociation: any[] // Tells TS it exists without overriding it
}
@Repository({ entity: User })
export class UserRepository extends BaseRepository<User> {
// No fullAssociation override here!
}License
UNLICENSED — HashCodeTI-Brasil