JSPM

  • Created
  • Published
  • Downloads 3122
  • Score
    100M100P100Q110102F
  • License UNLICENSED

TypeScript backend framework — DI Container, Filter Chain, EventBus, Provider Pattern

Package Exports

  • @onroad/core
  • @onroad/core/core
  • @onroad/core/database
  • @onroad/core/entity
  • @onroad/core/filters
  • @onroad/core/logging
  • @onroad/core/messaging
  • @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.0 — Full TypeScript rewrite of the onRoad framework.


Table of Contents


Installation

npm install @onroad/core

Peer dependencies (install as needed):

npm install express reflect-metadata pino uuid
# Optional — only if using HttpTransport:
npm install axios

TypeRoad is ESM-only ("type": "module"). Your tsconfig.json must 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 @Roles overrides class-level (not additive).
  • @Public() sets roles to [] (empty array = no restriction).
  • getRoles(instance, methodName) returns undefined if 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 ready
  • false = provider registered but not ready
  • null = 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 augmentation

Subpath 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"                 // Storage

License

UNLICENSED — HashCodeTI-Brasil