webspresso 0.0.100 → 0.0.102
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/.agents/ADMIN.md +47 -4
- package/.agents/AGENTS.md +3 -1
- package/.agents/ARCHITECTURE.md +11 -0
- package/.agents/AUTH.md +63 -0
- package/.agents/CLI.md +81 -0
- package/.agents/CONVENTIONS.md +4 -0
- package/.agents/EMAIL.md +84 -2
- package/.agents/EVENTS.md +179 -0
- package/.agents/ORM.md +59 -0
- package/.agents/PLUGINS.md +32 -6
- package/.agents/ROUTING.md +163 -3
- package/.agents/skills/webspresso-framework/SKILL.md +32 -0
- package/.agents/skills/webspresso-routing-and-api/SKILL.md +4 -4
- package/AGENTS.md +7 -0
- package/README.md +18 -5
- package/bin/commands/add-tailwind.js +10 -0
- package/bin/commands/build.js +34 -0
- package/bin/commands/db-makemigrations.js +83 -0
- package/bin/commands/list.js +96 -0
- package/bin/commands/make-command.js +93 -0
- package/bin/commands/make-event.js +80 -0
- package/bin/commands/make-factory.js +72 -0
- package/bin/commands/make-mail.js +93 -0
- package/bin/commands/make-policy.js +106 -0
- package/bin/commands/routes.js +8 -0
- package/bin/commands/run.js +195 -0
- package/bin/webspresso.js +17 -0
- package/core/applySchema.js +18 -1
- package/core/auth/bouncer.js +196 -0
- package/core/auth/index.js +10 -0
- package/core/auth/middleware.js +15 -1
- package/core/commands/define-command.js +48 -0
- package/core/commands/index.js +18 -0
- package/core/commands/registry.js +156 -0
- package/core/commands/runner.js +76 -0
- package/core/compression/compressible.js +1 -1
- package/core/compression/middleware.js +35 -251
- package/core/content/service.js +8 -2
- package/core/events/discovery.js +142 -0
- package/core/events/dispatcher.js +552 -0
- package/core/events/index.js +23 -0
- package/core/events/signal.js +92 -0
- package/core/forms/flash.js +234 -0
- package/core/forms/form-builder.js +214 -0
- package/core/forms/index.js +16 -0
- package/core/mail/index.js +23 -0
- package/core/mail/mailable.js +250 -0
- package/core/mail/mailer.js +184 -0
- package/core/mail/transports.js +238 -0
- package/core/orm/factory.js +250 -0
- package/core/orm/index.js +6 -0
- package/core/orm/migrations/diff.js +185 -0
- package/core/orm/migrations/index.js +22 -4
- package/core/orm/model.js +4 -0
- package/index.d.ts +422 -1
- package/index.js +111 -16
- package/package.json +9 -5
- package/plugins/admin-panel/api.js +509 -3
- package/plugins/admin-panel/index.js +29 -0
- package/plugins/content/models/content-entry.js +4 -3
- package/plugins/content/models/content-type.js +4 -3
- package/plugins/cors.js +2 -2
- package/plugins/csrf/index.js +1 -1
- package/plugins/email/index.js +48 -0
- package/plugins/email/preview-handler.js +555 -0
- package/plugins/email/service.js +23 -12
- package/plugins/email/transport.js +30 -11
- package/plugins/file-manager/index.js +13 -0
- package/plugins/index.js +2 -0
- package/plugins/lazy.js +258 -0
- package/plugins/redirect/index.js +1 -1
- package/src/api/api-loader.js +18 -2
- package/src/api/validation-i18n.js +231 -0
- package/src/app-context.js +48 -6
- package/src/dev-reload/index.js +9 -1
- package/src/discovery/route-manifest.js +197 -0
- package/src/discovery/scan-routes.js +80 -5
- package/src/file-router.js +153 -91
- package/src/helpers.js +143 -0
- package/src/njk-frontmatter.js +17 -1
- package/src/pages/page-loader.js +1 -1
- package/src/plugin-manager.js +12 -0
- package/src/routing/cascading.js +14 -10
- package/src/routing/route-table.js +1 -1
- package/src/server.js +243 -34
- package/src/services/builtins/system.js +4 -6
- package/src/services/registry.js +12 -2
- package/src/template/precompile.js +247 -0
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso Events - Decoupled Event & Signals Dispatcher (AdonisJS Events & Django Signals-inspired)
|
|
3
|
+
* Zero-dependency event dispatcher supporting sync, async parallel, serial, waterfall,
|
|
4
|
+
* wildcard pattern matching, Django-style sender filtering, and background queue bridging.
|
|
5
|
+
* @module core/events/dispatcher
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const EventEmitter = require('events');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Convert a glob/wildcard pattern (e.g. 'order.*', 'order.**', '*') into a RegExp
|
|
12
|
+
* @param {string} pattern
|
|
13
|
+
* @returns {RegExp}
|
|
14
|
+
*/
|
|
15
|
+
function patternToRegExp(pattern) {
|
|
16
|
+
if (pattern === '*' || pattern === '**') {
|
|
17
|
+
return /^.*$/;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const escaped = pattern
|
|
21
|
+
.replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape regex special chars except *
|
|
22
|
+
.replace(/\*\*/g, '___DOUBLE_STAR___')
|
|
23
|
+
.replace(/\*/g, '[^.]+')
|
|
24
|
+
.replace(/___DOUBLE_STAR___/g, '.*');
|
|
25
|
+
|
|
26
|
+
return new RegExp(`^${escaped}$`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Match sender against filter
|
|
31
|
+
* @param {any} expectedSender
|
|
32
|
+
* @param {any} actualSender
|
|
33
|
+
* @returns {boolean}
|
|
34
|
+
*/
|
|
35
|
+
function matchesSender(expectedSender, actualSender) {
|
|
36
|
+
if (expectedSender === undefined || expectedSender === null) {
|
|
37
|
+
return true; // No sender filter applied
|
|
38
|
+
}
|
|
39
|
+
if (actualSender === undefined || actualSender === null) {
|
|
40
|
+
return false; // Filter requested but no sender provided
|
|
41
|
+
}
|
|
42
|
+
if (expectedSender === actualSender) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
// If sender is string or object with name (e.g. Model class or name)
|
|
46
|
+
const expName = typeof expectedSender === 'function' ? expectedSender.name : (expectedSender.name || String(expectedSender));
|
|
47
|
+
const actName = typeof actualSender === 'function' ? actualSender.name : (actualSender.name || String(actualSender));
|
|
48
|
+
return expName.toLowerCase() === actName.toLowerCase();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* EventDispatcher - Enterprise Event & Signal Dispatcher
|
|
53
|
+
*/
|
|
54
|
+
class EventDispatcher extends EventEmitter {
|
|
55
|
+
/**
|
|
56
|
+
* @param {Object} [options]
|
|
57
|
+
* @param {any} [options.queue] - Optional Webspresso QueueManager instance for bridging
|
|
58
|
+
* @param {number} [options.maxListeners=50]
|
|
59
|
+
* @param {any} [options.logger=console]
|
|
60
|
+
*/
|
|
61
|
+
constructor(options = {}) {
|
|
62
|
+
super();
|
|
63
|
+
this.maxListeners = options.maxListeners || 50;
|
|
64
|
+
this.setMaxListeners(this.maxListeners);
|
|
65
|
+
|
|
66
|
+
this.queue = options.queue || null;
|
|
67
|
+
this.logger = options.logger || console;
|
|
68
|
+
|
|
69
|
+
/** @type {Map<string, Array<{ id: number, handler: Function, once: boolean, sender?: any, priority: number }>>} */
|
|
70
|
+
this.exactListeners = new Map();
|
|
71
|
+
|
|
72
|
+
/** @type {Array<{ id: number, pattern: string, regex: RegExp, handler: Function, once: boolean, sender?: any, priority: number }>} */
|
|
73
|
+
this.wildcardListeners = [];
|
|
74
|
+
|
|
75
|
+
/** @type {Array<{ id: number, handler: Function, once: boolean, priority: number }>} */
|
|
76
|
+
this.anyListeners = [];
|
|
77
|
+
|
|
78
|
+
this._idCounter = 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Register an event listener
|
|
83
|
+
* @param {string} event - Event name or wildcard pattern (e.g. 'order.created', 'order.*')
|
|
84
|
+
* @param {Function} handler - Listener function (data, meta) => any
|
|
85
|
+
* @param {Object} [options]
|
|
86
|
+
* @param {any} [options.sender] - Django-style sender filter
|
|
87
|
+
* @param {number} [options.priority=0] - Execution priority (higher runs first)
|
|
88
|
+
* @param {boolean} [options.once=false] - Whether to fire only once
|
|
89
|
+
* @returns {Function} Unsubscribe function
|
|
90
|
+
*/
|
|
91
|
+
on(event, handler, options = {}) {
|
|
92
|
+
if (typeof event !== 'string' || !event) {
|
|
93
|
+
throw new TypeError('Event name must be a non-empty string');
|
|
94
|
+
}
|
|
95
|
+
if (typeof handler !== 'function') {
|
|
96
|
+
throw new TypeError('Handler must be a function');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const id = ++this._idCounter;
|
|
100
|
+
const priority = typeof options.priority === 'number' ? options.priority : 0;
|
|
101
|
+
const once = Boolean(options.once);
|
|
102
|
+
const sender = options.sender ?? null;
|
|
103
|
+
|
|
104
|
+
const listenerRecord = { id, handler, once, sender, priority };
|
|
105
|
+
|
|
106
|
+
if (event.includes('*') || event.includes('?')) {
|
|
107
|
+
this.wildcardListeners.push({
|
|
108
|
+
...listenerRecord,
|
|
109
|
+
pattern: event,
|
|
110
|
+
regex: patternToRegExp(event),
|
|
111
|
+
});
|
|
112
|
+
} else {
|
|
113
|
+
if (!this.exactListeners.has(event)) {
|
|
114
|
+
this.exactListeners.set(event, []);
|
|
115
|
+
}
|
|
116
|
+
this.exactListeners.get(event).push(listenerRecord);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Return unsubscribe function
|
|
120
|
+
return () => this.off(event, handler);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Register a one-time event listener
|
|
125
|
+
* @param {string} event
|
|
126
|
+
* @param {Function} handler
|
|
127
|
+
* @param {Object} [options]
|
|
128
|
+
* @returns {Function} Unsubscribe function
|
|
129
|
+
*/
|
|
130
|
+
once(event, handler, options = {}) {
|
|
131
|
+
return this.on(event, handler, { ...options, once: true });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Remove an event listener
|
|
136
|
+
* @param {string} event
|
|
137
|
+
* @param {Function} handler
|
|
138
|
+
* @returns {this}
|
|
139
|
+
*/
|
|
140
|
+
off(event, handler) {
|
|
141
|
+
if (typeof event !== 'string' || typeof handler !== 'function') {
|
|
142
|
+
return this;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (event.includes('*') || event.includes('?')) {
|
|
146
|
+
this.wildcardListeners = this.wildcardListeners.filter(
|
|
147
|
+
(l) => !(l.pattern === event && l.handler === handler)
|
|
148
|
+
);
|
|
149
|
+
} else if (this.exactListeners.has(event)) {
|
|
150
|
+
const list = this.exactListeners.get(event).filter((l) => l.handler !== handler);
|
|
151
|
+
if (list.length === 0) {
|
|
152
|
+
this.exactListeners.delete(event);
|
|
153
|
+
} else {
|
|
154
|
+
this.exactListeners.set(event, list);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return this;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Catch-all listener for every event
|
|
163
|
+
* @param {Function} handler - (eventName, data, meta) => any
|
|
164
|
+
* @param {Object} [options]
|
|
165
|
+
* @returns {Function} Unsubscribe function
|
|
166
|
+
*/
|
|
167
|
+
onAny(handler, options = {}) {
|
|
168
|
+
if (typeof handler !== 'function') {
|
|
169
|
+
throw new TypeError('Handler must be a function');
|
|
170
|
+
}
|
|
171
|
+
const id = ++this._idCounter;
|
|
172
|
+
const priority = typeof options.priority === 'number' ? options.priority : 0;
|
|
173
|
+
const once = Boolean(options.once);
|
|
174
|
+
|
|
175
|
+
const record = { id, handler, once, priority };
|
|
176
|
+
this.anyListeners.push(record);
|
|
177
|
+
|
|
178
|
+
return () => {
|
|
179
|
+
this.anyListeners = this.anyListeners.filter((l) => l.id !== id);
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Collect matched listeners sorted by priority
|
|
185
|
+
* @protected
|
|
186
|
+
* @param {string} event
|
|
187
|
+
* @param {any} [sender]
|
|
188
|
+
* @returns {Array<{ handler: Function, isAny: boolean, remove: Function }>}
|
|
189
|
+
*/
|
|
190
|
+
_getMatchedListeners(event, sender = null) {
|
|
191
|
+
const matched = [];
|
|
192
|
+
|
|
193
|
+
// 1. Exact listeners
|
|
194
|
+
if (this.exactListeners.has(event)) {
|
|
195
|
+
const list = this.exactListeners.get(event);
|
|
196
|
+
const toKeep = [];
|
|
197
|
+
for (const item of list) {
|
|
198
|
+
if (matchesSender(item.sender, sender)) {
|
|
199
|
+
matched.push({
|
|
200
|
+
priority: item.priority,
|
|
201
|
+
handler: item.handler,
|
|
202
|
+
isAny: false,
|
|
203
|
+
remove: () => {
|
|
204
|
+
const current = this.exactListeners.get(event) || [];
|
|
205
|
+
this.exactListeners.set(event, current.filter((l) => l.id !== item.id));
|
|
206
|
+
},
|
|
207
|
+
once: item.once,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
if (!item.once) {
|
|
211
|
+
toKeep.push(item);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
this.exactListeners.set(event, toKeep);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 2. Wildcard listeners
|
|
218
|
+
const remainingWildcards = [];
|
|
219
|
+
for (const item of this.wildcardListeners) {
|
|
220
|
+
if (item.regex.test(event) && matchesSender(item.sender, sender)) {
|
|
221
|
+
matched.push({
|
|
222
|
+
priority: item.priority,
|
|
223
|
+
handler: item.handler,
|
|
224
|
+
isAny: false,
|
|
225
|
+
remove: () => {
|
|
226
|
+
this.wildcardListeners = this.wildcardListeners.filter((l) => l.id !== item.id);
|
|
227
|
+
},
|
|
228
|
+
once: item.once,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
if (!item.once) {
|
|
232
|
+
remainingWildcards.push(item);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
this.wildcardListeners = remainingWildcards;
|
|
236
|
+
|
|
237
|
+
// 3. Any listeners
|
|
238
|
+
const remainingAny = [];
|
|
239
|
+
for (const item of this.anyListeners) {
|
|
240
|
+
matched.push({
|
|
241
|
+
priority: item.priority,
|
|
242
|
+
handler: item.handler,
|
|
243
|
+
isAny: true,
|
|
244
|
+
remove: () => {
|
|
245
|
+
this.anyListeners = this.anyListeners.filter((l) => l.id !== item.id);
|
|
246
|
+
},
|
|
247
|
+
once: item.once,
|
|
248
|
+
});
|
|
249
|
+
if (!item.once) {
|
|
250
|
+
remainingAny.push(item);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
this.anyListeners = remainingAny;
|
|
254
|
+
|
|
255
|
+
// Sort by priority descending (higher numbers first)
|
|
256
|
+
matched.sort((a, b) => b.priority - a.priority);
|
|
257
|
+
|
|
258
|
+
return matched;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Parse event argument (supports event class instance)
|
|
263
|
+
* @protected
|
|
264
|
+
*/
|
|
265
|
+
_parseEmitArgs(eventOrInstance, data, options) {
|
|
266
|
+
if (eventOrInstance && typeof eventOrInstance === 'object' && eventOrInstance.eventName) {
|
|
267
|
+
return {
|
|
268
|
+
event: eventOrInstance.eventName,
|
|
269
|
+
data: eventOrInstance,
|
|
270
|
+
options: data || {},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
event: String(eventOrInstance),
|
|
275
|
+
data,
|
|
276
|
+
options: options || {},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Synchronous / fire-and-forget emit
|
|
282
|
+
* @param {string|Object} eventOrInstance - Event name or Event class instance
|
|
283
|
+
* @param {any} [data] - Event payload
|
|
284
|
+
* @param {Object} [options] - Options ({ sender })
|
|
285
|
+
* @returns {boolean} Whether any listeners were invoked
|
|
286
|
+
*/
|
|
287
|
+
emit(eventOrInstance, data, options = {}) {
|
|
288
|
+
const parsed = this._parseEmitArgs(eventOrInstance, data, options);
|
|
289
|
+
const { event, data: payload, options: opts } = parsed;
|
|
290
|
+
const sender = opts.sender ?? null;
|
|
291
|
+
|
|
292
|
+
const listeners = this._getMatchedListeners(event, sender);
|
|
293
|
+
if (listeners.length === 0) {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const meta = { event, sender, timestamp: Date.now() };
|
|
298
|
+
|
|
299
|
+
for (const listener of listeners) {
|
|
300
|
+
try {
|
|
301
|
+
if (listener.isAny) {
|
|
302
|
+
listener.handler(event, payload, meta);
|
|
303
|
+
} else {
|
|
304
|
+
listener.handler(payload, meta);
|
|
305
|
+
}
|
|
306
|
+
} catch (err) {
|
|
307
|
+
// Emit error on dispatcher or log
|
|
308
|
+
if (this.listenerCount('error') > 0) {
|
|
309
|
+
super.emit('error', err);
|
|
310
|
+
} else if (this.logger && typeof this.logger.error === 'function') {
|
|
311
|
+
this.logger.error(`[EventDispatcher] Error in listener for event "${event}":`, err);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Asynchronous parallel emit (AdonisJS-style)
|
|
321
|
+
* Invokes all matching listeners concurrently with Promise.allSettled
|
|
322
|
+
* @param {string|Object} eventOrInstance
|
|
323
|
+
* @param {any} [data]
|
|
324
|
+
* @param {Object} [options]
|
|
325
|
+
* @returns {Promise<Array<{ status: 'fulfilled'|'rejected', value?: any, reason?: any }>>}
|
|
326
|
+
*/
|
|
327
|
+
async emitAsync(eventOrInstance, data, options = {}) {
|
|
328
|
+
const parsed = this._parseEmitArgs(eventOrInstance, data, options);
|
|
329
|
+
const { event, data: payload, options: opts } = parsed;
|
|
330
|
+
const sender = opts.sender ?? null;
|
|
331
|
+
|
|
332
|
+
const listeners = this._getMatchedListeners(event, sender);
|
|
333
|
+
if (listeners.length === 0) {
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const meta = { event, sender, timestamp: Date.now() };
|
|
338
|
+
|
|
339
|
+
const promises = listeners.map(async (listener) => {
|
|
340
|
+
if (listener.isAny) {
|
|
341
|
+
return listener.handler(event, payload, meta);
|
|
342
|
+
}
|
|
343
|
+
return listener.handler(payload, meta);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
return Promise.allSettled(promises);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Alias for emitAsync
|
|
351
|
+
*/
|
|
352
|
+
async emitParallel(eventOrInstance, data, options = {}) {
|
|
353
|
+
return this.emitAsync(eventOrInstance, data, options);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Asynchronous sequential emit (one after another in priority order)
|
|
358
|
+
* @param {string|Object} eventOrInstance
|
|
359
|
+
* @param {any} [data]
|
|
360
|
+
* @param {Object} [options]
|
|
361
|
+
* @returns {Promise<any[]>} Array of return values
|
|
362
|
+
*/
|
|
363
|
+
async emitSerial(eventOrInstance, data, options = {}) {
|
|
364
|
+
const parsed = this._parseEmitArgs(eventOrInstance, data, options);
|
|
365
|
+
const { event, data: payload, options: opts } = parsed;
|
|
366
|
+
const sender = opts.sender ?? null;
|
|
367
|
+
|
|
368
|
+
const listeners = this._getMatchedListeners(event, sender);
|
|
369
|
+
if (listeners.length === 0) {
|
|
370
|
+
return [];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const meta = { event, sender, timestamp: Date.now() };
|
|
374
|
+
const results = [];
|
|
375
|
+
|
|
376
|
+
for (const listener of listeners) {
|
|
377
|
+
const res = listener.isAny
|
|
378
|
+
? await listener.handler(event, payload, meta)
|
|
379
|
+
: await listener.handler(payload, meta);
|
|
380
|
+
results.push(res);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return results;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Waterfall sequential emit: Each listener receives the output of the previous listener
|
|
388
|
+
* @param {string|Object} eventOrInstance
|
|
389
|
+
* @param {any} initialData
|
|
390
|
+
* @param {Object} [options]
|
|
391
|
+
* @returns {Promise<any>} Final transformed data
|
|
392
|
+
*/
|
|
393
|
+
async emitWaterfall(eventOrInstance, initialData, options = {}) {
|
|
394
|
+
const parsed = this._parseEmitArgs(eventOrInstance, initialData, options);
|
|
395
|
+
const { event, data: payload, options: opts } = parsed;
|
|
396
|
+
const sender = opts.sender ?? null;
|
|
397
|
+
|
|
398
|
+
const listeners = this._getMatchedListeners(event, sender);
|
|
399
|
+
if (listeners.length === 0) {
|
|
400
|
+
return payload;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const meta = { event, sender, timestamp: Date.now() };
|
|
404
|
+
let currentData = payload;
|
|
405
|
+
|
|
406
|
+
for (const listener of listeners) {
|
|
407
|
+
const res = listener.isAny
|
|
408
|
+
? await listener.handler(event, currentData, meta)
|
|
409
|
+
: await listener.handler(currentData, meta);
|
|
410
|
+
|
|
411
|
+
if (res !== undefined) {
|
|
412
|
+
currentData = res;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return currentData;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Set or update background queue manager for job dispatching
|
|
421
|
+
* @param {any} queueManager
|
|
422
|
+
* @returns {this}
|
|
423
|
+
*/
|
|
424
|
+
setQueue(queueManager) {
|
|
425
|
+
this.queue = queueManager;
|
|
426
|
+
return this;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Bridge an event to a background queue job (Adonis / Laravel style)
|
|
431
|
+
* When event is fired, automatically dispatches to `queue.dispatch(jobName, data, queueOptions)`
|
|
432
|
+
* @param {string} event - Event name
|
|
433
|
+
* @param {string} jobName - Queue job name
|
|
434
|
+
* @param {Object} [queueOptions] - Dispatch options (delay, priority, retries, etc.)
|
|
435
|
+
* @returns {Function} Unsubscribe function
|
|
436
|
+
*/
|
|
437
|
+
listenAndQueue(event, jobName, queueOptions = {}) {
|
|
438
|
+
return this.on(event, (data) => {
|
|
439
|
+
if (!this.queue || typeof this.queue.dispatch !== 'function') {
|
|
440
|
+
throw new Error(
|
|
441
|
+
`[EventDispatcher] Cannot queue job '${jobName}' for event '${event}'. No QueueManager is attached.`
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
return this.queue.dispatch(jobName, data, queueOptions);
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Bridge Django-style ORM lifecycle events (ModelEvents) into application dispatcher
|
|
450
|
+
* Emits 'model:created', 'model:updated', 'model:deleted'
|
|
451
|
+
* @param {import('../orm/events').ModelEventsClass} [modelEvents]
|
|
452
|
+
* @returns {Function} Detach function
|
|
453
|
+
*/
|
|
454
|
+
bridgeOrm(modelEvents) {
|
|
455
|
+
if (!modelEvents || typeof modelEvents.on !== 'function') {
|
|
456
|
+
return () => {};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const unsubs = [
|
|
460
|
+
modelEvents.on('*.afterCreate', (record, ctx) => {
|
|
461
|
+
this.emit('model:created', record, { sender: ctx.model, trx: ctx.trx });
|
|
462
|
+
this.emit(`${ctx.model.toLowerCase()}:created`, record, { sender: ctx.model, trx: ctx.trx });
|
|
463
|
+
}),
|
|
464
|
+
modelEvents.on('*.afterUpdate', (record, ctx) => {
|
|
465
|
+
this.emit('model:updated', record, { sender: ctx.model, trx: ctx.trx });
|
|
466
|
+
this.emit(`${ctx.model.toLowerCase()}:updated`, record, { sender: ctx.model, trx: ctx.trx });
|
|
467
|
+
}),
|
|
468
|
+
modelEvents.on('*.afterDelete', (record, ctx) => {
|
|
469
|
+
this.emit('model:deleted', record, { sender: ctx.model, trx: ctx.trx });
|
|
470
|
+
this.emit(`${ctx.model.toLowerCase()}:deleted`, record, { sender: ctx.model, trx: ctx.trx });
|
|
471
|
+
}),
|
|
472
|
+
];
|
|
473
|
+
|
|
474
|
+
return () => {
|
|
475
|
+
for (const unsub of unsubs) {
|
|
476
|
+
if (typeof unsub === 'function') unsub();
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Get all registered event names and wildcard patterns
|
|
483
|
+
* @returns {string[]}
|
|
484
|
+
*/
|
|
485
|
+
eventNames() {
|
|
486
|
+
const exact = Array.from(this.exactListeners.keys());
|
|
487
|
+
const wildcards = this.wildcardListeners.map((w) => w.pattern);
|
|
488
|
+
return Array.from(new Set([...exact, ...wildcards]));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Get count of listeners for a given event name
|
|
493
|
+
* @param {string} [event]
|
|
494
|
+
* @returns {number}
|
|
495
|
+
*/
|
|
496
|
+
listenerCount(event) {
|
|
497
|
+
if (!event) {
|
|
498
|
+
let count = 0;
|
|
499
|
+
for (const list of this.exactListeners.values()) count += list.length;
|
|
500
|
+
count += this.wildcardListeners.length;
|
|
501
|
+
count += this.anyListeners.length;
|
|
502
|
+
return count;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
let count = (this.exactListeners.get(event) || []).length;
|
|
506
|
+
for (const w of this.wildcardListeners) {
|
|
507
|
+
if (w.regex.test(event)) count++;
|
|
508
|
+
}
|
|
509
|
+
count += this.anyListeners.length;
|
|
510
|
+
return count;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Remove all listeners
|
|
515
|
+
* @param {string} [event]
|
|
516
|
+
* @returns {this}
|
|
517
|
+
*/
|
|
518
|
+
removeAllListeners(event) {
|
|
519
|
+
if (event) {
|
|
520
|
+
this.exactListeners.delete(event);
|
|
521
|
+
this.wildcardListeners = this.wildcardListeners.filter((w) => w.pattern !== event);
|
|
522
|
+
} else {
|
|
523
|
+
this.exactListeners.clear();
|
|
524
|
+
this.wildcardListeners = [];
|
|
525
|
+
this.anyListeners = [];
|
|
526
|
+
}
|
|
527
|
+
return this;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Reset / clear dispatcher state
|
|
532
|
+
*/
|
|
533
|
+
clear() {
|
|
534
|
+
this.removeAllListeners();
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Factory to create a new EventDispatcher
|
|
540
|
+
* @param {Object} [options]
|
|
541
|
+
* @returns {EventDispatcher}
|
|
542
|
+
*/
|
|
543
|
+
function createEventDispatcher(options = {}) {
|
|
544
|
+
return new EventDispatcher(options);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
module.exports = {
|
|
548
|
+
EventDispatcher,
|
|
549
|
+
createEventDispatcher,
|
|
550
|
+
patternToRegExp,
|
|
551
|
+
matchesSender,
|
|
552
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso Events Subsystem - Decoupled Event & Signals Architecture
|
|
3
|
+
* (AdonisJS Events & Django Signals-inspired)
|
|
4
|
+
* @module core/events
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { EventDispatcher, createEventDispatcher } = require('./dispatcher');
|
|
8
|
+
const { Signal, defineSignal } = require('./signal');
|
|
9
|
+
const { defineListener, loadListeners } = require('./discovery');
|
|
10
|
+
|
|
11
|
+
// Global default event dispatcher instance
|
|
12
|
+
const defaultDispatcher = createEventDispatcher();
|
|
13
|
+
|
|
14
|
+
module.exports = {
|
|
15
|
+
EventDispatcher,
|
|
16
|
+
createEventDispatcher,
|
|
17
|
+
defaultDispatcher,
|
|
18
|
+
events: defaultDispatcher,
|
|
19
|
+
Signal,
|
|
20
|
+
defineSignal,
|
|
21
|
+
defineListener,
|
|
22
|
+
loadListeners,
|
|
23
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso Events - Django-Style Signals Subsystem
|
|
3
|
+
* Declarative Signal objects with sender filtering, connect, disconnect, and send methods.
|
|
4
|
+
* @module core/events/signal
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Signal class representing a named event channel (Django-inspired)
|
|
9
|
+
*/
|
|
10
|
+
class Signal {
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} name - Signal identifier name (e.g. 'order_placed', 'user_registered')
|
|
13
|
+
* @param {import('./dispatcher').EventDispatcher} dispatcher
|
|
14
|
+
*/
|
|
15
|
+
constructor(name, dispatcher) {
|
|
16
|
+
if (!name || typeof name !== 'string') {
|
|
17
|
+
throw new TypeError('Signal name must be a non-empty string');
|
|
18
|
+
}
|
|
19
|
+
this.name = name;
|
|
20
|
+
this.dispatcher = dispatcher;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Connect a receiver/listener to this signal
|
|
25
|
+
* @param {Function} receiver - (data, meta) => any
|
|
26
|
+
* @param {Object} [options]
|
|
27
|
+
* @param {any} [options.sender] - Filter by specific sender (class, object, or string)
|
|
28
|
+
* @param {number} [options.priority=0] - Priority order
|
|
29
|
+
* @param {boolean} [options.once=false] - Fire only once
|
|
30
|
+
* @returns {Function} Disconnect function
|
|
31
|
+
*/
|
|
32
|
+
connect(receiver, options = {}) {
|
|
33
|
+
return this.dispatcher.on(this.name, receiver, options);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Disconnect a receiver from this signal
|
|
38
|
+
* @param {Function} receiver
|
|
39
|
+
* @returns {this}
|
|
40
|
+
*/
|
|
41
|
+
disconnect(receiver) {
|
|
42
|
+
this.dispatcher.off(this.name, receiver);
|
|
43
|
+
return this;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Send the signal (synchronous / fire-and-forget)
|
|
48
|
+
* @param {any} [data] - Signal payload
|
|
49
|
+
* @param {Object} [options] - Options ({ sender })
|
|
50
|
+
* @returns {boolean} Whether any receivers were invoked
|
|
51
|
+
*/
|
|
52
|
+
send(data, options = {}) {
|
|
53
|
+
return this.dispatcher.emit(this.name, data, options);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Send the signal asynchronously in parallel
|
|
58
|
+
* @param {any} [data]
|
|
59
|
+
* @param {Object} [options]
|
|
60
|
+
* @returns {Promise<Array<{ status: 'fulfilled'|'rejected', value?: any, reason?: any }>>}
|
|
61
|
+
*/
|
|
62
|
+
async sendAsync(data, options = {}) {
|
|
63
|
+
return this.dispatcher.emitAsync(this.name, data, options);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Send the signal asynchronously in series (one receiver at a time)
|
|
68
|
+
* @param {any} [data]
|
|
69
|
+
* @param {Object} [options]
|
|
70
|
+
* @returns {Promise<any[]>}
|
|
71
|
+
*/
|
|
72
|
+
async sendSerial(data, options = {}) {
|
|
73
|
+
return this.dispatcher.emitSerial(this.name, data, options);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Define a named Signal
|
|
79
|
+
* @param {string} name
|
|
80
|
+
* @param {import('./dispatcher').EventDispatcher} [dispatcher]
|
|
81
|
+
* @returns {Signal}
|
|
82
|
+
*/
|
|
83
|
+
function defineSignal(name, dispatcher) {
|
|
84
|
+
const { defaultDispatcher } = require('./index');
|
|
85
|
+
const targetDispatcher = dispatcher || defaultDispatcher;
|
|
86
|
+
return new Signal(name, targetDispatcher);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = {
|
|
90
|
+
Signal,
|
|
91
|
+
defineSignal,
|
|
92
|
+
};
|