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:
- Inline Events (
persistent: false): Subscribers execute immediately when events are emitted, within the same request lifecycle - Async Events (
persistent: true): Events are queued and processed asynchronously by dedicated workers, providing reliability, retry capabilities, and scalability
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(orapps/saasframe/src/modules/<module>/subscribers/*.tsfor 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 generateand registered into a global Event Bus via the core bootstrap (@saasframe/core/bootstrap), which your app calls fromapps/saasframe/src/di.ts. - Emit events programmatically via
eventBus.emitEvent(event, payload, { persistent? }). - Two strategies:
- Local: online delivery + optional persistence to
.events/queue.jsonwith state in.events/state.json. - Redis: online delivery + persistence in Redis sorted set.
- Local: online delivery + optional persistence to
- 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(defaultlocal). - Redis URL taken from
REDIS_URLorEVENTS_REDIS_URL. - Persistent events are recorded and can be replayed later.
- Local:
.events/queue.jsonand.events/state.jsonin project root. - Redis: keys
events:last_id,events:queue(sorted set),events:last_processed_id.
- Local:
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.startedapplication.bootstrap.completedapplication.bootstrap.failedapplication.request.receivedapplication.request.auth_resolvedapplication.request.authorization_deniedapplication.request.rate_limitedapplication.request.not_foundapplication.request.completedapplication.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 (ornull).tenantId: Current tenant ID (ornull).durationMs: Processing time in milliseconds.errorMessage: Failure message on error paths.source: Bootstrap event source (for exampleapps/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)
}