Stacks Events
Tiny (~200b) functional event emitter based on mitt, with Stacks-specific model events and action-based listener resolution.
Key Paths
- Core package:
storage/framework/core/events/src/index.ts(single file -- entire implementation) - Application events:
app/Events.ts - Listener setup:
app/Listener.ts - Listeners:
app/Listeners/ - Event types:
storage/framework/types/events.ts
Core mitt Implementation (index.ts)
The entire event system is a single mitt() factory function that returns an Emitter<Events> object:
export default function mitt<Events>(all?: EventHandlerMap<Events>): Emitter<Events>
Emitter Interface
interface Emitter<Events> {
all: EventHandlerMap<Events> // Map<keyof Events | '*', Handler[]>
on<Key>(type: Key, handler: Handler<Events[Key]>): void
on(type: '*', handler: WildcardHandler<Events>): void
off<Key>(type: Key, handler?: Handler<Events[Key]>): void
off(type: '*', handler?: WildcardHandler<Events>): void
emit<Key>(type: Key, event?: Events[Key]): void
}
How emit() Works
- Gets handlers array from
all.get(type)-- calls each withhandler(event) - Gets wildcard handlers from
all.get('*')-- calls each withhandler(type, event) - Handlers are called via
.slice()copy to avoid mutation during iteration - Each handler is wrapped in try-catch with
console.errorlogging - Error in one handler does NOT prevent other handlers from executing
- Both type-matched AND wildcard handlers run (wildcards run second)
- If
eventisundefined, handlers are NOT called
on() / off() Behavior
on(type, handler): pushes to handlers array (creates array if first handler)off(type, handler): splices handler from array by indexoff(type)(no handler): replaces handlers array with empty[]- Handler maps use
Map<string, Array<Handler>>internally
Stacks Event System Exports
The package creates a single mitt<StacksEvents>() instance and exports multiple aliases:
import { dispatch, listen, off, emitter, events, useEvent, useListen, useEvents, all } from '@stacksjs/events'
// Dispatch an event
dispatch('user:registered', { id: 1, email: 'user@example.com' })
// Listen for an event
listen('user:registered', (data) => {
console.log('New user:', data.email)
})
// Wildcard listener (catches ALL events)
listen('*', (type, data) => {
console.log(`Event ${type}:`, data)
})
// Remove a specific listener
off('user:registered', handler)
// Direct emitter access
emitter.on('event', handler)
emitter.off('event', handler)
emitter.emit('event', data)
emitter.all // Map of all handlers
Export Aliases
| Export | Maps To |
|---|---|
dispatch |
emitter.emit |
useEvent |
emitter.emit (alias for dispatch) |
listen |
emitter.on |
useListen |
emitter.on (alias for listen) |
off |
emitter.off |
emitter |
the mitt instance |
events |
the mitt instance (alias for emitter) |
useEvents |
the mitt instance (alias for emitter) |
all |
emitter.all (the handler Map) |
mitt |
the factory function itself |
Type Aliases
type Dispatch = <Key extends keyof StacksEvents>(type: Key, event: StacksEvents[Key]) => void
type Listen = <Key extends keyof StacksEvents>(type: Key, handler: Handler<StacksEvents[Key]>) => void
type Off = <Key extends keyof StacksEvents>(type: Key, handler?: Handler<StacksEvents[Key]>) => void
Built-in Event Types (StacksEvents)
// @stacksjs/events
interface AuthEvents {
'user:registered': UserRegisteredEvent
'user:logged-in': UserLoggedInEvent
'user:logged-out': UserLoggedOutEvent
'user:password-reset': UserPasswordEvent
'user:password-changed': UserPasswordEvent
}
// Augmentation target - model events land here, and so do yours.
interface AppEvents {}
type StacksEvents = AppEvents & AuthEvents
type EventName = keyof StacksEvents & string
There is no trailing index signature, deliberately: an arbitrary event name is
what made dispatch('user:creatd', …) compile and reach nobody. Declare an
application's own events on AppEvents and the typo becomes a compile error.
declare module '@stacksjs/events' {
interface AppEvents {
'invoice:settled': { id: number, total: number }
}
}
Model Events
Every model with the observe: true trait emits eight events:
| Event | When | Payload |
|---|---|---|
{model}:saving |
before any write | the model object |
{model}:creating / :updating / :deleting |
before that write | the model object |
{model}:created / :updated / :deleted |
after that write | the row |
{model}:saved |
after insert OR update | the row |
Model name is lowercased: 'user:created', 'post:updated', 'teammember:saved'.
A before listener can cancel the write by returning false:
listen('user:deleting', (model) => {
if (model.attributes.email.endsWith('@example.com'))
return false // the delete does not happen
})
Before-events carry the model object (.attributes holds the row); after-events
carry the row itself.
The payloads are typed, and nothing generates them
listen('user:created', user => user.emial) is a compile error - the payload is
the User row, with the columns your model declares.
storage/framework/types/model-events.d.ts derives the whole map from the models
barrel with a mapped type:
type ModelAfterEvents = {
[K in keyof Models & string as `${Lowercase<K>}:${AfterEvent}`]: ModelRow<Models[K]>
}
So a model existing IS its events existing - there is no generated list to keep in
agreement, and nothing to re-run after adding a model. (It replaced an 817-line
generated file, and before that a hand-maintained one that listed three events per
model and typed every payload Record<string, any>.)
Declare your own events by augmenting AppEvents:
declare module '@stacksjs/events' {
interface AppEvents {
'invoice:overdue': { id: number, daysLate: number }
}
}
Events are dispatched via lazy import('@stacksjs/events').then(({ dispatch }) => dispatch(...)) to avoid circular dependencies. If the import fails (e.g., browser context), errors are silently caught.
The observe trait can be:
true-- emits all three events (create, update, delete)['create', 'update']-- emits only specified eventsfalse/ undefined -- no events
There is no model list to keep here. Every model in storage/framework/auto-imports/models.ts
has its eight events, and that barrel is generated from disk for the runtime, so the
answer to "which models emit events" is "the ones that exist".
Event-to-Listener Mapping (app/Events.ts)
import { defineEvents } from '@stacksjs/events'
export default defineEvents({
'user:registered': ['SendWelcomeEmail'],
'user:created': ['NotifyUser'],
})
Both halves are checked. A key must be an event that exists (EventName, above);
a value must name a listener that is on disk (ListenerName, generated into
storage/framework/types/actions.d.ts from app/Listeners/, app/Actions/ and
the framework defaults behind them). satisfies Events is equivalent and still
supported; defineEvents is preferred because it also keeps the literal types,
so keyof typeof events is the two names the file declares rather than string.
Listener Resolution (app/Listener.ts)
handleEvents() delegates to registerAppListeners(), which registers both
conventions and is idempotent:
import { registerAppListeners } from '@stacksjs/events'
import { path as p } from '@stacksjs/path'
export async function handleEvents(): Promise<number> {
return registerAppListeners({ base: p.projectPath() })
}
Names in the map resolve against app/Listeners/, app/Actions/, then the same
two directories under the framework defaults - first match wins.
Registration Flow
- The map:
registerFromMap()importsapp/Events.tsand, for each name, resolves a module with ahandlemethod out ofapp/Listeners/,app/Actions/or the framework defaults, then subscribes it to that event directly. A name that resolves to nothing is warned about by name. - The scan:
discoverListeners()walksapp/Listeners/and registers any default export shaped{ listensTo, handle }.listensTomay be an array. - Dedup: every
(event, module)pair is claimed once per process, so a listener that appears in both conventions - or a dev server that re-runs boot - registers once rather than twice. - Payload check: if the resolved action declares
validations, a dispatched payload that does not match them is warned about. It is not thrown: the dispatcher has already committed the thing the event announces.
Where it is called from
injectGlobalAutoImports()(@stacksjs/server), so every path that dispatches - HTTP,buddy seed, a scheduled job, a console command - has listeners on the bushandleEvents()inapp/Listener.ts, the app's own override hook
Both on the same boot in dev. The claim registry is what keeps that from being two of every listener.
Implementation Details
Thread Safety
- handlers are stored in arrays --
emit()calls.slice()before iterating to safely handle additions/removals during iteration
Synchronous vs Asynchronous
emitis synchronous: it calls handlers directly and does not await them; an async handler's rejection is logged rather than lostemitAsync/dispatchAsyncawaits every matching handler and resolves with their results- Registration is asynchronous: resolving a listener name imports a module
Memory
- The emitter is one per process, keyed on
Symbol.for('stacks.events.emitter')rather than per copy of the package -- two installed copies would otherwise be two separate buses, and a dispatch into the wrong one looks exactly like a dispatch nobody listened for - Resolved listener modules are held by the closures registered on the emitter, so adding an action requires a restart
Gotchas
- Events are functional, not class-based -- no need to create event classes
- The emitter is a singleton -- shared across the entire application process
- Wildcard
'*'listeners receive(type, event)-- regular handlers receive just(event) - Listeners in
app/Events.tsare names (strings), not file paths or handler functions -- resolved againstapp/Listeners/,app/Actions/, then the framework defaults - The listener module must export a default with a
handle(event)method - Event dispatch is synchronous but listener resolution (dynamic import) happens once, at boot
- Model events only fire when the model has
observe: true(or array) trait set - The event system is ~200 bytes total -- it is intentionally minimal
- Listeners are resolved once at boot, so adding an action requires a server restart
- If
evtisundefined, handlers are NOT called (emitchecksif (evt !== undefined)) - The
'*'event type cannot be manually emitted -- it only receives forwarded events off(type)without a handler argument clears ALL handlers for that type (sets to empty array, not delete)StacksEventshas no index signature: an undeclared event name is a compile error, not a dispatch into the void. Declare your own onAppEvents- Error logging in mitt uses
console.error(not@stacksjs/logging) to avoid circular dependencies