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