@stacksjs/events 0.70.53 → 0.70.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/events",
3
3
  "type": "module",
4
- "version": "0.70.53",
4
+ "version": "0.70.55",
5
5
  "description": "Functional event emitting.",
6
6
  "author": "Chris Breuer",
7
7
  "contributors": [
@@ -40,7 +40,8 @@
40
40
  "types": "dist/index.d.ts",
41
41
  "files": [
42
42
  "README.md",
43
- "dist"
43
+ "dist",
44
+ "src"
44
45
  ],
45
46
  "scripts": {
46
47
  "build": "bun build.ts",
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Boot-time listener auto-discovery (stacksjs/stacks#1878 E-3,
3
+ * carrying forward F-3 from #1874).
4
+ *
5
+ * Background: events package exports a singleton emitter (`listen`,
6
+ * `dispatch`, etc.), but there's no convention-over-configuration
7
+ * path that scans `app/Listeners/**\/*.ts` and wires up every
8
+ * listener at boot. Apps that follow the standard Stacks layout
9
+ * have to manually `import` every listener file from somewhere or
10
+ * their listeners silently never fire.
11
+ *
12
+ * This module adds `discoverListeners(dir)` that walks a directory
13
+ * (default `app/Listeners`), imports each `.ts` / `.js` file, and
14
+ * registers the default export as a listener if it matches a
15
+ * documented shape:
16
+ *
17
+ * ```ts
18
+ * // app/Listeners/SendWelcomeEmail.ts
19
+ * export default {
20
+ * listensTo: 'user:registered',
21
+ * handle: async (event) => {
22
+ * await mail.send({ to: event.user.email, ... })
23
+ * },
24
+ * // optional: 'high' | 'normal' | 'low' — higher runs first
25
+ * priority: 'normal',
26
+ * }
27
+ * ```
28
+ *
29
+ * Errors during import (syntax errors, missing default export,
30
+ * malformed listener shape) are logged but don't halt discovery —
31
+ * one broken listener shouldn't prevent others from registering.
32
+ */
33
+
34
+ import { existsSync, readdirSync, statSync } from 'node:fs'
35
+ import { extname, join } from 'node:path'
36
+ import process from 'node:process'
37
+ import { listen } from './index'
38
+ import type { Handler } from './index'
39
+
40
+ /**
41
+ * Shape of a listener module's default export. The `listensTo`
42
+ * field is the event name (matches the strict type of
43
+ * `StacksEvents` keys via `unknown` for cross-pkg flexibility);
44
+ * `handle` is the actual listener function.
45
+ */
46
+ export interface ListenerModule<T = unknown> {
47
+ /** Event name to subscribe to. Required. */
48
+ listensTo: string
49
+ /** Listener function. Required. */
50
+ handle: Handler<T>
51
+ /** Optional human-readable name for logging. Defaults to filename. */
52
+ name?: string
53
+ }
54
+
55
+ interface DiscoverOptions {
56
+ /**
57
+ * Absolute path to the listeners directory. Defaults to
58
+ * `<cwd>/app/Listeners`.
59
+ */
60
+ dir?: string
61
+ /**
62
+ * File extensions to import. Defaults to `['.ts', '.js']`.
63
+ */
64
+ extensions?: string[]
65
+ /**
66
+ * Custom logger. Defaults to `console.warn` / `console.error`
67
+ * for visibility without adding a logging dependency.
68
+ */
69
+ log?: {
70
+ warn?: (msg: string) => void
71
+ error?: (msg: string) => void
72
+ info?: (msg: string) => void
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Walk the listeners directory and register every default-exported
78
+ * listener that matches the `ListenerModule` shape. Returns the
79
+ * count of successfully registered listeners.
80
+ *
81
+ * @example
82
+ * ```ts
83
+ * // In your framework boot path:
84
+ * import { discoverListeners } from '@stacksjs/events'
85
+ *
86
+ * await discoverListeners() // defaults to app/Listeners
87
+ * // or
88
+ * await discoverListeners({ dir: '/custom/path' })
89
+ * ```
90
+ */
91
+ export async function discoverListeners(options: DiscoverOptions = {}): Promise<number> {
92
+ const dir = options.dir ?? join(process.cwd(), 'app', 'Listeners')
93
+ const extensions = options.extensions ?? ['.ts', '.js']
94
+ const logger = {
95
+ warn: options.log?.warn ?? ((msg: string) => console.warn(msg)),
96
+ error: options.log?.error ?? ((msg: string) => console.error(msg)),
97
+ info: options.log?.info ?? ((msg: string) => console.info(msg)),
98
+ }
99
+
100
+ if (!existsSync(dir)) {
101
+ // Not an error — many projects don't have a listeners directory.
102
+ // Stay silent so boot logs don't fill with "no listeners found"
103
+ // for every CLI command.
104
+ return 0
105
+ }
106
+
107
+ const files = collectFiles(dir, extensions)
108
+ if (files.length === 0) return 0
109
+
110
+ let registered = 0
111
+ for (const filepath of files) {
112
+ try {
113
+ const mod = await import(filepath)
114
+ const exported = mod?.default ?? mod
115
+ if (!isListenerModule(exported)) {
116
+ logger.warn(`[events/discover] ${filepath}: default export doesn't match ListenerModule shape ({ listensTo, handle }), skipping`)
117
+ continue
118
+ }
119
+ listen(exported.listensTo as never, exported.handle as never)
120
+ registered++
121
+ }
122
+ catch (err) {
123
+ logger.error(`[events/discover] failed to import ${filepath}: ${err instanceof Error ? err.message : String(err)}`)
124
+ }
125
+ }
126
+
127
+ if (registered > 0)
128
+ logger.info(`[events/discover] registered ${registered} listener${registered === 1 ? '' : 's'} from ${dir}`)
129
+
130
+ return registered
131
+ }
132
+
133
+ /**
134
+ * Recursively collect every file under `dir` with an allowed
135
+ * extension. Symlinks are followed via `fs.statSync` which throws
136
+ * on broken links — those propagate to the caller, who can decide
137
+ * whether to retry or fail.
138
+ */
139
+ function collectFiles(dir: string, extensions: string[]): string[] {
140
+ const out: string[] = []
141
+ const entries = readdirSync(dir)
142
+ for (const name of entries) {
143
+ const full = join(dir, name)
144
+ const st = statSync(full)
145
+ if (st.isDirectory()) {
146
+ out.push(...collectFiles(full, extensions))
147
+ }
148
+ else if (st.isFile() && extensions.includes(extname(name))) {
149
+ out.push(full)
150
+ }
151
+ }
152
+ return out
153
+ }
154
+
155
+ function isListenerModule(v: unknown): v is ListenerModule {
156
+ return (
157
+ !!v
158
+ && typeof v === 'object'
159
+ && typeof (v as { listensTo?: unknown }).listensTo === 'string'
160
+ && typeof (v as { handle?: unknown }).handle === 'function'
161
+ )
162
+ }
package/src/index.ts ADDED
@@ -0,0 +1,520 @@
1
+ /**
2
+ * Stacks event engine — a native, type-safe, async-aware pub/sub.
3
+ *
4
+ * Originally adapted from `mitt`; rewritten in-house to:
5
+ * - Surface async handler errors on the same channel as sync ones
6
+ * (mitt swallowed unhandled rejections)
7
+ * - Support glob patterns (`user:*`, `*.created`) alongside `'*'` wildcard
8
+ * - Add `once`, `removeAllListeners`, `listenerCount` for parity with
9
+ * Node's EventEmitter ergonomics
10
+ * - Add `dispatchAsync` that AWAITS handlers and returns their results,
11
+ * so callers can express "fire this event AND wait until every
12
+ * listener finishes" (booking:cancelled → wait for refund + email
13
+ * before responding to the user)
14
+ *
15
+ * The legacy `mitt` export is preserved for backward compat — calling it
16
+ * gets you the same emitter you'd get from `createEmitter()`.
17
+ */
18
+
19
+ import type { ModelEvents } from '@stacksjs/types'
20
+
21
+ export type EventType = string | symbol
22
+
23
+ export type Handler<T = unknown> = (_event: T) => void | Promise<void>
24
+ export type WildcardHandler<T = Record<string, unknown>> = (_type: keyof T, _event: T[keyof T]) => void | Promise<void>
25
+
26
+ export type EventHandlerList<T = unknown> = Array<Handler<T>>
27
+ export type WildCardEventHandlerList<T = Record<string, unknown>> = Array<WildcardHandler<T>>
28
+
29
+ export type EventHandlerMap<Events extends Record<EventType, unknown>> = Map<
30
+ keyof Events | '*',
31
+ EventHandlerList<Events[keyof Events]> | WildCardEventHandlerList<Events>
32
+ >
33
+
34
+ export interface Emitter<Events extends Record<EventType, unknown>> {
35
+ /** Underlying handler map. Mutating it directly is supported but rarely needed. */
36
+ all: EventHandlerMap<Events>
37
+
38
+ /**
39
+ * Register a handler for `type` (or `'*'` for every event, or a
40
+ * glob like `'user:*'`). Optional `{ priority }` controls dispatch
41
+ * order — higher runs first, default 0
42
+ * (stacksjs/stacks#1878 E-2).
43
+ */
44
+ on: (<Key extends keyof Events>(_type: Key, _handler: Handler<Events[Key]>, _options?: { priority?: number }) => void) &
45
+ ((_type: '*', _handler: WildcardHandler<Events>, _options?: { priority?: number }) => void) &
46
+ ((_type: string, _handler: WildcardHandler<Events>, _options?: { priority?: number }) => void)
47
+
48
+ /** Register a handler that auto-removes after the first invocation. */
49
+ once: (<Key extends keyof Events>(_type: Key, _handler: Handler<Events[Key]>) => void) &
50
+ ((_type: '*', _handler: WildcardHandler<Events>) => void)
51
+
52
+ /** Remove a single handler, or every handler for a type when handler is omitted. */
53
+ off: (<Key extends keyof Events>(_type: Key, _handler?: Handler<Events[Key]>) => void) &
54
+ ((_type: '*', _handler?: WildcardHandler<Events>) => void) &
55
+ ((_type: string, _handler?: WildcardHandler<Events>) => void)
56
+
57
+ /** Fire-and-forget. Async handler errors are logged but never propagated. */
58
+ emit: (<Key extends keyof Events>(_type: Key, _event: Events[Key]) => void) &
59
+ (<Key extends keyof Events>(_type: undefined extends Events[Key] ? Key : never) => void)
60
+
61
+ /**
62
+ * Awaitable dispatch — resolves once every matching handler (exact +
63
+ * pattern + wildcard) has finished. Use when downstream work has to
64
+ * complete before the caller continues (e.g. a booking cancel that
65
+ * must persist + refund + notify before returning a 200).
66
+ *
67
+ * Errors are LOGGED but swallowed into the results array as
68
+ * `undefined`. Use `emitAndCollect` when you need to inspect them.
69
+ */
70
+ emitAsync: <Key extends keyof Events>(_type: Key, _event: Events[Key]) => Promise<unknown[]>
71
+
72
+ /**
73
+ * Like `emitAsync` but returns per-handler `Result<T, Error>` so
74
+ * callers can inspect partial failures. Use when downstream work
75
+ * must be observable — e.g. fan-out where some sinks might fail
76
+ * but the call shouldn't throw (stacksjs/stacks#1878 E-1).
77
+ */
78
+ emitAndCollect: <Key extends keyof Events>(_type: Key, _event: Events[Key]) => Promise<Array<{ ok: true, value: unknown } | { ok: false, error: Error }>>
79
+
80
+ /** Drop every handler for a type (or every handler everywhere when omitted). */
81
+ removeAllListeners: (_type?: keyof Events | '*') => void
82
+
83
+ /** How many handlers are registered for a type — exact match only, no patterns. */
84
+ listenerCount: (_type: keyof Events | '*') => number
85
+ }
86
+
87
+ const ASYNC_HANDLER_TAG = Symbol.for('stacks.events.handler.error')
88
+ /**
89
+ * Symbol attached to handlers registered with an explicit priority
90
+ * (stacksjs/stacks#1878 E-2). Listeners with higher priorities run
91
+ * before lower ones; default priority is 0. The tag-on-function
92
+ * approach keeps the existing `off()` identity comparisons working
93
+ * — `handler === otherHandler` still holds, the priority is just
94
+ * an extra annotation.
95
+ */
96
+ const HANDLER_PRIORITY = Symbol.for('stacks.events.handler.priority')
97
+
98
+ /**
99
+ * Read a handler's priority. Defaults to 0 for handlers registered
100
+ * without an explicit priority (the pre-fix behavior).
101
+ */
102
+ function priorityOf(handler: unknown): number {
103
+ if (handler && typeof handler === 'object' || typeof handler === 'function') {
104
+ const p = (handler as Record<symbol, unknown>)[HANDLER_PRIORITY]
105
+ return typeof p === 'number' && Number.isFinite(p) ? p : 0
106
+ }
107
+ return 0
108
+ }
109
+
110
+ /**
111
+ * Sort a handler array by priority descending (higher runs first).
112
+ * Stable so handlers with the same priority preserve insertion order
113
+ * — matters for the "audit log fires after the change but before
114
+ * analytics" case where ordering within a priority bucket is
115
+ * load-bearing.
116
+ */
117
+ function sortByPriority<T>(handlers: T[]): T[] {
118
+ // Decorate-sort-undecorate keeps the sort stable (Array.sort is
119
+ // stable in modern engines but the decorate avoids relying on it).
120
+ return handlers
121
+ .map((h, i) => ({ h, i, p: priorityOf(h) }))
122
+ .sort((a, b) => b.p - a.p || a.i - b.i)
123
+ .map(x => x.h)
124
+ }
125
+
126
+ function logAsyncError(label: string, type: EventType, err: unknown) {
127
+ // eslint-disable-next-line no-console
128
+ console.error(`[Events] ${label} for '${String(type)}':`, err)
129
+ }
130
+
131
+ function isPromiseLike(v: unknown): v is Promise<unknown> {
132
+ return !!v && typeof (v as Promise<unknown>).catch === 'function'
133
+ }
134
+
135
+ /**
136
+ * Create a fresh Stacks event emitter. Most consumers want the singleton
137
+ * exported below — call this directly only when you need an isolated bus
138
+ * (tests, child workers, plugin sandboxes).
139
+ */
140
+ // eslint-disable-next-line pickier/no-unused-vars
141
+ export function createEmitter<Events extends Record<EventType, unknown>>(
142
+ all?: EventHandlerMap<Events>,
143
+ ): Emitter<Events> {
144
+ const map = all ?? new Map<keyof Events | '*', any>()
145
+
146
+ // Match a glob-pattern key (`user:*`, `*.created`) against a concrete event
147
+ // type. Compiled regexes are cached on the key string so the hot path
148
+ // doesn't recompile on every emit.
149
+ const patternCache = new Map<string, RegExp>()
150
+ const matchPattern = (key: string, type: string): boolean => {
151
+ let re = patternCache.get(key)
152
+ if (!re) {
153
+ // Preserve mitt's loose semantics: `*` is a glob (any chars) and
154
+ // every other char is treated as a literal regex char. We do NOT
155
+ // escape `.` because users in the wild rely on patterns like
156
+ // `*.created` matching `user:created` / `post-created` / etc.
157
+ // (treating `.` as "any char", not literal dot).
158
+ re = new RegExp(`^${key.replace(/\*/g, '.*')}$`)
159
+ patternCache.set(key, re)
160
+ }
161
+ return re.test(type)
162
+ }
163
+
164
+ function on(type: any, handler: any, options?: { priority?: number }) {
165
+ // Stamp priority on the handler (stacksjs/stacks#1878 E-2). The
166
+ // sort happens at emit-time, not register-time, so two handlers
167
+ // registered with different priorities still keep their original
168
+ // identity for `off()` comparisons.
169
+ if (options?.priority !== undefined && Number.isFinite(options.priority))
170
+ (handler as Record<symbol, number>)[HANDLER_PRIORITY] = options.priority
171
+ const list = map.get(type)
172
+ if (list) list.push(handler)
173
+ else map.set(type, [handler])
174
+ }
175
+
176
+ function once(type: any, handler: any) {
177
+ const wrapped: any = (...args: any[]) => {
178
+ off(type, wrapped)
179
+ return handler(...args)
180
+ }
181
+ // Tag so off-by-original-handler still works when caller stashed the
182
+ // original reference. Maintain a back-pointer for lookup.
183
+ wrapped[ASYNC_HANDLER_TAG] = handler
184
+ on(type, wrapped)
185
+ }
186
+
187
+ function off(type: any, handler?: any) {
188
+ const list = map.get(type)
189
+ if (!list) return
190
+ if (!handler) {
191
+ map.set(type, [])
192
+ return
193
+ }
194
+ for (let i = list.length - 1; i >= 0; i--) {
195
+ const h = list[i] as any
196
+ if (h === handler || h?.[ASYNC_HANDLER_TAG] === handler) list.splice(i, 1)
197
+ }
198
+ }
199
+
200
+ function removeAllListeners(type?: keyof Events | '*') {
201
+ if (type === undefined) map.clear()
202
+ else map.delete(type)
203
+ }
204
+
205
+ function listenerCount(type: keyof Events | '*'): number {
206
+ return map.get(type)?.length ?? 0
207
+ }
208
+
209
+ function emit(type: any, evt?: any) {
210
+ // Snapshot the relevant handler arrays so a handler that mutates the
211
+ // map (e.g. via `once` removal) doesn't trip iteration. Sort by
212
+ // priority (stacksjs/stacks#1878 E-2) — higher runs first; same
213
+ // priority preserves insertion order via stable sort.
214
+ const exactRaw = (map.get(type) as Handler<any>[] | undefined)?.slice()
215
+ const wildcardRaw = (map.get('*') as WildcardHandler<any>[] | undefined)?.slice()
216
+ const exactHandlers = exactRaw ? sortByPriority(exactRaw) : undefined
217
+ const wildcardHandlers = wildcardRaw ? sortByPriority(wildcardRaw) : undefined
218
+
219
+ if (exactHandlers) {
220
+ for (const handler of exactHandlers) {
221
+ try {
222
+ // No undefined-skip — events with no payload (e.g. signals like
223
+ // `ping`, `ready`) are first-class. The previous `if (evt !==
224
+ // undefined)` guard inherited from mitt silently dropped those.
225
+ const result = handler(evt)
226
+ if (isPromiseLike(result))
227
+ result.catch(err => logAsyncError(`Async handler error`, type, err))
228
+ }
229
+ catch (err) {
230
+ logAsyncError(`Handler error`, type, err)
231
+ }
232
+ }
233
+ }
234
+
235
+ // Pattern match: 'user:*', '*.created', etc. Skip exact + literal '*'
236
+ // (handled separately below) so we don't double-fire. Pattern
237
+ // handlers are also priority-sorted (#1878 E-2).
238
+ const typeStr = String(type)
239
+ map.forEach((patternHandlers, key) => {
240
+ const keyStr = String(key)
241
+ if (keyStr === typeStr || keyStr === '*' || !keyStr.includes('*')) return
242
+ if (matchPattern(keyStr, typeStr)) {
243
+ for (const handler of sortByPriority((patternHandlers as WildcardHandler<any>[]).slice())) {
244
+ try {
245
+ const result = handler(type, evt)
246
+ if (isPromiseLike(result))
247
+ result.catch(err => logAsyncError(`Async pattern handler '${keyStr}' error`, type, err))
248
+ }
249
+ catch (err) {
250
+ logAsyncError(`Pattern handler '${keyStr}' error`, type, err)
251
+ }
252
+ }
253
+ }
254
+ })
255
+
256
+ if (wildcardHandlers) {
257
+ for (const handler of wildcardHandlers) {
258
+ try {
259
+ const result = handler(type, evt)
260
+ if (isPromiseLike(result))
261
+ result.catch(err => logAsyncError(`Async wildcard handler error`, type, err))
262
+ }
263
+ catch (err) {
264
+ logAsyncError(`Wildcard handler error`, type, err)
265
+ }
266
+ }
267
+ }
268
+ }
269
+
270
+ async function emitAsync(type: any, evt?: any): Promise<unknown[]> {
271
+ const results: unknown[] = []
272
+
273
+ const runAll = async (handlers: Handler<any>[] | WildcardHandler<any>[] | undefined, isWildcard: boolean) => {
274
+ if (!handlers) return
275
+ for (const handler of sortByPriority(handlers.slice())) {
276
+ try {
277
+ const result = isWildcard ? (handler as WildcardHandler<any>)(type, evt) : (handler as Handler<any>)(evt)
278
+ results.push(isPromiseLike(result) ? await result : result)
279
+ }
280
+ catch (err) {
281
+ logAsyncError(`Awaited handler error`, type, err)
282
+ results.push(undefined)
283
+ }
284
+ }
285
+ }
286
+
287
+ await runAll(map.get(type) as Handler<any>[] | undefined, false)
288
+
289
+ const typeStr = String(type)
290
+ const patternKeys: string[] = []
291
+ map.forEach((_, key) => {
292
+ const keyStr = String(key)
293
+ if (keyStr === typeStr || keyStr === '*' || !keyStr.includes('*')) return
294
+ if (matchPattern(keyStr, typeStr)) patternKeys.push(keyStr)
295
+ })
296
+ for (const key of patternKeys)
297
+ await runAll(map.get(key) as WildcardHandler<any>[] | undefined, true)
298
+
299
+ await runAll(map.get('*') as WildcardHandler<any>[] | undefined, true)
300
+
301
+ return results
302
+ }
303
+
304
+ /**
305
+ * Variant of `emitAsync` that returns per-handler `Result<T, Error>`
306
+ * so callers can inspect partial failures
307
+ * (stacksjs/stacks#1878 E-1). Pre-fix the only "I want to know what
308
+ * happened" emit path was `emitAsync` which swallowed failures into
309
+ * `undefined` — callers couldn't tell a returned `undefined` from
310
+ * an error.
311
+ *
312
+ * @example
313
+ * ```ts
314
+ * const results = await emitAndCollect('booking:cancelled', payload)
315
+ * const failed = results.filter(r => !r.ok)
316
+ * if (failed.length > 0) alertSlack({ payload, failed })
317
+ * ```
318
+ */
319
+ async function emitAndCollect(type: any, evt?: any): Promise<Array<{ ok: true, value: unknown } | { ok: false, error: Error }>> {
320
+ const results: Array<{ ok: true, value: unknown } | { ok: false, error: Error }> = []
321
+
322
+ const runAll = async (handlers: Handler<any>[] | WildcardHandler<any>[] | undefined, isWildcard: boolean) => {
323
+ if (!handlers) return
324
+ for (const handler of sortByPriority(handlers.slice())) {
325
+ try {
326
+ const result = isWildcard ? (handler as WildcardHandler<any>)(type, evt) : (handler as Handler<any>)(evt)
327
+ const value = isPromiseLike(result) ? await result : result
328
+ results.push({ ok: true, value })
329
+ }
330
+ catch (err) {
331
+ const error = err instanceof Error ? err : new Error(String(err))
332
+ results.push({ ok: false, error })
333
+ }
334
+ }
335
+ }
336
+
337
+ await runAll(map.get(type) as Handler<any>[] | undefined, false)
338
+
339
+ const typeStr = String(type)
340
+ const patternKeys: string[] = []
341
+ map.forEach((_, key) => {
342
+ const keyStr = String(key)
343
+ if (keyStr === typeStr || keyStr === '*' || !keyStr.includes('*')) return
344
+ if (matchPattern(keyStr, typeStr)) patternKeys.push(keyStr)
345
+ })
346
+ for (const key of patternKeys)
347
+ await runAll(map.get(key) as WildcardHandler<any>[] | undefined, true)
348
+
349
+ await runAll(map.get('*') as WildcardHandler<any>[] | undefined, true)
350
+
351
+ return results
352
+ }
353
+
354
+ return { all: map, on, once, off, emit, emitAsync, emitAndCollect, removeAllListeners, listenerCount } as Emitter<Events>
355
+ }
356
+
357
+ /**
358
+ * Backward-compatible alias for the legacy `mitt()` export. Behaves
359
+ * identically to {@link createEmitter}.
360
+ */
361
+ export const mitt = createEmitter
362
+
363
+ // Default export keeps `import mitt from '@stacksjs/events'` shape working.
364
+ export default createEmitter
365
+
366
+ /**
367
+ * Build a scoped wrapper around an emitter (stacksjs/stacks#1878 E-5).
368
+ * Every dispatch and listen call is prefixed with `${prefix}:` so
369
+ * different tenants / plugins / subsystems can share the same
370
+ * underlying bus without colliding on event names.
371
+ *
372
+ * Listeners registered through the scoped wrapper only receive events
373
+ * dispatched through the SAME wrapper — they don't see unprefixed
374
+ * events on the underlying bus. Apps that need to subscribe across
375
+ * scopes use the underlying emitter directly with a glob pattern.
376
+ *
377
+ * @example
378
+ * ```ts
379
+ * import { events, scope } from '@stacksjs/events'
380
+ *
381
+ * const tenantA = scope(events, 'tenant:42')
382
+ * tenantA.on('user:created', user => sendWelcome(user))
383
+ * tenantA.emit('user:created', { id: 1 })
384
+ * // ↑ fires the listener; on the underlying bus the event is
385
+ * // emitted as 'tenant:42:user:created'.
386
+ *
387
+ * // Listener on the raw bus DOES see the prefixed form:
388
+ * events.on('tenant:42:user:created', auditTrail)
389
+ * // Listener on the raw bus DOES NOT see the bare 'user:created' —
390
+ * // the prefix is mandatory.
391
+ * ```
392
+ */
393
+ export function scope<Events extends Record<EventType, unknown>>(
394
+ underlying: Emitter<Events>,
395
+ prefix: string,
396
+ ): {
397
+ on: (type: string, handler: Handler<unknown>, options?: { priority?: number }) => void
398
+ once: (type: string, handler: Handler<unknown>) => void
399
+ off: (type: string, handler?: Handler<unknown>) => void
400
+ emit: (type: string, event: unknown) => void
401
+ emitAsync: (type: string, event: unknown) => Promise<unknown[]>
402
+ emitAndCollect: (type: string, event: unknown) => Promise<Array<{ ok: true, value: unknown } | { ok: false, error: Error }>>
403
+ listenerCount: (type: string) => number
404
+ } {
405
+ const scopedType = (type: string): string => `${prefix}:${type}`
406
+ return {
407
+ on(type, handler, options) {
408
+ ;(underlying.on as (t: string, h: any, o?: any) => void)(scopedType(type), handler, options)
409
+ },
410
+ once(type, handler) {
411
+ ;(underlying.once as (t: string, h: any) => void)(scopedType(type), handler)
412
+ },
413
+ off(type, handler) {
414
+ ;(underlying.off as (t: string, h?: any) => void)(scopedType(type), handler)
415
+ },
416
+ emit(type, event) {
417
+ ;(underlying.emit as (t: string, e?: any) => void)(scopedType(type), event)
418
+ },
419
+ emitAsync(type, event) {
420
+ return (underlying.emitAsync as (t: string, e?: any) => Promise<unknown[]>)(scopedType(type), event)
421
+ },
422
+ emitAndCollect(type, event) {
423
+ return (underlying.emitAndCollect as (t: string, e?: any) => Promise<any>)(scopedType(type), event)
424
+ },
425
+ listenerCount(type) {
426
+ return (underlying.listenerCount as (t: string) => number)(scopedType(type))
427
+ },
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Concrete payload shape for the auth-related events. Keeping these
433
+ * narrow (instead of `Record<string, any>`) means listeners don't need to
434
+ * cast or guess what fields are present — the handler signature reflects
435
+ * what RegisterAction / LoginAction actually dispatch.
436
+ */
437
+ export interface UserRegisteredEvent {
438
+ id?: number | string
439
+ email: string
440
+ name?: string
441
+ /** Convenience alias of `email` for SendWelcomeEmail-style listeners. */
442
+ to?: string
443
+ }
444
+
445
+ export interface UserLoggedInEvent {
446
+ id: number | string
447
+ email: string
448
+ }
449
+
450
+ export interface UserLoggedOutEvent {
451
+ id: number | string
452
+ }
453
+
454
+ export interface UserPasswordEvent {
455
+ id: number | string
456
+ email: string
457
+ }
458
+
459
+ /**
460
+ * Application-wide event types. Listeners and dispatchers below are
461
+ * pre-typed to this map; user-defined event names land here via
462
+ * `ModelEvents` (model-emitted events) + the explicit auth events listed.
463
+ */
464
+ export interface StacksEvents extends ModelEvents, Record<EventType, unknown> {
465
+ 'user:registered': UserRegisteredEvent
466
+ 'user:logged-in': UserLoggedInEvent
467
+ 'user:logged-out': UserLoggedOutEvent
468
+ 'user:password-reset': UserPasswordEvent
469
+ 'user:password-changed': UserPasswordEvent
470
+ }
471
+
472
+ const events: Emitter<StacksEvents> = createEmitter<StacksEvents>()
473
+
474
+ type Dispatch = <Key extends keyof StacksEvents>(_type: Key, _event: StacksEvents[Key]) => void
475
+ // eslint-disable-next-line pickier/no-unused-vars
476
+ type Listen = <Key extends keyof StacksEvents>(_type: Key, _handler: Handler<StacksEvents[Key]>, _options?: { priority?: number }) => void
477
+ // eslint-disable-next-line pickier/no-unused-vars
478
+ type Off = <Key extends keyof StacksEvents>(_type: Key, handler?: Handler<StacksEvents[Key]>) => void
479
+ type DispatchAsync = <Key extends keyof StacksEvents>(_type: Key, _event: StacksEvents[Key]) => Promise<unknown[]>
480
+ type DispatchAndCollect = <Key extends keyof StacksEvents>(_type: Key, _event: StacksEvents[Key]) => Promise<Array<{ ok: true, value: unknown } | { ok: false, error: Error }>>
481
+
482
+ const emitter: Emitter<StacksEvents> = events
483
+ const useEvents: Emitter<StacksEvents> = events
484
+
485
+ const dispatch: Dispatch = emitter.emit
486
+ const dispatchAsync: DispatchAsync = emitter.emitAsync
487
+ const dispatchAndCollect: DispatchAndCollect = emitter.emitAndCollect
488
+ const useEvent: Dispatch = dispatch
489
+ const all: EventHandlerMap<StacksEvents> = emitter.all
490
+ const listen: Listen = emitter.on
491
+ const useListen: Listen = emitter.on
492
+ const once: Listen = emitter.once
493
+ const off: Off = emitter.off
494
+
495
+ export {
496
+ all,
497
+ dispatch,
498
+ dispatchAndCollect,
499
+ dispatchAsync,
500
+ emitter,
501
+ events,
502
+ listen,
503
+ off,
504
+ once,
505
+ useEvent,
506
+ useEvents,
507
+ useListen,
508
+ }
509
+
510
+ // Boot-time listener auto-discovery (stacksjs/stacks#1878 E-3,
511
+ // closing F-3 from #1874). Scans `app/Listeners/**/*.ts` for
512
+ // default-exported `{ listensTo, handle }` modules and registers them.
513
+ export { discoverListeners } from './discover'
514
+ export type { ListenerModule } from './discover'
515
+
516
+ // Singleton-friendly scope alias (#1878 E-5). Use to create a
517
+ // per-tenant / per-plugin wrapper that auto-prefixes event names.
518
+ export function scopedEvents(prefix: string) {
519
+ return scope(events, prefix)
520
+ }