@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 +3 -2
- package/src/discover.ts +162 -0
- package/src/index.ts +520 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stacksjs/events",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.70.
|
|
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",
|
package/src/discover.ts
ADDED
|
@@ -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
|
+
}
|