@hedwigjs/broker 0.1.0

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/dist/index.cjs ADDED
@@ -0,0 +1,2238 @@
1
+ 'use strict';
2
+
3
+ // src/core/utils/matchPattern.ts
4
+ function matchPattern(topic, pattern) {
5
+ if (pattern === "*") return true;
6
+ if (topic === pattern) return true;
7
+ if (!pattern.includes("*")) return false;
8
+ const regexPattern = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
9
+ return new RegExp(`^${regexPattern}$`).test(topic);
10
+ }
11
+ function matchesAnyPattern(topic, patterns) {
12
+ return patterns.some((pattern) => matchPattern(topic, pattern));
13
+ }
14
+
15
+ // src/core/bridge/Bridge.ts
16
+ var Bridge = class {
17
+ #inject;
18
+ #transport;
19
+ #patterns;
20
+ #unsubscribe = null;
21
+ #logger;
22
+ constructor(inject, config, logger) {
23
+ this.#inject = inject;
24
+ this.#transport = config.transport;
25
+ this.#patterns = config.forward;
26
+ this.#logger = logger;
27
+ this.#unsubscribe = this.#transport.onMessage((data) => {
28
+ this.#handleIncoming(data);
29
+ });
30
+ }
31
+ get forwardPatterns() {
32
+ return this.#patterns;
33
+ }
34
+ /**
35
+ * Human-friendly transport class name, derived from the constructor —
36
+ * `WebSocket` for `WebSocketTransport`, etc. Used by DevTools to label
37
+ * bridges. `undefined` for anonymous-class transports.
38
+ */
39
+ get transportKind() {
40
+ const raw = this.#transport.constructor?.name;
41
+ if (!raw) return void 0;
42
+ return raw.endsWith("Transport") ? raw.slice(0, -"Transport".length) : raw;
43
+ }
44
+ /**
45
+ * Check if topic matches forward patterns
46
+ */
47
+ shouldForward(topic) {
48
+ return matchesAnyPattern(topic, this.#patterns);
49
+ }
50
+ /**
51
+ * Send message to transport (OUTBOUND)
52
+ * Called by BrokerCore when message matches forward patterns
53
+ */
54
+ send(message) {
55
+ this.#transport.send(message);
56
+ }
57
+ /**
58
+ * Handle incoming message from transport (INBOUND)
59
+ * Parse and inject into broker
60
+ */
61
+ #handleIncoming(data) {
62
+ const message = this.#parseMessage(data);
63
+ if (!message) return;
64
+ if (!this.shouldForward(message.topic)) return;
65
+ this.#inject(
66
+ message.topic,
67
+ message.source,
68
+ message.target,
69
+ message.data
70
+ );
71
+ }
72
+ /**
73
+ * Parse raw data into Message object
74
+ */
75
+ #parseMessage(data) {
76
+ try {
77
+ const message = typeof data === "string" ? JSON.parse(data) : data;
78
+ if (!message || typeof message !== "object") return null;
79
+ if (!message.topic || typeof message.topic !== "string") return null;
80
+ return message;
81
+ } catch (error) {
82
+ this.#logger.error("bridge.message.parse_failed", { error });
83
+ return null;
84
+ }
85
+ }
86
+ /**
87
+ * Cleanup: stop listening and destroy transport
88
+ */
89
+ destroy() {
90
+ this.#unsubscribe?.();
91
+ this.#unsubscribe = null;
92
+ this.#transport.destroy();
93
+ }
94
+ };
95
+
96
+ // src/core/routing/RoutingResult.ts
97
+ var RoutingReason = {
98
+ DELIVERED: "DELIVERED",
99
+ DISPATCHED: "DISPATCHED",
100
+ REPLAY_DELIVERED: "REPLAY_DELIVERED",
101
+ HOOK_REJECTED: "HOOK_REJECTED",
102
+ NO_SUBSCRIBERS: "NO_SUBSCRIBERS",
103
+ NOT_SUBSCRIBED: "NOT_SUBSCRIBED",
104
+ HANDLER_FAILED: "HANDLER_FAILED",
105
+ BROKER_DESTROYED: "BROKER_DESTROYED"
106
+ };
107
+ var RoutingResult = class _RoutingResult {
108
+ status;
109
+ reason;
110
+ message;
111
+ timestamp;
112
+ /** Recipient client ID — set for unicast, undefined for multicast. */
113
+ recipientId;
114
+ /** All recipient client IDs — set for multicast, undefined for unicast. */
115
+ recipientIds;
116
+ data;
117
+ constructor(status, reason, message, recipientId, data, recipientIds) {
118
+ this.status = status;
119
+ this.reason = reason;
120
+ this.message = message;
121
+ this.timestamp = Date.now();
122
+ this.recipientId = recipientId;
123
+ this.recipientIds = recipientIds;
124
+ this.data = data;
125
+ Object.freeze(this);
126
+ }
127
+ /**
128
+ * @param status - ACK for success, NACK for failure
129
+ * @param reason - Machine-readable reason code (use RoutingReason constants)
130
+ * @param message - Human-readable result description
131
+ * @param recipientId - Recipient client ID (unicast only)
132
+ * @param data - Response data from handler (Request-Reply pattern only)
133
+ * @param recipientIds - All recipient client IDs (multicast only)
134
+ */
135
+ static create(status, reason, message, recipientId, data, recipientIds) {
136
+ return new _RoutingResult(status, reason, message, recipientId, data, recipientIds);
137
+ }
138
+ };
139
+
140
+ // src/core/routing/Router.ts
141
+ var Router = class {
142
+ #subscriptions;
143
+ #logger;
144
+ constructor(subscriptions, logger) {
145
+ this.#subscriptions = subscriptions;
146
+ this.#logger = logger;
147
+ }
148
+ /**
149
+ * Route unicast message to specific recipient.
150
+ *
151
+ * If the recipient registered multiple handlers on the topic, the FIRST
152
+ * one (in registration order) receives the message and its return value
153
+ * is captured. Multi-handler unicast is not a supported responder pattern
154
+ * — callers that expect a single response should keep unicast slots to
155
+ * one handler.
156
+ */
157
+ async unicast(message, recipient) {
158
+ if (!this.#subscriptions.isSubscribed(recipient, message.topic)) {
159
+ return RoutingResult.create(
160
+ "NACK",
161
+ RoutingReason.NOT_SUBSCRIBED,
162
+ `Client '${recipient}' not subscribed to '${message.topic}'`,
163
+ recipient
164
+ );
165
+ }
166
+ const entries = this.#subscriptions.getEntries(recipient, message.topic);
167
+ const first = entries[0]?.handler;
168
+ const { success, data: responseData } = await this.#executeHandler(message, first);
169
+ return RoutingResult.create(
170
+ success ? "ACK" : "NACK",
171
+ success ? RoutingReason.DELIVERED : RoutingReason.HANDLER_FAILED,
172
+ success ? `Message delivered and handled by '${recipient}'` : `Message not handled by '${recipient}'`,
173
+ recipient,
174
+ responseData
175
+ );
176
+ }
177
+ /**
178
+ * Route multicast message to all subscribers except sender.
179
+ *
180
+ * A subscriber may have registered multiple handlers on the topic — every
181
+ * one of them fires. The `dispatched` count reflects unique recipients
182
+ * (not handler invocations) to keep the ACK payload consistent with the
183
+ * subscriber-centric mental model.
184
+ *
185
+ * Handlers run fire-and-forget — ACK means dispatch completed, not that
186
+ * every subscriber finished processing the message.
187
+ */
188
+ async multicast(message, sender) {
189
+ const subscribers = this.#subscriptions.getSubscribers(message.topic);
190
+ const dispatched = [];
191
+ for (const clientId of subscribers) {
192
+ if (clientId === sender) continue;
193
+ const entries = this.#subscriptions.getEntries(clientId, message.topic);
194
+ if (entries.length === 0) continue;
195
+ for (const entry of entries) {
196
+ this.#executeHandlerFireAndForget(message, entry.handler, clientId);
197
+ }
198
+ dispatched.push(clientId);
199
+ }
200
+ if (dispatched.length === 0) {
201
+ return RoutingResult.create("NACK", RoutingReason.NO_SUBSCRIBERS, `No subscribers for message '${message.topic}'`);
202
+ }
203
+ return RoutingResult.create(
204
+ "ACK",
205
+ RoutingReason.DISPATCHED,
206
+ `Multicast dispatched to ${dispatched.length} subscriber${dispatched.length === 1 ? "" : "s"}`,
207
+ void 0,
208
+ void 0,
209
+ dispatched
210
+ );
211
+ }
212
+ // ========================================
213
+ // PRIVATE HELPER METHODS
214
+ // ========================================
215
+ /**
216
+ * Execute a handler with error handling and response capture
217
+ * @private
218
+ */
219
+ async #executeHandler(message, handler) {
220
+ if (!handler) return { success: false };
221
+ try {
222
+ const result = await handler(message);
223
+ return { success: true, data: result };
224
+ } catch (handlerError) {
225
+ this.#logger.error("handler.failed", { error: handlerError });
226
+ return { success: false };
227
+ }
228
+ }
229
+ /**
230
+ * Execute handler in fire-and-forget mode (for multicast)
231
+ * @private
232
+ */
233
+ #executeHandlerFireAndForget(message, handler, clientId) {
234
+ try {
235
+ Promise.resolve(handler(message)).catch((handlerError) => {
236
+ this.#logger.error("handler.failed", { clientId, error: handlerError });
237
+ });
238
+ } catch (handlerError) {
239
+ this.#logger.error("handler.failed", { clientId, error: handlerError });
240
+ }
241
+ }
242
+ };
243
+
244
+ // src/core/hooks/HooksRegistry.ts
245
+ var HooksRegistry = class {
246
+ #onSubscribeHooks = [];
247
+ #beforeSendHooks = [];
248
+ #afterSendHooks = [];
249
+ #logger;
250
+ constructor(logger) {
251
+ this.#logger = logger;
252
+ }
253
+ // ========================================
254
+ // REGISTRATION
255
+ // ========================================
256
+ /**
257
+ * Register onSubscribe hook(s).
258
+ * Called whenever a client subscribes to a topic. Return `{ allowed: false }` to block.
259
+ *
260
+ * @returns Cleanup function to remove the hook(s).
261
+ */
262
+ addOnSubscribeHook(hook) {
263
+ return this.#addHook(this.#onSubscribeHooks, hook);
264
+ }
265
+ /**
266
+ * Register beforeSend hook(s).
267
+ *
268
+ * Called before routing for EVERY message, including those received from bridges.
269
+ * Return `{ allowed: false }` to block delivery.
270
+ *
271
+ * @returns Cleanup function to remove the hook(s).
272
+ */
273
+ addBeforeSendHook(hook) {
274
+ return this.#addHook(this.#beforeSendHooks, hook);
275
+ }
276
+ /**
277
+ * Register afterSend hook(s).
278
+ *
279
+ * Called after each message is processed. Receives the routing result.
280
+ * Called for ALL messages — both local and forwarded from bridges.
281
+ *
282
+ * @returns Cleanup function to remove the hook(s).
283
+ */
284
+ addAfterSendHook(hook) {
285
+ return this.#addHook(this.#afterSendHooks, hook);
286
+ }
287
+ // ========================================
288
+ // EXECUTION (called by BrokerCore)
289
+ // ========================================
290
+ /**
291
+ * Execute onSubscribe hooks. Stops at the first hook that denies.
292
+ */
293
+ onSubscribe(topic, clientId) {
294
+ return this.#runGuard(
295
+ this.#onSubscribeHooks,
296
+ "onSubscribe",
297
+ (hook) => hook(topic, clientId)
298
+ );
299
+ }
300
+ /**
301
+ * Execute beforeSend hooks. Stops at the first hook that denies.
302
+ * Executed for ALL messages, including those from bridges.
303
+ */
304
+ beforeSend(message) {
305
+ return this.#runGuard(this.#beforeSendHooks, "beforeSend", (hook) => hook(message));
306
+ }
307
+ /**
308
+ * Execute afterSend hooks. All hooks run; errors are isolated per-hook.
309
+ * Executed for ALL messages (local and external).
310
+ */
311
+ afterSend(message, messageResult) {
312
+ for (const hook of this.#afterSendHooks) {
313
+ try {
314
+ hook(message, messageResult);
315
+ } catch (error) {
316
+ this.#logger.error("hook.after_send.failed", { error });
317
+ }
318
+ }
319
+ }
320
+ // ========================================
321
+ // LIFECYCLE
322
+ // ========================================
323
+ /**
324
+ * Remove all registered hooks.
325
+ */
326
+ clear() {
327
+ this.#onSubscribeHooks = [];
328
+ this.#beforeSendHooks = [];
329
+ this.#afterSendHooks = [];
330
+ }
331
+ // ========================================
332
+ // PRIVATE HELPERS
333
+ // ========================================
334
+ /**
335
+ * Add one or more hooks to a list and return an unsubscribe function.
336
+ * Preserves insertion order for deterministic hook execution.
337
+ */
338
+ #addHook(list, hookOrHooks) {
339
+ const added = Array.isArray(hookOrHooks) ? [...hookOrHooks] : [hookOrHooks];
340
+ list.push(...added);
341
+ return () => {
342
+ for (const hook of added) {
343
+ const index = list.indexOf(hook);
344
+ if (index !== -1) list.splice(index, 1);
345
+ }
346
+ };
347
+ }
348
+ /**
349
+ * Run a list of guard-style hooks: each returns HookResult, execution stops
350
+ * at the first `{ allowed: false }`. Errors are caught and logged (fail-open):
351
+ * a throwing hook does not block the pipeline.
352
+ */
353
+ #runGuard(hooks, kind, invoke) {
354
+ for (const hook of hooks) {
355
+ try {
356
+ const result = invoke(hook);
357
+ if (!result.allowed) return result;
358
+ } catch (error) {
359
+ this.#logger.error("hook.failed", { kind, error });
360
+ }
361
+ }
362
+ return { allowed: true };
363
+ }
364
+ };
365
+
366
+ // src/core/client/ClientRegistry.ts
367
+ var ClientRegistry = class {
368
+ #clients = /* @__PURE__ */ new Map();
369
+ #connectedAt = /* @__PURE__ */ new Map();
370
+ /**
371
+ * Register a client
372
+ */
373
+ register(client) {
374
+ this.#clients.set(client.id, client);
375
+ this.#connectedAt.set(client.id, Date.now());
376
+ }
377
+ /**
378
+ * Unregister a client
379
+ */
380
+ unregister(clientId) {
381
+ this.#clients.delete(clientId);
382
+ this.#connectedAt.delete(clientId);
383
+ }
384
+ /**
385
+ * Get the timestamp when a client registered (Unix ms)
386
+ */
387
+ getConnectedAt(clientId) {
388
+ return this.#connectedAt.get(clientId);
389
+ }
390
+ /**
391
+ * Get client by ID
392
+ */
393
+ get(clientId) {
394
+ return this.#clients.get(clientId);
395
+ }
396
+ /**
397
+ * Check if client is registered
398
+ */
399
+ has(clientId) {
400
+ return this.#clients.has(clientId);
401
+ }
402
+ /**
403
+ * Get all registered clients
404
+ */
405
+ getAll() {
406
+ return Array.from(this.#clients.values());
407
+ }
408
+ /**
409
+ * Get all client IDs
410
+ */
411
+ getAllIds() {
412
+ return Array.from(this.#clients.keys());
413
+ }
414
+ /**
415
+ * Clear all clients
416
+ */
417
+ clear() {
418
+ this.#clients.clear();
419
+ this.#connectedAt.clear();
420
+ }
421
+ /**
422
+ * Get number of registered clients
423
+ */
424
+ get size() {
425
+ return this.#clients.size;
426
+ }
427
+ };
428
+
429
+ // src/core/routing/Subscriptions.ts
430
+ var Subscriptions = class {
431
+ // ========================================
432
+ // BIDIRECTIONAL INDEXES FOR O(1) OPERATIONS
433
+ // ========================================
434
+ /** Topic → Clients mapping for fast multicast recipient lookup */
435
+ #subscriptions = /* @__PURE__ */ new Map();
436
+ /** Client → Topics mapping for fast unsubscribe operations */
437
+ #clientSubscriptions = /* @__PURE__ */ new Map();
438
+ /** Composite key → ordered handler entries (many per pair). */
439
+ #entries = /* @__PURE__ */ new Map();
440
+ /** Subscription id → its location, for O(1) single-handler removal. */
441
+ #entryLocations = /* @__PURE__ */ new Map();
442
+ /** Monotonic subscription id counter. */
443
+ #nextId = 1;
444
+ /** Shared empty set to avoid allocations */
445
+ #emptySet = Object.freeze(/* @__PURE__ */ new Set());
446
+ #emptyEntries = Object.freeze([]);
447
+ // ========================================
448
+ // SUBSCRIPTION OPERATIONS
449
+ // ========================================
450
+ /**
451
+ * Reserve a subscription id ahead of {@link subscribe}.
452
+ *
453
+ * Callers that need the id BEFORE the handler is finalized (e.g. to key
454
+ * a backpressure strategy by that id) can pre-allocate here and then
455
+ * pass the reserved id to {@link subscribe}.
456
+ */
457
+ reserveId() {
458
+ return this.#nextId++;
459
+ }
460
+ /**
461
+ * Subscribe a handler to a (client, topic) pair.
462
+ *
463
+ * Appends a new entry — previously registered handlers on the same pair
464
+ * are preserved. Returns the subscription id so the caller can remove
465
+ * this specific handler later via {@link unsubscribeOne}.
466
+ *
467
+ * If `preReservedId` is provided (from {@link reserveId}), that id is
468
+ * used instead of generating a new one.
469
+ */
470
+ subscribe(clientId, topic, handler, options, preReservedId) {
471
+ if (!this.#subscriptions.has(topic)) {
472
+ this.#subscriptions.set(topic, /* @__PURE__ */ new Set());
473
+ }
474
+ this.#subscriptions.get(topic).add(clientId);
475
+ if (!this.#clientSubscriptions.has(clientId)) {
476
+ this.#clientSubscriptions.set(clientId, /* @__PURE__ */ new Set());
477
+ }
478
+ this.#clientSubscriptions.get(clientId).add(topic);
479
+ const id = preReservedId ?? this.#nextId++;
480
+ const entry = { id, handler, options };
481
+ const key = this.#getKey(clientId, topic);
482
+ let list = this.#entries.get(key);
483
+ if (!list) {
484
+ list = [];
485
+ this.#entries.set(key, list);
486
+ }
487
+ list.push(entry);
488
+ this.#entryLocations.set(id, { clientId, topic });
489
+ return id;
490
+ }
491
+ /**
492
+ * Remove a single handler by its subscription id.
493
+ *
494
+ * If this was the last handler for the pair, the pair is fully removed
495
+ * from the bidirectional indexes (mirroring `unsubscribe` semantics).
496
+ *
497
+ * @returns Removal outcome: the removed entry, the pair it belonged to,
498
+ * and whether it was the last handler on that pair. `undefined`
499
+ * when no such id existed.
500
+ */
501
+ unsubscribeOne(id) {
502
+ const location = this.#entryLocations.get(id);
503
+ if (!location) return void 0;
504
+ const { clientId, topic } = location;
505
+ const key = this.#getKey(clientId, topic);
506
+ const list = this.#entries.get(key);
507
+ if (!list) {
508
+ this.#entryLocations.delete(id);
509
+ return void 0;
510
+ }
511
+ const idx = list.findIndex((e) => e.id === id);
512
+ if (idx === -1) {
513
+ this.#entryLocations.delete(id);
514
+ return void 0;
515
+ }
516
+ const [removed] = list.splice(idx, 1);
517
+ this.#entryLocations.delete(id);
518
+ const wasLast = list.length === 0;
519
+ if (wasLast) {
520
+ this.#entries.delete(key);
521
+ this.#subscriptions.get(topic)?.delete(clientId);
522
+ this.#clientSubscriptions.get(clientId)?.delete(topic);
523
+ if (this.#subscriptions.get(topic)?.size === 0) {
524
+ this.#subscriptions.delete(topic);
525
+ }
526
+ }
527
+ return { entry: removed, clientId, topic, wasLast };
528
+ }
529
+ /**
530
+ * Unsubscribe every handler a client holds on a topic.
531
+ *
532
+ * @returns Entries that were actually removed. Empty when the client had
533
+ * no handlers on the topic. Callers use this to release
534
+ * per-handler resources (e.g. backpressure strategies).
535
+ */
536
+ unsubscribe(clientId, topic) {
537
+ const key = this.#getKey(clientId, topic);
538
+ const list = this.#entries.get(key);
539
+ if (!list || list.length === 0) return this.#emptyEntries;
540
+ for (const entry of list) {
541
+ this.#entryLocations.delete(entry.id);
542
+ }
543
+ this.#entries.delete(key);
544
+ this.#subscriptions.get(topic)?.delete(clientId);
545
+ this.#clientSubscriptions.get(clientId)?.delete(topic);
546
+ if (this.#subscriptions.get(topic)?.size === 0) {
547
+ this.#subscriptions.delete(topic);
548
+ }
549
+ return list;
550
+ }
551
+ /**
552
+ * Remove every subscription held by a given client.
553
+ *
554
+ * @returns Per-topic entry buckets that were removed, in iteration order.
555
+ * Empty when the client had no active subscriptions. Callers use
556
+ * this to release per-handler resources and emit per-topic
557
+ * `subscription.removed` events.
558
+ */
559
+ unsubscribeAll(clientId) {
560
+ const clientTopics = this.#clientSubscriptions.get(clientId);
561
+ if (!clientTopics || clientTopics.size === 0) {
562
+ this.#clientSubscriptions.delete(clientId);
563
+ return [];
564
+ }
565
+ const removed = [];
566
+ for (const topic of clientTopics) {
567
+ const key = this.#getKey(clientId, topic);
568
+ const list = this.#entries.get(key);
569
+ if (list) {
570
+ for (const entry of list) {
571
+ this.#entryLocations.delete(entry.id);
572
+ }
573
+ this.#entries.delete(key);
574
+ removed.push({ topic, entries: list });
575
+ }
576
+ this.#subscriptions.get(topic)?.delete(clientId);
577
+ if (this.#subscriptions.get(topic)?.size === 0) {
578
+ this.#subscriptions.delete(topic);
579
+ }
580
+ }
581
+ this.#clientSubscriptions.delete(clientId);
582
+ return removed;
583
+ }
584
+ // ========================================
585
+ // QUERY OPERATIONS
586
+ // ========================================
587
+ /**
588
+ * Get all topics a client is subscribed to
589
+ */
590
+ getClientTopics(clientId) {
591
+ return this.#clientSubscriptions.get(clientId);
592
+ }
593
+ /**
594
+ * Check if a client has at least one handler on a topic.
595
+ */
596
+ isSubscribed(clientId, topic) {
597
+ return this.#clientSubscriptions.get(clientId)?.has(topic) ?? false;
598
+ }
599
+ /**
600
+ * All handler entries a client has on a topic, in registration order.
601
+ */
602
+ getEntries(clientId, topic) {
603
+ return this.#entries.get(this.#getKey(clientId, topic)) ?? this.#emptyEntries;
604
+ }
605
+ /**
606
+ * Options of the first handler registered on `(clientId, topic)`.
607
+ *
608
+ * Convenience for read-only observers (e.g. Inspector) that predate the
609
+ * multi-handler model and expect a single options blob per pair.
610
+ */
611
+ getFirstOptions(clientId, topic) {
612
+ return this.#entries.get(this.#getKey(clientId, topic))?.[0]?.options;
613
+ }
614
+ /**
615
+ * Number of handlers a client holds on a topic (0 = not subscribed).
616
+ */
617
+ getHandlerCount(clientId, topic) {
618
+ return this.#entries.get(this.#getKey(clientId, topic))?.length ?? 0;
619
+ }
620
+ /**
621
+ * Get all subscribers for a topic (read-only)
622
+ */
623
+ getSubscribers(topic) {
624
+ return this.#subscriptions.get(topic) ?? this.#emptySet;
625
+ }
626
+ /**
627
+ * Get list of all clients that have active subscriptions
628
+ */
629
+ getAllSubscribedClients() {
630
+ return Array.from(this.#clientSubscriptions.keys());
631
+ }
632
+ /**
633
+ * Get detailed subscription map for all clients
634
+ */
635
+ getAllSubscriptions() {
636
+ const result = {};
637
+ for (const [clientId, topics] of this.#clientSubscriptions) {
638
+ result[clientId] = Array.from(topics);
639
+ }
640
+ return result;
641
+ }
642
+ // ========================================
643
+ // LIFECYCLE
644
+ // ========================================
645
+ /**
646
+ * Clear all subscriptions and handlers.
647
+ *
648
+ * @returns All entries that were held, so the caller can release
649
+ * per-handler resources (e.g. backpressure strategies).
650
+ */
651
+ clear() {
652
+ const all = [];
653
+ for (const list of this.#entries.values()) {
654
+ for (const entry of list) {
655
+ all.push(entry);
656
+ }
657
+ }
658
+ this.#subscriptions.clear();
659
+ this.#clientSubscriptions.clear();
660
+ this.#entries.clear();
661
+ this.#entryLocations.clear();
662
+ return all;
663
+ }
664
+ // ========================================
665
+ // PRIVATE HELPERS
666
+ // ========================================
667
+ #getKey(clientId, topic) {
668
+ return `${clientId}:${topic}`;
669
+ }
670
+ };
671
+
672
+ // src/core/backpressure/strategies/ThrottleStrategy.ts
673
+ var ThrottleStrategy = class {
674
+ #throttleMs;
675
+ #lastExecutionTime = 0;
676
+ #timeoutId;
677
+ #pendingMessage;
678
+ #pendingHandler;
679
+ #logger;
680
+ constructor(throttleMs, logger) {
681
+ if (typeof throttleMs !== "number" || !Number.isFinite(throttleMs)) {
682
+ throw new Error("Throttle period must be a finite number");
683
+ }
684
+ if (throttleMs <= 0) {
685
+ throw new Error("Throttle period must be positive");
686
+ }
687
+ this.#throttleMs = throttleMs;
688
+ this.#logger = logger;
689
+ }
690
+ /**
691
+ * Process message through throttle
692
+ *
693
+ * @param message - Incoming message
694
+ * @param handler - Handler to call
695
+ * @returns true if executed immediately, false if delayed
696
+ */
697
+ process(message, handler) {
698
+ const now = Date.now();
699
+ const timeSinceLastExecution = now - this.#lastExecutionTime;
700
+ if (timeSinceLastExecution >= this.#throttleMs) {
701
+ this.#execute(message, handler);
702
+ return true;
703
+ }
704
+ this.#pendingMessage = message;
705
+ this.#pendingHandler = handler;
706
+ if (!this.#timeoutId) {
707
+ const delay = this.#throttleMs - timeSinceLastExecution;
708
+ this.#timeoutId = setTimeout(() => {
709
+ this.#flush();
710
+ }, delay);
711
+ }
712
+ return false;
713
+ }
714
+ /**
715
+ * Execute handler and update last execution time
716
+ */
717
+ #execute(message, handler) {
718
+ this.#lastExecutionTime = Date.now();
719
+ try {
720
+ handler(message);
721
+ } catch (error) {
722
+ this.#logger.error("backpressure.handler.failed", { strategy: "throttle", error });
723
+ }
724
+ }
725
+ /**
726
+ * Flush pending message
727
+ */
728
+ #flush() {
729
+ if (this.#pendingMessage && this.#pendingHandler) {
730
+ this.#execute(this.#pendingMessage, this.#pendingHandler);
731
+ }
732
+ this.#pendingMessage = void 0;
733
+ this.#pendingHandler = void 0;
734
+ this.#timeoutId = void 0;
735
+ }
736
+ /**
737
+ * Force flush pending messages
738
+ * Called on unsubscribe to ensure no messages are lost
739
+ */
740
+ flush() {
741
+ if (this.#timeoutId) {
742
+ clearTimeout(this.#timeoutId);
743
+ this.#flush();
744
+ }
745
+ }
746
+ /**
747
+ * Cleanup resources
748
+ */
749
+ destroy() {
750
+ if (this.#timeoutId) {
751
+ clearTimeout(this.#timeoutId);
752
+ }
753
+ this.#pendingMessage = void 0;
754
+ this.#pendingHandler = void 0;
755
+ }
756
+ };
757
+
758
+ // src/core/backpressure/strategies/DebounceStrategy.ts
759
+ var DebounceStrategy = class {
760
+ #debounceMs;
761
+ #timeoutId;
762
+ #pendingMessage;
763
+ #pendingHandler;
764
+ #logger;
765
+ constructor(debounceMs, logger) {
766
+ if (typeof debounceMs !== "number" || !Number.isFinite(debounceMs)) {
767
+ throw new Error("Debounce period must be a finite number");
768
+ }
769
+ if (debounceMs <= 0) {
770
+ throw new Error("Debounce period must be positive");
771
+ }
772
+ this.#debounceMs = debounceMs;
773
+ this.#logger = logger;
774
+ }
775
+ /**
776
+ * Process message through debounce
777
+ *
778
+ * Resets timer on each call. Only executes after silence period.
779
+ *
780
+ * @param message - Incoming message
781
+ * @param handler - Handler to call
782
+ * @returns false (always delayed)
783
+ */
784
+ process(message, handler) {
785
+ if (this.#timeoutId) {
786
+ clearTimeout(this.#timeoutId);
787
+ }
788
+ this.#pendingMessage = message;
789
+ this.#pendingHandler = handler;
790
+ this.#timeoutId = setTimeout(() => {
791
+ this.#flush();
792
+ }, this.#debounceMs);
793
+ return false;
794
+ }
795
+ /**
796
+ * Execute pending message
797
+ */
798
+ #flush() {
799
+ if (this.#pendingMessage && this.#pendingHandler) {
800
+ try {
801
+ this.#pendingHandler(this.#pendingMessage);
802
+ } catch (error) {
803
+ this.#logger.error("backpressure.handler.failed", { strategy: "debounce", error });
804
+ }
805
+ }
806
+ this.#pendingMessage = void 0;
807
+ this.#pendingHandler = void 0;
808
+ this.#timeoutId = void 0;
809
+ }
810
+ /**
811
+ * Force flush pending message
812
+ * Called on unsubscribe to ensure no messages are lost
813
+ */
814
+ flush() {
815
+ if (this.#timeoutId) {
816
+ clearTimeout(this.#timeoutId);
817
+ this.#flush();
818
+ }
819
+ }
820
+ /**
821
+ * Cleanup resources
822
+ */
823
+ destroy() {
824
+ if (this.#timeoutId) {
825
+ clearTimeout(this.#timeoutId);
826
+ }
827
+ this.#pendingMessage = void 0;
828
+ this.#pendingHandler = void 0;
829
+ }
830
+ };
831
+
832
+ // src/core/backpressure/strategies/RateLimitStrategy.ts
833
+ var RateLimitStrategy = class {
834
+ #max;
835
+ #windowMs;
836
+ #onDrop;
837
+ #timestamps;
838
+ #head = 0;
839
+ #count = 0;
840
+ #droppedCount = 0;
841
+ #logger;
842
+ constructor(options, onDrop, logger) {
843
+ if (typeof options.max !== "number" || !Number.isFinite(options.max)) {
844
+ throw new Error("Rate limit max must be a finite number");
845
+ }
846
+ if (options.max <= 0) {
847
+ throw new Error("Rate limit max must be positive");
848
+ }
849
+ if (typeof options.window !== "number" || !Number.isFinite(options.window)) {
850
+ throw new Error("Rate limit window must be a finite number");
851
+ }
852
+ if (options.window <= 0) {
853
+ throw new Error("Rate limit window must be positive");
854
+ }
855
+ this.#max = options.max;
856
+ this.#windowMs = options.window;
857
+ this.#onDrop = onDrop;
858
+ this.#timestamps = new Array(options.max).fill(0);
859
+ this.#logger = logger;
860
+ }
861
+ /**
862
+ * Process message through rate limit
863
+ *
864
+ * Uses sliding window with circular buffer for O(1) amortized eviction.
865
+ *
866
+ * @param message - Incoming message
867
+ * @param handler - Handler to call
868
+ * @returns true if processed, false if dropped
869
+ */
870
+ process(message, handler) {
871
+ const now = Date.now();
872
+ this.#evict(now);
873
+ if (this.#count < this.#max) {
874
+ const tail = (this.#head + this.#count) % this.#max;
875
+ this.#timestamps[tail] = now;
876
+ this.#count++;
877
+ try {
878
+ handler(message);
879
+ } catch (error) {
880
+ this.#logger.error("backpressure.handler.failed", { strategy: "rateLimit", error });
881
+ }
882
+ return true;
883
+ }
884
+ this.#droppedCount++;
885
+ if (this.#onDrop) {
886
+ try {
887
+ this.#onDrop(this.#droppedCount);
888
+ } catch (error) {
889
+ this.#logger.error("backpressure.on_drop.failed", { error });
890
+ }
891
+ }
892
+ return false;
893
+ }
894
+ /**
895
+ * Flush is no-op for rate limiting
896
+ * Rate limit doesn't accumulate messages, so nothing to flush
897
+ */
898
+ flush() {
899
+ }
900
+ /**
901
+ * Cleanup resources
902
+ */
903
+ destroy() {
904
+ this.#head = 0;
905
+ this.#count = 0;
906
+ this.#timestamps.fill(0);
907
+ }
908
+ /**
909
+ * Get number of dropped messages (for debugging/metrics)
910
+ */
911
+ get droppedCount() {
912
+ return this.#droppedCount;
913
+ }
914
+ /**
915
+ * Get current count in window (for debugging/metrics)
916
+ */
917
+ get currentCount() {
918
+ this.#evict(Date.now());
919
+ return this.#count;
920
+ }
921
+ /** Advance head past expired timestamps */
922
+ #evict(now) {
923
+ while (this.#count > 0 && now - this.#timestamps[this.#head] >= this.#windowMs) {
924
+ this.#head = (this.#head + 1) % this.#max;
925
+ this.#count--;
926
+ }
927
+ }
928
+ };
929
+
930
+ // src/core/backpressure/BackpressureHandler.ts
931
+ var BackpressureHandler = class {
932
+ /** subscriptionId → strategy. One entry per handler that opted into BP. */
933
+ #strategies = /* @__PURE__ */ new Map();
934
+ #logger;
935
+ constructor(logger) {
936
+ this.#logger = logger;
937
+ }
938
+ /**
939
+ * Wrap handler in backpressure strategy
940
+ *
941
+ * If options.backpressure is undefined/null, returns original handler (no backpressure).
942
+ * Otherwise creates appropriate strategy and returns wrapped handler.
943
+ *
944
+ * @param subscriptionId - Unique id of this handler subscription.
945
+ * The id is emitted by {@link Subscriptions.subscribe} — the caller is
946
+ * responsible for reserving it and passing the same value to both
947
+ * `wrap()` and `subscribe()` so the strategy can be released via
948
+ * {@link removeOne} when that specific handler unsubscribes.
949
+ * @param clientId - Unique client identifier (for logging context)
950
+ * @param topic - Topic being subscribed to (for logging context)
951
+ * @param handler - Original handler function
952
+ * @param options - Subscription options (optional)
953
+ * @returns Wrapped handler or original handler if no backpressure options
954
+ */
955
+ wrap(subscriptionId, clientId, topic, handler, options) {
956
+ const bpOptions = options?.backpressure;
957
+ if (!bpOptions) {
958
+ return handler;
959
+ }
960
+ const strategy = this.#createStrategy(bpOptions);
961
+ this.#strategies.set(subscriptionId, strategy);
962
+ return (message) => {
963
+ strategy.process(message, handler);
964
+ };
965
+ }
966
+ /**
967
+ * Create strategy instance based on backpressure options
968
+ *
969
+ * Only ONE strategy can be specified per subscription.
970
+ * Multiple strategies will throw an error.
971
+ *
972
+ * @throws Error if no strategy specified
973
+ * @throws Error if multiple strategies specified
974
+ */
975
+ #createStrategy(options) {
976
+ const specifiedStrategies = [
977
+ options.throttle !== void 0 && "throttle",
978
+ options.debounce !== void 0 && "debounce",
979
+ options.rateLimit !== void 0 && "rateLimit"
980
+ ].filter(Boolean);
981
+ if (specifiedStrategies.length === 0) {
982
+ throw new Error(
983
+ "No backpressure strategy specified. Provide one of: throttle, debounce, or rateLimit."
984
+ );
985
+ }
986
+ if (specifiedStrategies.length > 1) {
987
+ throw new Error(
988
+ `Multiple backpressure strategies specified: ${specifiedStrategies.join(", ")}. Only one strategy is allowed per subscription.`
989
+ );
990
+ }
991
+ if (options.throttle !== void 0) {
992
+ return new ThrottleStrategy(options.throttle, this.#logger);
993
+ }
994
+ if (options.debounce !== void 0) {
995
+ return new DebounceStrategy(options.debounce, this.#logger);
996
+ }
997
+ if (options.rateLimit) {
998
+ return new RateLimitStrategy(options.rateLimit, options.onDrop, this.#logger);
999
+ }
1000
+ throw new Error("No backpressure strategy specified in BackpressureOptions");
1001
+ }
1002
+ /**
1003
+ * Release the strategy attached to a single subscription id.
1004
+ *
1005
+ * Called when the corresponding handler unsubscribes. Flushes pending
1006
+ * messages and destroys the strategy. No-op when the subscription had
1007
+ * no backpressure.
1008
+ */
1009
+ removeOne(subscriptionId) {
1010
+ const strategy = this.#strategies.get(subscriptionId);
1011
+ if (!strategy) return;
1012
+ strategy.flush();
1013
+ strategy.destroy();
1014
+ this.#strategies.delete(subscriptionId);
1015
+ }
1016
+ /**
1017
+ * Bulk release for a set of subscription ids.
1018
+ *
1019
+ * Used when a client unsubscribes from a whole topic (or resets), which
1020
+ * removes N handlers at once.
1021
+ */
1022
+ removeMany(subscriptionIds) {
1023
+ for (const id of subscriptionIds) {
1024
+ this.removeOne(id);
1025
+ }
1026
+ }
1027
+ /**
1028
+ * Cleanup all strategies
1029
+ *
1030
+ * Called when broker is destroyed.
1031
+ */
1032
+ destroy() {
1033
+ for (const strategy of this.#strategies.values()) {
1034
+ strategy.flush();
1035
+ strategy.destroy();
1036
+ }
1037
+ this.#strategies.clear();
1038
+ }
1039
+ /**
1040
+ * Get number of active strategies (for debugging/metrics)
1041
+ */
1042
+ get activeStrategies() {
1043
+ return this.#strategies.size;
1044
+ }
1045
+ };
1046
+
1047
+ // src/core/utils/deepFreeze.ts
1048
+ function deepFreeze(obj) {
1049
+ Object.freeze(obj);
1050
+ Object.getOwnPropertyNames(obj).forEach((prop) => {
1051
+ const value = obj[prop];
1052
+ if (value !== null && (typeof value === "object" || typeof value === "function") && !Object.isFrozen(value)) {
1053
+ deepFreeze(value);
1054
+ }
1055
+ });
1056
+ return obj;
1057
+ }
1058
+
1059
+ // src/core/history/MessageHistory.ts
1060
+ var MessageHistory = class {
1061
+ #entries = [];
1062
+ #sequence = 0;
1063
+ #config;
1064
+ #cleanupTimer;
1065
+ constructor(config) {
1066
+ this.#config = {
1067
+ enabled: config.enabled,
1068
+ maxSize: config.maxSize ?? 1e3,
1069
+ ttl: config.ttl
1070
+ };
1071
+ if (this.#config.ttl !== void 0) {
1072
+ this.#startCleanup();
1073
+ }
1074
+ }
1075
+ /**
1076
+ * Record a message to history
1077
+ */
1078
+ record(message) {
1079
+ const entry = {
1080
+ message: deepFreeze(message),
1081
+ timestamp: message.timestamp,
1082
+ sequence: this.#sequence++
1083
+ };
1084
+ this.#entries.push(entry);
1085
+ if (this.#entries.length > this.#config.maxSize) {
1086
+ this.#entries.shift();
1087
+ }
1088
+ }
1089
+ /**
1090
+ * Query messages from history
1091
+ */
1092
+ async query(filter) {
1093
+ let results = [...this.#entries];
1094
+ if (filter?.since !== void 0) {
1095
+ results = results.filter((entry) => entry.timestamp >= filter.since);
1096
+ }
1097
+ if (filter?.until !== void 0) {
1098
+ results = results.filter((entry) => entry.timestamp <= filter.until);
1099
+ }
1100
+ if (filter?.topics && filter.topics.length > 0) {
1101
+ results = results.filter(
1102
+ (entry) => filter.topics.some((pattern) => matchPattern(entry.message.topic, pattern))
1103
+ );
1104
+ }
1105
+ if (filter?.sources && filter.sources.length > 0) {
1106
+ results = results.filter((entry) => filter.sources.includes(entry.message.source));
1107
+ }
1108
+ if (filter?.limit !== void 0 && filter.limit > 0) {
1109
+ results = results.slice(-filter.limit);
1110
+ }
1111
+ return results;
1112
+ }
1113
+ /**
1114
+ * Clear messages from history
1115
+ */
1116
+ async clear(filter) {
1117
+ if (!filter) {
1118
+ this.#entries = [];
1119
+ return;
1120
+ }
1121
+ const toKeep = await this.#getInverseFilter(filter);
1122
+ this.#entries = toKeep;
1123
+ }
1124
+ /**
1125
+ * Return a point-in-time snapshot of all entries (oldest → newest).
1126
+ */
1127
+ getSnapshot() {
1128
+ return [...this.#entries];
1129
+ }
1130
+ /**
1131
+ * Get history statistics
1132
+ */
1133
+ getStats() {
1134
+ const count = this.#entries.length;
1135
+ if (count === 0) {
1136
+ return { count: 0 };
1137
+ }
1138
+ const oldestTimestamp = this.#entries[0]?.timestamp;
1139
+ const newestTimestamp = this.#entries[count - 1]?.timestamp;
1140
+ const memoryUsage = this.#estimateMemoryUsage();
1141
+ return {
1142
+ count,
1143
+ oldestTimestamp,
1144
+ newestTimestamp,
1145
+ memoryUsage
1146
+ };
1147
+ }
1148
+ /**
1149
+ * Cleanup and destroy
1150
+ */
1151
+ destroy() {
1152
+ if (this.#cleanupTimer) {
1153
+ clearInterval(this.#cleanupTimer);
1154
+ this.#cleanupTimer = void 0;
1155
+ }
1156
+ this.#entries = [];
1157
+ }
1158
+ // ========================================
1159
+ // PRIVATE METHODS
1160
+ // ========================================
1161
+ /**
1162
+ * Get entries that should be kept (inverse of filter)
1163
+ */
1164
+ async #getInverseFilter(filter) {
1165
+ return this.#entries.filter((entry) => {
1166
+ if (filter.since !== void 0 && entry.timestamp < filter.since) {
1167
+ return true;
1168
+ }
1169
+ if (filter.until !== void 0 && entry.timestamp > filter.until) {
1170
+ return true;
1171
+ }
1172
+ if (filter.topics && filter.topics.length > 0) {
1173
+ const matches = filter.topics.some(
1174
+ (pattern) => matchPattern(entry.message.topic, pattern)
1175
+ );
1176
+ if (!matches) {
1177
+ return true;
1178
+ }
1179
+ }
1180
+ if (filter.sources && filter.sources.length > 0) {
1181
+ if (!filter.sources.includes(entry.message.source)) {
1182
+ return true;
1183
+ }
1184
+ }
1185
+ return false;
1186
+ });
1187
+ }
1188
+ /**
1189
+ * Start periodic TTL-based cleanup
1190
+ */
1191
+ #startCleanup() {
1192
+ const ttl = this.#config.ttl;
1193
+ if (!ttl) return;
1194
+ const interval = Math.min(ttl / 2, 6e4);
1195
+ this.#cleanupTimer = setInterval(() => {
1196
+ const now = Date.now();
1197
+ const cutoff = now - ttl;
1198
+ this.#entries = this.#entries.filter((entry) => entry.timestamp > cutoff);
1199
+ }, interval);
1200
+ }
1201
+ /**
1202
+ * Estimate memory usage (rough approximation)
1203
+ */
1204
+ #estimateMemoryUsage() {
1205
+ return this.#entries.length * 100;
1206
+ }
1207
+ };
1208
+
1209
+ // src/core/history/SubscriptionReplay.ts
1210
+ var SubscriptionReplay = class {
1211
+ #history;
1212
+ #hooks;
1213
+ #logger;
1214
+ constructor(history, hooks, logger) {
1215
+ this.#history = history;
1216
+ this.#hooks = hooks;
1217
+ this.#logger = logger;
1218
+ }
1219
+ /**
1220
+ * Asynchronously replay matching history entries to the given subscription.
1221
+ *
1222
+ * Does NOT await completion — returns immediately while the replay runs on
1223
+ * the microtask queue. Callers should not assume replay is finished when
1224
+ * this method returns.
1225
+ *
1226
+ * @param clientId - Target subscriber identifier.
1227
+ * @param topic - Subscribed topic (supports glob; forwarded to history.query).
1228
+ * @param handler - Handler to receive each replayed message.
1229
+ * @param options - Replay window (`limit`, `since`, `until`).
1230
+ */
1231
+ start(clientId, topic, handler, options) {
1232
+ queueMicrotask(async () => {
1233
+ try {
1234
+ const entries = await this.#history.query({
1235
+ topics: [topic],
1236
+ limit: options.limit,
1237
+ since: options.since,
1238
+ until: options.until
1239
+ });
1240
+ for (const entry of entries) {
1241
+ try {
1242
+ const recipient = entry.message.target;
1243
+ if (recipient !== "*" && recipient !== clientId) continue;
1244
+ const replayedMessage = {
1245
+ ...entry.message,
1246
+ replayed: true
1247
+ };
1248
+ await handler(replayedMessage);
1249
+ this.#hooks.afterSend(
1250
+ replayedMessage,
1251
+ RoutingResult.create(
1252
+ "ACK",
1253
+ RoutingReason.REPLAY_DELIVERED,
1254
+ `Replayed to '${clientId}'`,
1255
+ clientId
1256
+ )
1257
+ );
1258
+ } catch (error) {
1259
+ this.#logger.error("replay.handler.failed", {
1260
+ messageId: entry.message.id,
1261
+ clientId,
1262
+ error
1263
+ });
1264
+ }
1265
+ }
1266
+ } catch (error) {
1267
+ this.#logger.error("replay.query.failed", { clientId, error });
1268
+ }
1269
+ });
1270
+ }
1271
+ };
1272
+
1273
+ // src/core/events/SystemEvents.ts
1274
+ var SystemEvents = class {
1275
+ #listeners = /* @__PURE__ */ new Map();
1276
+ #anyListeners = /* @__PURE__ */ new Set();
1277
+ #logger;
1278
+ constructor(logger) {
1279
+ this.#logger = logger;
1280
+ }
1281
+ on(event, listener) {
1282
+ let set = this.#listeners.get(event);
1283
+ if (!set) {
1284
+ set = /* @__PURE__ */ new Set();
1285
+ this.#listeners.set(event, set);
1286
+ }
1287
+ set.add(listener);
1288
+ return () => {
1289
+ const s = this.#listeners.get(event);
1290
+ if (!s) return;
1291
+ s.delete(listener);
1292
+ if (s.size === 0) this.#listeners.delete(event);
1293
+ };
1294
+ }
1295
+ once(event, listener) {
1296
+ const wrapped = (payload) => {
1297
+ unsubscribe();
1298
+ listener(payload);
1299
+ };
1300
+ const unsubscribe = this.on(event, wrapped);
1301
+ return unsubscribe;
1302
+ }
1303
+ off(event) {
1304
+ if (event === void 0) {
1305
+ this.#listeners.clear();
1306
+ return;
1307
+ }
1308
+ this.#listeners.delete(event);
1309
+ }
1310
+ onAny(listener) {
1311
+ this.#anyListeners.add(listener);
1312
+ return () => this.#anyListeners.delete(listener);
1313
+ }
1314
+ listenerCount(event) {
1315
+ if (event === void 0) {
1316
+ let total = this.#anyListeners.size;
1317
+ for (const set of this.#listeners.values()) total += set.size;
1318
+ return total;
1319
+ }
1320
+ return (this.#listeners.get(event)?.size ?? 0) + this.#anyListeners.size;
1321
+ }
1322
+ /**
1323
+ * Emit a system event to all subscribed listeners.
1324
+ *
1325
+ * Safe to call on the hot path: returns immediately when no listeners are
1326
+ * registered (zero allocation). Listener errors are caught and logged;
1327
+ * they never propagate back to the broker pipeline.
1328
+ */
1329
+ emit(event, payload) {
1330
+ if (this.#anyListeners.size === 0) {
1331
+ const direct2 = this.#listeners.get(event);
1332
+ if (!direct2 || direct2.size === 0) return;
1333
+ this.#dispatch(direct2, payload, event);
1334
+ return;
1335
+ }
1336
+ const direct = this.#listeners.get(event);
1337
+ if (direct && direct.size > 0) this.#dispatch(direct, payload, event);
1338
+ for (const listener of this.#anyListeners) {
1339
+ try {
1340
+ listener(event, payload);
1341
+ } catch (err) {
1342
+ this.#logger.error("system_events.listener.failed", { event: String(event), error: err });
1343
+ }
1344
+ }
1345
+ }
1346
+ /**
1347
+ * Remove all listeners. Called by `BrokerCore.destroy()`.
1348
+ */
1349
+ clear() {
1350
+ this.#listeners.clear();
1351
+ this.#anyListeners.clear();
1352
+ }
1353
+ #dispatch(listeners, payload, event) {
1354
+ for (const listener of listeners) {
1355
+ try {
1356
+ listener(payload);
1357
+ } catch (err) {
1358
+ this.#logger.error("system_events.listener.failed", { event: String(event), error: err });
1359
+ }
1360
+ }
1361
+ }
1362
+ };
1363
+
1364
+ // src/core/observability/inspect/Inspector.ts
1365
+ var Inspector = class {
1366
+ #clients;
1367
+ #subscriptions;
1368
+ #bridges;
1369
+ #getHistory;
1370
+ constructor(clients, subscriptions, bridges, getHistory) {
1371
+ this.#clients = clients;
1372
+ this.#subscriptions = subscriptions;
1373
+ this.#bridges = bridges;
1374
+ this.#getHistory = getHistory;
1375
+ }
1376
+ /**
1377
+ * Snapshot of every registered client together with its active subscriptions.
1378
+ *
1379
+ * Use together with `$systemEvents.on('client.*' | 'subscription.*')` to
1380
+ * build an accurate initial state without race conditions: read the snapshot
1381
+ * first, then subscribe to events for incremental updates.
1382
+ */
1383
+ getClients() {
1384
+ return this.#clients.getAllIds().map((id) => ({
1385
+ id,
1386
+ connectedAt: this.#clients.getConnectedAt(id) ?? Date.now(),
1387
+ subscriptions: Array.from(this.#subscriptions.getClientTopics(id) ?? []).map((topic) => ({
1388
+ topic,
1389
+ // A pair may hold N handlers with different options — the Inspector
1390
+ // surface predates the multi-handler model and exposes a single
1391
+ // options blob. First handler wins; drill into `getEntries()` for
1392
+ // full detail.
1393
+ options: this.#subscriptions.getFirstOptions(id, topic),
1394
+ handlerCount: this.#subscriptions.getHandlerCount(id, topic)
1395
+ }))
1396
+ }));
1397
+ }
1398
+ /**
1399
+ * IDs of clients that have at least one active subscription.
1400
+ */
1401
+ getSubscribedClientIds() {
1402
+ return this.#subscriptions.getAllSubscribedClients();
1403
+ }
1404
+ /**
1405
+ * Lifecycle info for every registered bridge. Does NOT expose internal
1406
+ * `Bridge` instances (see `BridgeInfo`).
1407
+ */
1408
+ getBridges() {
1409
+ const result = [];
1410
+ for (const [id, bridge] of this.#bridges) {
1411
+ result.push({
1412
+ id,
1413
+ forwardPatterns: bridge.forwardPatterns,
1414
+ transportKind: bridge.transportKind
1415
+ });
1416
+ }
1417
+ return result;
1418
+ }
1419
+ /**
1420
+ * All messages currently stored in the replay buffer (oldest → newest).
1421
+ * Returns an empty array when history is not enabled.
1422
+ */
1423
+ getHistory() {
1424
+ const history = this.#getHistory();
1425
+ if (!history) return [];
1426
+ return history.getSnapshot();
1427
+ }
1428
+ /**
1429
+ * Replay buffer statistics. Always returns `{ enabled: false, count: 0 }`
1430
+ * when history is not enabled.
1431
+ */
1432
+ getHistoryStats() {
1433
+ const history = this.#getHistory();
1434
+ if (!history) return { count: 0, enabled: false };
1435
+ return { ...history.getStats(), enabled: true };
1436
+ }
1437
+ };
1438
+
1439
+ // src/core/logger/BrokerLogger.types.ts
1440
+ var defaultLogger = {
1441
+ warn(event, meta) {
1442
+ meta !== void 0 ? console.warn(`[broker] ${event}`, meta) : console.warn(`[broker] ${event}`);
1443
+ },
1444
+ error(event, meta) {
1445
+ meta !== void 0 ? console.error(`[broker] ${event}`, meta) : console.error(`[broker] ${event}`);
1446
+ }
1447
+ };
1448
+
1449
+ // src/core/BrokerCore.ts
1450
+ var BrokerCore = class {
1451
+ #isDestroyed = false;
1452
+ #sessionId = crypto.randomUUID();
1453
+ #eventCounter = 0;
1454
+ #subscriptions = new Subscriptions();
1455
+ #router;
1456
+ #hooks;
1457
+ #clientRegistry = new ClientRegistry();
1458
+ #systemEvents;
1459
+ #backpressure;
1460
+ #history;
1461
+ #replay;
1462
+ #bridges = /* @__PURE__ */ new Map();
1463
+ #inspect;
1464
+ /**
1465
+ * Infrastructure logger configured via {@link BrokerConfig.logger}.
1466
+ *
1467
+ * @internal Used by the facade layer.
1468
+ */
1469
+ logger;
1470
+ constructor(config) {
1471
+ this.logger = config?.logger ?? defaultLogger;
1472
+ this.#hooks = new HooksRegistry(this.logger);
1473
+ this.#systemEvents = new SystemEvents(this.logger);
1474
+ this.#backpressure = new BackpressureHandler(this.logger);
1475
+ this.#router = new Router(this.#subscriptions, this.logger);
1476
+ if (config?.history?.enabled) {
1477
+ this.#history = new MessageHistory(config.history);
1478
+ this.#replay = new SubscriptionReplay(this.#history, this.#hooks, this.logger);
1479
+ }
1480
+ this.#inspect = new Inspector(
1481
+ this.#clientRegistry,
1482
+ this.#subscriptions,
1483
+ this.#bridges,
1484
+ () => this.#history
1485
+ );
1486
+ }
1487
+ // ========================================
1488
+ // SYSTEM EVENTS & INSPECT
1489
+ // ========================================
1490
+ /**
1491
+ * Broker-internal system event channel (push model).
1492
+ *
1493
+ * The `$` prefix marks this as a broker-internal API. Intended for tooling:
1494
+ * DevTools, tracing collectors, metrics integrations.
1495
+ *
1496
+ * This is NOT for extending broker behaviour — extension hooks are exposed
1497
+ * via `useBeforeSendHook`, `useAfterSendHook`, `useOnSubscribeHook`.
1498
+ *
1499
+ * @example
1500
+ * broker.$systemEvents.on('client.registered', ({ clientId }) => { ... });
1501
+ * broker.$systemEvents.on('subscription.added', ({ clientId, topic }) => { ... });
1502
+ */
1503
+ get $systemEvents() {
1504
+ return this.#systemEvents;
1505
+ }
1506
+ /**
1507
+ * Point-in-time state snapshots (pull model).
1508
+ *
1509
+ * Read-only view over broker state for DevTools and debugging tools.
1510
+ *
1511
+ * @example
1512
+ * const clients = broker.inspect.getClients();
1513
+ * const history = broker.inspect.getHistory();
1514
+ */
1515
+ get inspect() {
1516
+ return this.#inspect;
1517
+ }
1518
+ // ========================================
1519
+ // SUBSCRIPTION MANAGEMENT
1520
+ // ========================================
1521
+ /**
1522
+ * Subscribe a client to a topic.
1523
+ *
1524
+ * Multiple handlers may be attached to the same `(clientId, topic)` pair —
1525
+ * each call returns a distinct subscription id that identifies THIS
1526
+ * handler for later removal via {@link unsubscribeOne}.
1527
+ *
1528
+ * @returns Subscription id, or `0` when the call was a no-op (broker
1529
+ * destroyed). Zero is never a valid id.
1530
+ *
1531
+ * @throws Error if subscription is blocked on onSubscribe hook
1532
+ *
1533
+ * @internal Called by {@link BrokerClient.on}. Not part of the public
1534
+ * `MessageBroker` contract.
1535
+ */
1536
+ subscribe(clientId, topic, handler, options) {
1537
+ if (this.#isDestroyed) {
1538
+ this.logger.warn("broker.subscribe.after_destroy", { clientId, topic });
1539
+ return 0;
1540
+ }
1541
+ const hookResult = this.#hooks.onSubscribe(topic, clientId);
1542
+ if (!hookResult.allowed) {
1543
+ this.#systemEvents.emit("subscription.rejected", {
1544
+ clientId,
1545
+ topic,
1546
+ reason: hookResult.message
1547
+ });
1548
+ throw new Error(hookResult.message);
1549
+ }
1550
+ const subscriptionId = this.#subscriptions.reserveId();
1551
+ const wrappedHandler = this.#backpressure.wrap(
1552
+ subscriptionId,
1553
+ clientId,
1554
+ topic,
1555
+ handler,
1556
+ options
1557
+ );
1558
+ this.#subscriptions.subscribe(clientId, topic, wrappedHandler, options, subscriptionId);
1559
+ this.#systemEvents.emit("subscription.added", { clientId, topic, options });
1560
+ if (options?.replay) {
1561
+ if (!this.#replay) {
1562
+ this.logger.warn("broker.replay.history_disabled", { clientId, topic });
1563
+ } else {
1564
+ this.#replay.start(clientId, topic, wrappedHandler, options.replay);
1565
+ }
1566
+ }
1567
+ return subscriptionId;
1568
+ }
1569
+ /**
1570
+ * Unsubscribe a client from a topic — removes every handler this client
1571
+ * has attached to the topic.
1572
+ *
1573
+ * @internal Called by {@link BrokerClient.off}. Not part of the public
1574
+ * `MessageBroker` contract.
1575
+ */
1576
+ unsubscribe(clientId, topic) {
1577
+ const removed = this.#subscriptions.unsubscribe(clientId, topic);
1578
+ if (removed.length === 0) return;
1579
+ for (const entry of removed) {
1580
+ this.#backpressure.removeOne(entry.id);
1581
+ }
1582
+ this.#systemEvents.emit("subscription.removed", { clientId, topic });
1583
+ }
1584
+ /**
1585
+ * Unsubscribe a single handler by its subscription id.
1586
+ *
1587
+ * Fires `subscription.removed` only if this was the last handler that
1588
+ * client had on the topic — otherwise the client is still subscribed.
1589
+ *
1590
+ * @internal Called by the unsubscribe closure returned from {@link BrokerClient.on}.
1591
+ */
1592
+ unsubscribeOne(subscriptionId) {
1593
+ if (subscriptionId === 0) return;
1594
+ const outcome = this.#subscriptions.unsubscribeOne(subscriptionId);
1595
+ if (!outcome) return;
1596
+ this.#backpressure.removeOne(outcome.entry.id);
1597
+ if (outcome.wasLast) {
1598
+ this.#systemEvents.emit("subscription.removed", {
1599
+ clientId: outcome.clientId,
1600
+ topic: outcome.topic
1601
+ });
1602
+ }
1603
+ }
1604
+ // ========================================
1605
+ // MESSAGE DELIVERY
1606
+ // ========================================
1607
+ /**
1608
+ * Process a message originating from a local client.
1609
+ *
1610
+ * Runs the full lifecycle pipeline: beforeSend → history → routing →
1611
+ * afterSend → forward to bridges.
1612
+ *
1613
+ * @param topic - Type of message
1614
+ * @param sender - Client ID of sender
1615
+ * @param recipient - Target recipient: specific ClientID (unicast) or '*' (multicast)
1616
+ * @param data - Message payload
1617
+ * @param options - Message options (history)
1618
+ * @returns Promise resolving to RoutingResult with delivery status
1619
+ *
1620
+ * @internal Called by {@link BrokerClient.emit} / {@link BrokerClient.request}.
1621
+ * Not part of the public `MessageBroker` contract.
1622
+ */
1623
+ async processMessage(topic, sender, recipient, data, options) {
1624
+ return this.#runPipeline(topic, sender, recipient, data, options, false, false);
1625
+ }
1626
+ /**
1627
+ * Broker-internal debug channel.
1628
+ *
1629
+ * `send()` runs the full message pipeline exactly like a normal
1630
+ * `Client.emit()` / `Client.request()` — routing, hooks, history and
1631
+ * bridge forwarding all apply — but with two differences:
1632
+ *
1633
+ * 1. `source` is an arbitrary string, not tied to a registered client.
1634
+ * Nothing gets reset in the client registry: safe to «impersonate»
1635
+ * any client id for testing subscribers without breaking that
1636
+ * client's own subscriptions.
1637
+ * 2. `message.synthetic === true` on the resulting Message, so
1638
+ * DevTools and integration tests can distinguish spoofed traffic
1639
+ * from production events (e.g. render a `SYNTHETIC` badge).
1640
+ *
1641
+ * Multicast vs unicast is picked by `target`: `'*'` fans out to all
1642
+ * subscribers, a specific `ClientID` targets one recipient and captures
1643
+ * that handler's return value in `RoutingResult.data`.
1644
+ *
1645
+ * The `$` prefix marks this as a broker-internal API — for DevTools
1646
+ * and integration tests, not for business code.
1647
+ */
1648
+ get $debug() {
1649
+ return {
1650
+ send: (source, topic, target, data, options) => {
1651
+ return this.#runPipeline(topic, source, target, data, options, false, true);
1652
+ }
1653
+ };
1654
+ }
1655
+ /**
1656
+ * Shared pipeline body for local {@link processMessage} and external
1657
+ * inject wired in {@link addBridge}.
1658
+ *
1659
+ * Pipeline stages:
1660
+ * 1. Create Message (assign id, timestamp) and deep-freeze it.
1661
+ * 2. Run `beforeSend` hooks. If any hook denies, short-circuit with
1662
+ * NACK(HOOK_REJECTED) — still fire `afterSend` so observers see the
1663
+ * rejection.
1664
+ * 3. Record to history — ONLY for local-origin messages that explicitly
1665
+ * opt in via `options.history`. External (injected) messages are
1666
+ * skipped: the sender-side broker has already recorded them; recording
1667
+ * again here would duplicate on every bridge hop.
1668
+ * 4. Route: unicast → one recipient, multicast (`*`) → all subscribers.
1669
+ * 5. Run `afterSend` hooks with the delivery result.
1670
+ * 6. Forward to bridges — ONLY for local-origin messages. External
1671
+ * messages are never bounced back to bridges; otherwise a bridge would
1672
+ * send what it just received right back to its transport.
1673
+ *
1674
+ * `fromExternal` gates stages 3 and 6 — the two places where local and
1675
+ * external paths diverge. `synthetic` is metadata-only: routing, hooks,
1676
+ * history and bridge forwarding all treat the message as real. Both
1677
+ * flags are internal — never on the public API.
1678
+ */
1679
+ async #runPipeline(topic, sender, recipient, data, options, fromExternal, synthetic) {
1680
+ if (this.#isDestroyed) {
1681
+ return RoutingResult.create("NACK", RoutingReason.BROKER_DESTROYED, "Broker is destroyed");
1682
+ }
1683
+ const message = this.#createMessage(topic, sender, recipient, data);
1684
+ if (fromExternal) {
1685
+ message.fromExternal = true;
1686
+ }
1687
+ if (synthetic) {
1688
+ message.synthetic = true;
1689
+ }
1690
+ const frozenMessage = deepFreeze(message);
1691
+ const hookResult = this.#hooks.beforeSend(frozenMessage);
1692
+ if (!hookResult.allowed) {
1693
+ const result2 = RoutingResult.create(
1694
+ "NACK",
1695
+ RoutingReason.HOOK_REJECTED,
1696
+ hookResult.message,
1697
+ recipient !== "*" ? recipient : void 0
1698
+ );
1699
+ this.#systemEvents.emit("message.rejected", {
1700
+ source: sender,
1701
+ target: recipient,
1702
+ topic,
1703
+ reason: hookResult.message
1704
+ });
1705
+ this.#hooks.afterSend(frozenMessage, result2);
1706
+ return result2;
1707
+ }
1708
+ if (this.#history && !fromExternal && options?.history === true) {
1709
+ this.#history.record(frozenMessage);
1710
+ }
1711
+ const result = recipient === "*" ? await this.#router.multicast(frozenMessage, sender) : await this.#router.unicast(frozenMessage, recipient);
1712
+ this.#hooks.afterSend(frozenMessage, result);
1713
+ if (!fromExternal) {
1714
+ this.#forwardToBridges(frozenMessage);
1715
+ }
1716
+ return result;
1717
+ }
1718
+ // ========================================
1719
+ // BRIDGE MANAGEMENT
1720
+ // ========================================
1721
+ /**
1722
+ * Add a bridge for cross-context communication (idempotent)
1723
+ *
1724
+ * If a bridge with the given ID already exists, the old bridge is destroyed
1725
+ * and replaced with the new one. This prevents duplicate bridges during HMR.
1726
+ *
1727
+ * @param id - Unique identifier for the bridge (e.g. 'cross-tab', 'iframe-checkout')
1728
+ * @param config - Bridge configuration (transport + forward patterns)
1729
+ * @returns Function to remove the bridge
1730
+ */
1731
+ addBridge(id, config) {
1732
+ if (this.#isDestroyed) {
1733
+ this.logger.warn("broker.bridge.add.after_destroy", { bridgeId: id });
1734
+ return () => {
1735
+ };
1736
+ }
1737
+ const existing = this.#bridges.get(id);
1738
+ if (existing) {
1739
+ this.logger.warn("broker.bridge.replaced", { bridgeId: id });
1740
+ existing.destroy();
1741
+ this.#systemEvents.emit("bridge.removed", { bridgeId: id });
1742
+ }
1743
+ const inject = (topic, sender, recipient, data) => this.#runPipeline(topic, sender, recipient, data, void 0, true, false);
1744
+ const bridge = new Bridge(inject, config, this.logger);
1745
+ this.#bridges.set(id, bridge);
1746
+ this.#systemEvents.emit("bridge.added", { bridgeId: id });
1747
+ return () => {
1748
+ if (this.#bridges.get(id) === bridge) {
1749
+ this.#bridges.delete(id);
1750
+ bridge.destroy();
1751
+ this.#systemEvents.emit("bridge.removed", { bridgeId: id });
1752
+ }
1753
+ };
1754
+ }
1755
+ // ========================================
1756
+ // CLIENT REGISTRY
1757
+ // ========================================
1758
+ /**
1759
+ * Register a client instance.
1760
+ *
1761
+ * @internal Called by the `BrokerClient` constructor.
1762
+ */
1763
+ registerClient(client) {
1764
+ if (this.#isDestroyed) {
1765
+ this.logger.warn("broker.client.register.after_destroy", { clientId: client.id });
1766
+ return;
1767
+ }
1768
+ this.#clientRegistry.register(client);
1769
+ this.#systemEvents.emit("client.registered", {
1770
+ clientId: client.id,
1771
+ at: this.#clientRegistry.getConnectedAt(client.id) ?? Date.now()
1772
+ });
1773
+ }
1774
+ /**
1775
+ * Unregister a client and remove all its subscriptions.
1776
+ *
1777
+ * @internal Called by {@link BrokerClient.destroy}.
1778
+ */
1779
+ unregisterClient(clientId) {
1780
+ const removed = this.#subscriptions.unsubscribeAll(clientId);
1781
+ this.#clientRegistry.unregister(clientId);
1782
+ for (const bucket of removed) {
1783
+ for (const entry of bucket.entries) {
1784
+ this.#backpressure.removeOne(entry.id);
1785
+ }
1786
+ this.#systemEvents.emit("subscription.removed", { clientId, topic: bucket.topic });
1787
+ }
1788
+ this.#systemEvents.emit("client.unregistered", { clientId, at: Date.now() });
1789
+ }
1790
+ /**
1791
+ * Get a registered client by ID.
1792
+ *
1793
+ * @param clientId - Unique client identifier
1794
+ * @returns Client instance or undefined if not found
1795
+ *
1796
+ * @internal Used by the `createClient` facade for idempotency checks.
1797
+ */
1798
+ getClient(clientId) {
1799
+ return this.#clientRegistry.get(clientId);
1800
+ }
1801
+ /**
1802
+ * Reset a client: clear all its subscriptions and backpressure strategies
1803
+ * while keeping the client registered.
1804
+ *
1805
+ * Used for idempotent client creation (HMR, re-mounting).
1806
+ * Iterates the client's subscriptions and calls unsubscribe() for each,
1807
+ * which correctly flushes/destroys backpressure strategies.
1808
+ *
1809
+ * @param clientId - Unique client identifier
1810
+ *
1811
+ * @internal Called by {@link BrokerClient.reset} and by the
1812
+ * `createClient` facade on idempotent re-creation.
1813
+ */
1814
+ resetClient(clientId) {
1815
+ const topics = this.#subscriptions.getClientTopics(clientId);
1816
+ if (topics) {
1817
+ for (const topic of [...topics]) {
1818
+ this.unsubscribe(clientId, topic);
1819
+ }
1820
+ }
1821
+ }
1822
+ // ========================================
1823
+ // HOOKS & EXTENSIBILITY
1824
+ // ========================================
1825
+ /**
1826
+ * Register a beforeSend hook
1827
+ *
1828
+ * Called before routing for ALL messages, including those from bridges.
1829
+ * Use message.fromExternal to distinguish local vs external if needed.
1830
+ */
1831
+ useBeforeSendHook(hook) {
1832
+ return this.#hooks.addBeforeSendHook(hook);
1833
+ }
1834
+ /**
1835
+ * Register an afterSend hook
1836
+ * Note: afterSend hooks are called for ALL messages (check message.fromExternal if needed)
1837
+ */
1838
+ useAfterSendHook(hook) {
1839
+ return this.#hooks.addAfterSendHook(hook);
1840
+ }
1841
+ /**
1842
+ * Register an onSubscribe hook
1843
+ */
1844
+ useOnSubscribeHook(hook) {
1845
+ return this.#hooks.addOnSubscribeHook(hook);
1846
+ }
1847
+ // ========================================
1848
+ // PRIVATE METHODS
1849
+ // ========================================
1850
+ /**
1851
+ * Forward message to all bridges that match the topic
1852
+ * @private
1853
+ */
1854
+ #forwardToBridges(message) {
1855
+ for (const bridge of this.#bridges.values()) {
1856
+ if (bridge.shouldForward(message.topic)) {
1857
+ bridge.send(message);
1858
+ }
1859
+ }
1860
+ }
1861
+ /**
1862
+ * Create a message with all required fields
1863
+ *
1864
+ * @private
1865
+ */
1866
+ #createMessage(topic, sender, recipient, data) {
1867
+ return {
1868
+ id: `${this.#sessionId}-${++this.#eventCounter}`,
1869
+ topic,
1870
+ source: sender,
1871
+ target: recipient,
1872
+ data,
1873
+ timestamp: Date.now()
1874
+ };
1875
+ }
1876
+ // ========================================
1877
+ // LIFECYCLE & CLEANUP
1878
+ // ========================================
1879
+ /**
1880
+ * Destroy the broker and clean up all resources
1881
+ */
1882
+ destroy() {
1883
+ if (this.#isDestroyed) {
1884
+ return;
1885
+ }
1886
+ this.#isDestroyed = true;
1887
+ for (const bridge of this.#bridges.values()) {
1888
+ bridge.destroy();
1889
+ }
1890
+ this.#bridges.clear();
1891
+ this.#hooks.clear();
1892
+ this.#history?.destroy();
1893
+ const cleared = this.#subscriptions.clear();
1894
+ for (const entry of cleared) {
1895
+ this.#backpressure.removeOne(entry.id);
1896
+ }
1897
+ this.#backpressure.destroy();
1898
+ this.#clientRegistry.clear();
1899
+ this.#systemEvents.clear();
1900
+ }
1901
+ };
1902
+
1903
+ // src/core/client/BrokerClient.ts
1904
+ var BrokerClient = class {
1905
+ id;
1906
+ #core;
1907
+ constructor(id, core2) {
1908
+ this.id = id;
1909
+ this.#core = core2;
1910
+ this.#core.registerClient(this);
1911
+ }
1912
+ /**
1913
+ * Subscribe to a topic with handler
1914
+ *
1915
+ * @param topic - Topic to subscribe to (e.g. 'user.login.v1')
1916
+ * @param handler - Message handler function
1917
+ * @param options - Subscription options (backpressure, replay)
1918
+ * @returns Unsubscribe function
1919
+ */
1920
+ on(topic, handler, options) {
1921
+ if (!handler) {
1922
+ throw new Error("BrokerClient requires explicit handler function");
1923
+ }
1924
+ const subscriptionId = this.#core.subscribe(
1925
+ this.id,
1926
+ topic,
1927
+ handler,
1928
+ options
1929
+ );
1930
+ return () => {
1931
+ this.#core.unsubscribeOne(subscriptionId);
1932
+ };
1933
+ }
1934
+ /**
1935
+ * Emit message to all subscribers (multicast)
1936
+ */
1937
+ async emit(topic, data, options) {
1938
+ return this.#core.processMessage(topic, this.id, "*", data, options);
1939
+ }
1940
+ /**
1941
+ * Send request to specific client (unicast).
1942
+ *
1943
+ * The recipient's handler return value (if any) is captured in
1944
+ * `RoutingResult.data`. Caller specifies `R` to type that payload.
1945
+ * The broker does not enforce that the handler actually returns `R` —
1946
+ * the cast happens at the boundary, same trust level as `as R`.
1947
+ */
1948
+ async request(recipient, topic, data, options) {
1949
+ return this.#core.processMessage(topic, this.id, recipient, data, options);
1950
+ }
1951
+ /**
1952
+ * Unsubscribe from a topic
1953
+ */
1954
+ off(topic) {
1955
+ this.#core.unsubscribe(this.id, topic);
1956
+ }
1957
+ /**
1958
+ * Reset client: clear all subscriptions and backpressure strategies
1959
+ * while keeping the client registered in the broker.
1960
+ *
1961
+ * After reset, the client can subscribe to messages again with fresh handlers.
1962
+ * Existing backpressure strategies are flushed and destroyed.
1963
+ */
1964
+ reset() {
1965
+ this.#core.resetClient(this.id);
1966
+ }
1967
+ /**
1968
+ * Destroy client and cleanup resources
1969
+ */
1970
+ destroy() {
1971
+ this.#core.unregisterClient(this.id);
1972
+ }
1973
+ };
1974
+
1975
+ // src/facade.ts
1976
+ var core = null;
1977
+ function initBroker(config) {
1978
+ if (core) {
1979
+ return core;
1980
+ }
1981
+ core = new BrokerCore(config);
1982
+ return core;
1983
+ }
1984
+ function createClient(id) {
1985
+ if (!core) {
1986
+ throw new Error(
1987
+ "MessageBroker not initialized. Call initBroker(config) first."
1988
+ );
1989
+ }
1990
+ const existing = core.getClient(id);
1991
+ if (existing) {
1992
+ core.logger.warn("facade.createClient.reset", { clientId: id });
1993
+ core.resetClient(id);
1994
+ return existing;
1995
+ }
1996
+ return new BrokerClient(id, core);
1997
+ }
1998
+ function getBroker() {
1999
+ if (!core) {
2000
+ throw new Error(
2001
+ "MessageBroker not initialized. Call initBroker(config) first."
2002
+ );
2003
+ }
2004
+ return core;
2005
+ }
2006
+ function destroyBroker() {
2007
+ core?.destroy();
2008
+ core = null;
2009
+ }
2010
+
2011
+ // src/transports/PostMessageTransport.ts
2012
+ var PostMessageTransport = class {
2013
+ #target;
2014
+ #origin;
2015
+ #allowedOrigins;
2016
+ #wildcardWarned = false;
2017
+ #messageHandler = null;
2018
+ #messageCallback = null;
2019
+ constructor(config) {
2020
+ this.#target = config.target;
2021
+ this.#origin = config.origin ?? "*";
2022
+ if (config.allowedOrigins !== void 0) {
2023
+ this.#allowedOrigins = [...config.allowedOrigins];
2024
+ } else if (this.#origin !== "*") {
2025
+ this.#allowedOrigins = [this.#origin];
2026
+ } else {
2027
+ this.#allowedOrigins = null;
2028
+ }
2029
+ }
2030
+ /**
2031
+ * Send data to target window via postMessage
2032
+ */
2033
+ send(data) {
2034
+ try {
2035
+ this.#target.postMessage(data, this.#origin);
2036
+ } catch (error) {
2037
+ console.error("[PostMessageTransport] Failed to send:", error);
2038
+ }
2039
+ }
2040
+ /**
2041
+ * Subscribe to incoming messages from target window
2042
+ */
2043
+ onMessage(callback) {
2044
+ this.#messageCallback = callback;
2045
+ if (this.#allowedOrigins === null && !this.#wildcardWarned) {
2046
+ this.#wildcardWarned = true;
2047
+ console.warn(
2048
+ "[PostMessageTransport] Listening with wildcard origin ('*') and no allowedOrigins \u2014 inbound messages from ANY origin will be accepted (source-window check still applies). Set `allowedOrigins` explicitly for cross-origin scenarios."
2049
+ );
2050
+ }
2051
+ this.#messageHandler = (e) => {
2052
+ if (e.source !== this.#target) {
2053
+ return;
2054
+ }
2055
+ if (this.#allowedOrigins !== null && !this.#allowedOrigins.includes(e.origin)) {
2056
+ console.warn(
2057
+ `[PostMessageTransport] Message from unauthorized origin: ${e.origin} (allowed: ${this.#allowedOrigins.join(", ")})`
2058
+ );
2059
+ return;
2060
+ }
2061
+ this.#messageCallback?.(e.data);
2062
+ };
2063
+ window.addEventListener("message", this.#messageHandler);
2064
+ return () => this.destroy();
2065
+ }
2066
+ /**
2067
+ * Cleanup: remove event listener
2068
+ */
2069
+ destroy() {
2070
+ if (this.#messageHandler) {
2071
+ window.removeEventListener("message", this.#messageHandler);
2072
+ this.#messageHandler = null;
2073
+ }
2074
+ this.#messageCallback = null;
2075
+ }
2076
+ };
2077
+
2078
+ // src/transports/BroadcastChannelTransport.ts
2079
+ var BroadcastChannelTransport = class {
2080
+ #channel;
2081
+ #messageCallback = null;
2082
+ /**
2083
+ * @param channelName - Unique channel name for this application
2084
+ */
2085
+ constructor(channelName) {
2086
+ this.#channel = new BroadcastChannel(channelName);
2087
+ }
2088
+ /**
2089
+ * Broadcast data to all other tabs
2090
+ */
2091
+ send(data) {
2092
+ try {
2093
+ this.#channel.postMessage(data);
2094
+ } catch (error) {
2095
+ console.error("[BroadcastChannelTransport] Failed to send:", error);
2096
+ }
2097
+ }
2098
+ /**
2099
+ * Subscribe to messages from other tabs
2100
+ */
2101
+ onMessage(callback) {
2102
+ this.#messageCallback = callback;
2103
+ this.#channel.onmessage = (e) => {
2104
+ this.#messageCallback?.(e.data);
2105
+ };
2106
+ return () => this.destroy();
2107
+ }
2108
+ /**
2109
+ * Cleanup: close the channel
2110
+ */
2111
+ destroy() {
2112
+ this.#channel.onmessage = null;
2113
+ this.#messageCallback = null;
2114
+ this.#channel.close();
2115
+ }
2116
+ };
2117
+
2118
+ // src/transports/WebSocketTransport.ts
2119
+ var WebSocketTransport = class {
2120
+ #socket;
2121
+ #messageCallback = null;
2122
+ #messageHandler = null;
2123
+ /**
2124
+ * @param socket - WebSocket instance (managed externally)
2125
+ */
2126
+ constructor(socket) {
2127
+ this.#socket = socket;
2128
+ }
2129
+ /**
2130
+ * Send data to server via WebSocket
2131
+ */
2132
+ send(data) {
2133
+ if (this.#socket.readyState !== WebSocket.OPEN) {
2134
+ console.warn("[WebSocketTransport] Cannot send: socket not open");
2135
+ return;
2136
+ }
2137
+ try {
2138
+ this.#socket.send(JSON.stringify(data));
2139
+ } catch (error) {
2140
+ console.error("[WebSocketTransport] Failed to send:", error);
2141
+ }
2142
+ }
2143
+ /**
2144
+ * Subscribe to messages from server
2145
+ */
2146
+ onMessage(callback) {
2147
+ this.#messageCallback = callback;
2148
+ this.#messageHandler = (e) => {
2149
+ try {
2150
+ const data = typeof e.data === "string" ? JSON.parse(e.data) : e.data;
2151
+ this.#messageCallback?.(data);
2152
+ } catch (error) {
2153
+ console.error("[WebSocketTransport] Failed to parse message:", error);
2154
+ }
2155
+ };
2156
+ this.#socket.addEventListener("message", this.#messageHandler);
2157
+ return () => this.destroy();
2158
+ }
2159
+ /**
2160
+ * Cleanup: remove listener (does NOT close socket)
2161
+ */
2162
+ destroy() {
2163
+ if (this.#messageHandler) {
2164
+ this.#socket.removeEventListener("message", this.#messageHandler);
2165
+ this.#messageHandler = null;
2166
+ }
2167
+ this.#messageCallback = null;
2168
+ }
2169
+ };
2170
+
2171
+ // src/transports/SSETransport.ts
2172
+ var SSETransport = class {
2173
+ #eventSource;
2174
+ #eventName;
2175
+ #messageHandler = null;
2176
+ #messageCallback = null;
2177
+ constructor(config) {
2178
+ this.#eventName = config.eventName ?? "message";
2179
+ this.#eventSource = new EventSource(config.url, {
2180
+ withCredentials: config.withCredentials ?? false
2181
+ });
2182
+ }
2183
+ /**
2184
+ * SSE has no upstream channel from the browser. This method exists to
2185
+ * satisfy the {@link BridgeTransport} contract but never actually
2186
+ * transmits — it logs a warning so misconfigurations surface early.
2187
+ *
2188
+ * Practical guidance: keep the bridge's `forward` list to topics that
2189
+ * are ONLY emitted by the server (never by local clients), so this
2190
+ * warning never fires in normal operation.
2191
+ */
2192
+ send(_data) {
2193
+ console.warn(
2194
+ "[SSETransport] send() is a no-op \u2014 SSE is inbound-only. Ensure the bridge forward list contains topics emitted only by the server, or use WebSocketTransport for duplex traffic."
2195
+ );
2196
+ }
2197
+ /**
2198
+ * Subscribe to incoming SSE messages. Parses JSON payloads before
2199
+ * forwarding to the bridge.
2200
+ */
2201
+ onMessage(callback) {
2202
+ this.#messageCallback = callback;
2203
+ this.#messageHandler = (e) => {
2204
+ try {
2205
+ const data = typeof e.data === "string" ? JSON.parse(e.data) : e.data;
2206
+ this.#messageCallback?.(data);
2207
+ } catch (error) {
2208
+ console.error("[SSETransport] Failed to parse message:", error);
2209
+ }
2210
+ };
2211
+ this.#eventSource.addEventListener(this.#eventName, this.#messageHandler);
2212
+ return () => this.destroy();
2213
+ }
2214
+ /**
2215
+ * Cleanup: remove listener and close the underlying EventSource.
2216
+ */
2217
+ destroy() {
2218
+ if (this.#messageHandler) {
2219
+ this.#eventSource.removeEventListener(this.#eventName, this.#messageHandler);
2220
+ this.#messageHandler = null;
2221
+ }
2222
+ this.#messageCallback = null;
2223
+ this.#eventSource.close();
2224
+ }
2225
+ };
2226
+
2227
+ exports.BroadcastChannelTransport = BroadcastChannelTransport;
2228
+ exports.PostMessageTransport = PostMessageTransport;
2229
+ exports.RoutingReason = RoutingReason;
2230
+ exports.SSETransport = SSETransport;
2231
+ exports.WebSocketTransport = WebSocketTransport;
2232
+ exports.createClient = createClient;
2233
+ exports.defaultLogger = defaultLogger;
2234
+ exports.destroyBroker = destroyBroker;
2235
+ exports.getBroker = getBroker;
2236
+ exports.initBroker = initBroker;
2237
+ //# sourceMappingURL=index.cjs.map
2238
+ //# sourceMappingURL=index.cjs.map