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.
Files changed (88) hide show
  1. package/.agents/ADMIN.md +47 -4
  2. package/.agents/AGENTS.md +3 -1
  3. package/.agents/ARCHITECTURE.md +11 -0
  4. package/.agents/AUTH.md +63 -0
  5. package/.agents/CLI.md +81 -0
  6. package/.agents/CONVENTIONS.md +4 -0
  7. package/.agents/EMAIL.md +84 -2
  8. package/.agents/EVENTS.md +179 -0
  9. package/.agents/ORM.md +59 -0
  10. package/.agents/PLUGINS.md +32 -6
  11. package/.agents/ROUTING.md +163 -3
  12. package/.agents/skills/webspresso-framework/SKILL.md +32 -0
  13. package/.agents/skills/webspresso-routing-and-api/SKILL.md +4 -4
  14. package/AGENTS.md +7 -0
  15. package/README.md +18 -5
  16. package/bin/commands/add-tailwind.js +10 -0
  17. package/bin/commands/build.js +34 -0
  18. package/bin/commands/db-makemigrations.js +83 -0
  19. package/bin/commands/list.js +96 -0
  20. package/bin/commands/make-command.js +93 -0
  21. package/bin/commands/make-event.js +80 -0
  22. package/bin/commands/make-factory.js +72 -0
  23. package/bin/commands/make-mail.js +93 -0
  24. package/bin/commands/make-policy.js +106 -0
  25. package/bin/commands/routes.js +8 -0
  26. package/bin/commands/run.js +195 -0
  27. package/bin/webspresso.js +17 -0
  28. package/core/applySchema.js +18 -1
  29. package/core/auth/bouncer.js +196 -0
  30. package/core/auth/index.js +10 -0
  31. package/core/auth/middleware.js +15 -1
  32. package/core/commands/define-command.js +48 -0
  33. package/core/commands/index.js +18 -0
  34. package/core/commands/registry.js +156 -0
  35. package/core/commands/runner.js +76 -0
  36. package/core/compression/compressible.js +1 -1
  37. package/core/compression/middleware.js +35 -251
  38. package/core/content/service.js +8 -2
  39. package/core/events/discovery.js +142 -0
  40. package/core/events/dispatcher.js +552 -0
  41. package/core/events/index.js +23 -0
  42. package/core/events/signal.js +92 -0
  43. package/core/forms/flash.js +234 -0
  44. package/core/forms/form-builder.js +214 -0
  45. package/core/forms/index.js +16 -0
  46. package/core/mail/index.js +23 -0
  47. package/core/mail/mailable.js +250 -0
  48. package/core/mail/mailer.js +184 -0
  49. package/core/mail/transports.js +238 -0
  50. package/core/orm/factory.js +250 -0
  51. package/core/orm/index.js +6 -0
  52. package/core/orm/migrations/diff.js +185 -0
  53. package/core/orm/migrations/index.js +22 -4
  54. package/core/orm/model.js +4 -0
  55. package/index.d.ts +422 -1
  56. package/index.js +111 -16
  57. package/package.json +9 -5
  58. package/plugins/admin-panel/api.js +509 -3
  59. package/plugins/admin-panel/index.js +29 -0
  60. package/plugins/content/models/content-entry.js +4 -3
  61. package/plugins/content/models/content-type.js +4 -3
  62. package/plugins/cors.js +2 -2
  63. package/plugins/csrf/index.js +1 -1
  64. package/plugins/email/index.js +48 -0
  65. package/plugins/email/preview-handler.js +555 -0
  66. package/plugins/email/service.js +23 -12
  67. package/plugins/email/transport.js +30 -11
  68. package/plugins/file-manager/index.js +13 -0
  69. package/plugins/index.js +2 -0
  70. package/plugins/lazy.js +258 -0
  71. package/plugins/redirect/index.js +1 -1
  72. package/src/api/api-loader.js +18 -2
  73. package/src/api/validation-i18n.js +231 -0
  74. package/src/app-context.js +48 -6
  75. package/src/dev-reload/index.js +9 -1
  76. package/src/discovery/route-manifest.js +197 -0
  77. package/src/discovery/scan-routes.js +80 -5
  78. package/src/file-router.js +153 -91
  79. package/src/helpers.js +143 -0
  80. package/src/njk-frontmatter.js +17 -1
  81. package/src/pages/page-loader.js +1 -1
  82. package/src/plugin-manager.js +12 -0
  83. package/src/routing/cascading.js +14 -10
  84. package/src/routing/route-table.js +1 -1
  85. package/src/server.js +243 -34
  86. package/src/services/builtins/system.js +4 -6
  87. package/src/services/registry.js +12 -2
  88. 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
+ };