Skip to main content

Events & Subscribers

Open Saasframe provides a comprehensive event/subscriber system with module auto-discovery, DI integration, and offline processing. The system supports two processing modes:

  1. Inline Events (persistent: false): Subscribers execute immediately when events are emitted, within the same request lifecycle
  2. Async Events (persistent: true): Events are queued and processed asynchronously by dedicated workers, providing reliability, retry capabilities, and scalability
When to use persistent events

Use persistent: true for events that:

  • Trigger long-running operations (sending emails, webhooks, external API calls)
  • Require guaranteed delivery even if the server restarts
  • Should not block the current request
  • Need retry logic on failure

For background job processing at scale, see the Queue & Workers documentation.

Overview

  • Subscribers live under packages/<pkg>/src/modules/<module>/subscribers/*.ts (or apps/saasframe/src/modules/<module>/subscribers/*.ts for app-level overrides) and export:
    • export const metadata = { event: string, persistent?: boolean, id?: string }
    • export default async function(payload, ctx) { /* ... */ }
      • ctx.resolve(name) resolves services from Awilix per-request container.
  • Subscribers discovered at build via yarn generate and registered into a global Event Bus via the core bootstrap (@saasframe/core/bootstrap), which your app calls from apps/saasframe/src/di.ts.
  • Emit events programmatically via eventBus.emitEvent(event, payload, { persistent? }).
  • Two strategies:
    • Local: online delivery + optional persistence to .events/queue.json with state in .events/state.json.
    • Redis: online delivery + persistence in Redis sorted set.
  • Offline processing: yarn saasframe events process [--limit=N] replays unprocessed persistent events.

File Structure

Example subscriber file packages/my-module/src/modules/my_module/subscribers/order-created.ts:

export const metadata = {
event: 'order.created',
persistent: true, // optional, default false
}

export default async function handle(payload: any, ctx: { resolve: <T=any>(name: string) => T }) {
const em = ctx.resolve('em')
// ... do something with payload using DI services
}

IDs are optional; default is "<module>:<nested_path>".

Emitting Events

From any handler with DI access:

const bus = container.resolve('eventBus')
await bus.emitEvent('order.created', { id: 123, total: 42 }, { persistent: true })

Programmatic Registration

Modules can register subscribers in di.ts:

import type { AppContainer } from '@/lib/di/container'

export function register(container: AppContainer) {
const bus = container.resolve<any>('eventBus')
bus.on('custom.event', async (payload, ctx) => {
const em = ctx.resolve('em')
// ...
})
}

Strategy & Persistence

  • Select strategy via EVENTS_STRATEGY=local|redis (default local).
  • Redis URL taken from REDIS_URL or EVENTS_REDIS_URL.
  • Persistent events are recorded and can be replayed later.
    • Local: .events/queue.json and .events/state.json in project root.
    • Redis: keys events:last_id, events:queue (sorted set), events:last_processed_id.

Processing Persistent Events

Persistent events are processed by running a dedicated queue worker:

# Start a worker to process events from the queue
yarn saasframe queue worker events

# With custom concurrency (default is 1)
yarn saasframe queue worker events --concurrency=5

The worker connects to Redis (or uses local storage) and continuously processes queued events. It uses the DI container, so subscriber handlers can resolve services.

Queue Management

# Check queue status (waiting, active, completed, failed counts)
yarn saasframe queue status events

# Clear all events from the queue
yarn saasframe queue clear events

Emit via CLI

Quickly emit an event from the terminal (useful for testing subscribers or seeding flows):

yarn saasframe events emit <event> [jsonPayload] [--persistent|-p]

Examples:

# Simple event without payload (non-persistent)
yarn saasframe events emit example.event

# Emit with JSON payload (remember to quote it)
yarn saasframe events emit order.created '{"id":123,"total":42.5}'

# Emit a persistent event so it is queued for async processing
yarn saasframe events emit order.created '{"id":124}' --persistent

# Shorthand for persistent flag
yarn saasframe events emit order.created '{"id":125}' -p

Notes:

  • Payload is parsed as JSON when possible; otherwise treated as a string.
  • Persistent events are delivered inline and also queued for async processing.
  • The CLI uses the DI container, so subscribers can resolve services via ctx.resolve.

Notes

  • Subscribers are executed online on emitEvent, and also available for offline replay when persistent.
  • Input validation and security remain the responsibility of the emitting producer/consumer code.

CRUD Events

The CRUD factory emits standard events for module entities:

  • <module>.<entity>.created
  • <module>.<entity>.updated
  • <module>.<entity>.deleted

Use these to react to lifecycle changes without tightly coupling modules. Mark them persistent to support offline replay.

Application Lifecycle Events

The app runtime also emits framework-level lifecycle events that modules can subscribe to:

  • application.bootstrap.started
  • application.bootstrap.completed
  • application.bootstrap.failed
  • application.request.received
  • application.request.auth_resolved
  • application.request.authorization_denied
  • application.request.rate_limited
  • application.request.not_found
  • application.request.completed
  • application.request.failed

Typical payload fields:

  • requestId: Correlation ID for a single request flow.
  • method: HTTP method.
  • pathname: Requested API pathname.
  • status: HTTP status when available.
  • userId: Current authenticated user ID (or null).
  • tenantId: Current tenant ID (or null).
  • durationMs: Processing time in milliseconds.
  • errorMessage: Failure message on error paths.
  • source: Bootstrap event source (for example apps/saasframe).
  • emittedAt: ISO timestamp for bootstrap emissions.

Example subscriber:

export const metadata = {
event: 'application.request.failed',
persistent: false,
}

export default async function onRequestFailed(payload: any, ctx: { resolve: <T = any>(name: string) => T }) {
const logger = ctx.resolve<any>('logger')
logger?.error?.('Request failed', payload)
}