@mastra/valkey-streams 0.5.3-alpha.0 → 0.5.4-alpha.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/client.d.ts +8 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/index.cjs +254 -20
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +254 -20
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
package/dist/index.js
CHANGED
|
@@ -2,6 +2,12 @@ import { PubSub } from "@mastra/core/events";
|
|
|
2
2
|
import { Batch, Decoder, GlideClient, InfBoundary } from "@valkey/valkey-glide";
|
|
3
3
|
//#region src/client.ts
|
|
4
4
|
const text = (value) => Buffer.isBuffer(value) ? value.toString() : String(value);
|
|
5
|
+
const countArgs = (options) => options.COUNT === void 0 ? [] : ["COUNT", String(options.COUNT)];
|
|
6
|
+
/** Decode an XRANGE/XREVRANGE reply (`[{ key: id, value: [[field, value], …] }]`). */
|
|
7
|
+
const records = (value) => (value ?? []).map((entry) => ({
|
|
8
|
+
id: text(entry.key),
|
|
9
|
+
message: Object.fromEntries((entry.value ?? []).map(([field, fieldValue]) => [text(field), text(fieldValue)]))
|
|
10
|
+
}));
|
|
5
11
|
var ValkeyMulti = class {
|
|
6
12
|
getClient;
|
|
7
13
|
#batch = new Batch(true);
|
|
@@ -119,6 +125,32 @@ var ValkeyStreamsClient = class {
|
|
|
119
125
|
message: Object.fromEntries(fields.map(([field, value]) => [text(field), text(value)]))
|
|
120
126
|
}));
|
|
121
127
|
}
|
|
128
|
+
async xRange(key, start, end, options = {}) {
|
|
129
|
+
return records(await this.command([
|
|
130
|
+
"XRANGE",
|
|
131
|
+
key,
|
|
132
|
+
start,
|
|
133
|
+
end,
|
|
134
|
+
...countArgs(options)
|
|
135
|
+
]));
|
|
136
|
+
}
|
|
137
|
+
async xRevRange(key, end, start, options = {}) {
|
|
138
|
+
return records(await this.command([
|
|
139
|
+
"XREVRANGE",
|
|
140
|
+
key,
|
|
141
|
+
end,
|
|
142
|
+
start,
|
|
143
|
+
...countArgs(options)
|
|
144
|
+
]));
|
|
145
|
+
}
|
|
146
|
+
/** XINFO GROUPS <key>, one object per group keyed by field name. */
|
|
147
|
+
async xInfoGroups(key) {
|
|
148
|
+
return (await this.command([
|
|
149
|
+
"XINFO",
|
|
150
|
+
"GROUPS",
|
|
151
|
+
key
|
|
152
|
+
]) ?? []).map((group) => Object.fromEntries(group.map(({ key, value }) => [text(key), value])));
|
|
153
|
+
}
|
|
122
154
|
async xAck(key, group, id) {
|
|
123
155
|
return (await this.getClient()).xack(key, group, [id]);
|
|
124
156
|
}
|
|
@@ -174,6 +206,8 @@ const createClient = (options) => new ValkeyStreamsClient(options);
|
|
|
174
206
|
//#region src/index.ts
|
|
175
207
|
/** Page size for the reclaim loop's XPENDING scan and the max entries claimed per tick. */
|
|
176
208
|
const RECLAIM_PAGE_SIZE = 100;
|
|
209
|
+
/** Page size for replaying existing entries to a fan-out subscriber joining a shared reader. */
|
|
210
|
+
const REPLAY_PAGE_SIZE = 500;
|
|
177
211
|
/**
|
|
178
212
|
* Heartbeat: reset a pending entry's idle time only if this consumer still
|
|
179
213
|
* owns it, so a late heartbeat never takes an entry back from a sibling.
|
|
@@ -222,6 +256,15 @@ function errorText(err) {
|
|
|
222
256
|
if (err instanceof Error && "replies" in err && Array.isArray(err.replies)) parts.push(...err.replies.map((r) => String(r)));
|
|
223
257
|
return parts.join("; ");
|
|
224
258
|
}
|
|
259
|
+
/** Order two stream ids (`<ms>-<seq>`). */
|
|
260
|
+
function compareStreamIds(a, b) {
|
|
261
|
+
const [aMs = "0", aSeq = "0"] = a.split("-");
|
|
262
|
+
const [bMs = "0", bSeq = "0"] = b.split("-");
|
|
263
|
+
const ms = BigInt(aMs) - BigInt(bMs);
|
|
264
|
+
if (ms !== 0n) return ms < 0n ? -1 : 1;
|
|
265
|
+
const seq = BigInt(aSeq) - BigInt(bSeq);
|
|
266
|
+
return seq === 0n ? 0 : seq < 0n ? -1 : 1;
|
|
267
|
+
}
|
|
225
268
|
var ValkeyStreamsPubSub = class extends PubSub {
|
|
226
269
|
get supportedModes() {
|
|
227
270
|
return ["pull"];
|
|
@@ -244,6 +287,8 @@ var ValkeyStreamsPubSub = class extends PubSub {
|
|
|
244
287
|
#inFlightTimeoutMs;
|
|
245
288
|
#logger;
|
|
246
289
|
#subscriptions = /* @__PURE__ */ new Map();
|
|
290
|
+
#fanoutReaders = /* @__PURE__ */ new Map();
|
|
291
|
+
#pendingFanoutReaders = /* @__PURE__ */ new Map();
|
|
247
292
|
#cbIds = /* @__PURE__ */ new WeakMap();
|
|
248
293
|
#pendingPublishes = /* @__PURE__ */ new Set();
|
|
249
294
|
#localCallbacks = /* @__PURE__ */ new Map();
|
|
@@ -373,11 +418,93 @@ var ValkeyStreamsPubSub = class extends PubSub {
|
|
|
373
418
|
}
|
|
374
419
|
localBucket.add(cb);
|
|
375
420
|
await this.#ensureWriterConnected();
|
|
376
|
-
const
|
|
377
|
-
|
|
421
|
+
const groupAnchor = options?.startFrom === "latest" ? "$" : "0";
|
|
422
|
+
if (options?.group) {
|
|
423
|
+
const sub = await this.#openSubscription(topic, cb, options.group, groupAnchor);
|
|
424
|
+
this.#subscriptions.set(key, sub);
|
|
425
|
+
sub.loop = this.#runReadLoop(sub);
|
|
426
|
+
this.#startReclaimLoop(sub);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
for (;;) {
|
|
430
|
+
const reader = this.#fanoutReaders.get(topic);
|
|
431
|
+
if (reader) return this.#joinFanoutReader(reader, cb, key, groupAnchor);
|
|
432
|
+
const opening = this.#pendingFanoutReaders.get(topic);
|
|
433
|
+
if (!opening) break;
|
|
434
|
+
await opening.catch(() => {});
|
|
435
|
+
}
|
|
436
|
+
const opening = this.#openSubscription(topic, cb, void 0, groupAnchor);
|
|
437
|
+
this.#pendingFanoutReaders.set(topic, opening);
|
|
438
|
+
try {
|
|
439
|
+
const sub = await opening;
|
|
440
|
+
this.#fanoutReaders.set(topic, sub);
|
|
441
|
+
this.#subscriptions.set(key, sub);
|
|
442
|
+
sub.loop = this.#runReadLoop(sub);
|
|
443
|
+
this.#startReclaimLoop(sub);
|
|
444
|
+
} finally {
|
|
445
|
+
this.#pendingFanoutReaders.delete(topic);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Add a callback to a topic's open fan-out reader. The reader may already
|
|
450
|
+
* have delivered entries this subscriber should see (`startFrom: 'earliest'`)
|
|
451
|
+
* or still be catching up on entries it should not (`startFrom: 'latest'`),
|
|
452
|
+
* so the new member buffers live entries while its starting point is
|
|
453
|
+
* resolved, replays what it missed, then switches to live delivery.
|
|
454
|
+
*/
|
|
455
|
+
async #joinFanoutReader(sub, cb, key, anchor) {
|
|
456
|
+
if (sub.members.has(cb)) {
|
|
457
|
+
this.#subscriptions.set(key, sub);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
const member = { joining: [] };
|
|
461
|
+
sub.members.set(cb, member);
|
|
462
|
+
this.#subscriptions.set(key, sub);
|
|
463
|
+
const active = () => sub.members.get(cb) === member && !sub.stopped;
|
|
464
|
+
try {
|
|
465
|
+
if (anchor === "$") {
|
|
466
|
+
const [tail] = await this.#writeClient.xRevRange(sub.streamKey, "+", "-", { COUNT: 1 });
|
|
467
|
+
member.skipThrough = tail?.id;
|
|
468
|
+
} else {
|
|
469
|
+
let boundary;
|
|
470
|
+
try {
|
|
471
|
+
const info = (await this.#writeClient.xInfoGroups(sub.streamKey)).find((g) => String(g.name) === sub.group);
|
|
472
|
+
boundary = info ? String(info["last-delivered-id"]) : void 0;
|
|
473
|
+
} catch (err) {
|
|
474
|
+
if (!errorText(err).match(/no such key|NOGROUP/i)) throw err;
|
|
475
|
+
}
|
|
476
|
+
if (boundary && boundary !== "0-0") {
|
|
477
|
+
member.skipThrough = boundary;
|
|
478
|
+
let start = "-";
|
|
479
|
+
while (active()) {
|
|
480
|
+
const page = await this.#writeClient.xRange(sub.streamKey, start, boundary, { COUNT: REPLAY_PAGE_SIZE });
|
|
481
|
+
for (const entry of page) {
|
|
482
|
+
if (!active()) break;
|
|
483
|
+
this.#deliverDetached(sub, cb, entry.message);
|
|
484
|
+
}
|
|
485
|
+
if (page.length < REPLAY_PAGE_SIZE) break;
|
|
486
|
+
start = `(${page[page.length - 1].id}`;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
} catch (err) {
|
|
491
|
+
sub.members.delete(cb);
|
|
492
|
+
this.#subscriptions.delete(key);
|
|
493
|
+
throw err;
|
|
494
|
+
}
|
|
495
|
+
if (!active()) return;
|
|
496
|
+
const buffered = member.joining ?? [];
|
|
497
|
+
for (const entry of buffered) {
|
|
498
|
+
if (member.skipThrough && compareStreamIds(entry.id, member.skipThrough) <= 0) continue;
|
|
499
|
+
this.#deliverDetached(sub, cb, entry.fields);
|
|
500
|
+
}
|
|
501
|
+
member.joining = void 0;
|
|
502
|
+
}
|
|
503
|
+
async #openSubscription(topic, cb, groupName, groupAnchor) {
|
|
504
|
+
const isGrouped = groupName !== void 0;
|
|
505
|
+
const group = groupName ?? `__fanout-${globalThis.crypto.randomUUID()}`;
|
|
378
506
|
const consumer = `${group}-${globalThis.crypto.randomUUID()}`;
|
|
379
507
|
const streamKey = this.#streamKey(topic);
|
|
380
|
-
const groupAnchor = options?.startFrom === "latest" ? "$" : "0";
|
|
381
508
|
try {
|
|
382
509
|
if (this.#streamIdleTtlMs > 0) await this.#writeClient.multi().xGroupCreate(streamKey, group, groupAnchor, { MKSTREAM: true }).pExpire(streamKey, this.#streamIdleTtlMs).exec();
|
|
383
510
|
else await this.#writeClient.xGroupCreate(streamKey, group, groupAnchor, { MKSTREAM: true });
|
|
@@ -391,8 +518,9 @@ var ValkeyStreamsPubSub = class extends PubSub {
|
|
|
391
518
|
const readClient = createClient(this.#connectOptions);
|
|
392
519
|
this.#attachErrorLogger(readClient, "read", { topic });
|
|
393
520
|
await readClient.connect();
|
|
394
|
-
|
|
395
|
-
cb,
|
|
521
|
+
return {
|
|
522
|
+
members: /* @__PURE__ */ new Map([[cb, {}]]),
|
|
523
|
+
settling: /* @__PURE__ */ new Map(),
|
|
396
524
|
topic,
|
|
397
525
|
streamKey,
|
|
398
526
|
group,
|
|
@@ -406,9 +534,6 @@ var ValkeyStreamsPubSub = class extends PubSub {
|
|
|
406
534
|
reclaimTimer: void 0,
|
|
407
535
|
inFlight: /* @__PURE__ */ new Map()
|
|
408
536
|
};
|
|
409
|
-
this.#subscriptions.set(key, sub);
|
|
410
|
-
sub.loop = this.#runReadLoop(sub);
|
|
411
|
-
this.#startReclaimLoop(sub);
|
|
412
537
|
}
|
|
413
538
|
/**
|
|
414
539
|
* Periodically reclaim idle pending entries in this subscription's group so
|
|
@@ -499,11 +624,21 @@ var ValkeyStreamsPubSub = class extends PubSub {
|
|
|
499
624
|
const sub = this.#subscriptions.get(key);
|
|
500
625
|
if (!sub) return;
|
|
501
626
|
this.#subscriptions.delete(key);
|
|
627
|
+
if (sub.teardown) return sub.teardown;
|
|
628
|
+
if (sub.members.size > 1 && sub.members.delete(cb)) {
|
|
629
|
+
for (const settle of [...sub.settling.values()]) settle(cb);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
if (this.#fanoutReaders.get(topic) === sub) this.#fanoutReaders.delete(topic);
|
|
502
633
|
sub.stopped = true;
|
|
503
634
|
if (sub.reclaimTimer) {
|
|
504
635
|
clearTimeout(sub.reclaimTimer);
|
|
505
636
|
sub.reclaimTimer = void 0;
|
|
506
637
|
}
|
|
638
|
+
sub.teardown = this.#stopSubscription(sub);
|
|
639
|
+
return sub.teardown;
|
|
640
|
+
}
|
|
641
|
+
async #stopSubscription(sub) {
|
|
507
642
|
try {
|
|
508
643
|
await sub.readClient.quit();
|
|
509
644
|
} catch (err) {
|
|
@@ -559,6 +694,7 @@ var ValkeyStreamsPubSub = class extends PubSub {
|
|
|
559
694
|
for (const sub of this.#subscriptions.values()) if (sub.topic === topic) {
|
|
560
695
|
sub.lastId = void 0;
|
|
561
696
|
sub.groupAnchor = "0";
|
|
697
|
+
for (const member of sub.members.values()) member.skipThrough = void 0;
|
|
562
698
|
}
|
|
563
699
|
} catch (err) {
|
|
564
700
|
this.#logger?.warn?.("valkey-streams: clearTopic failed", {
|
|
@@ -685,8 +821,8 @@ var ValkeyStreamsPubSub = class extends PubSub {
|
|
|
685
821
|
async close() {
|
|
686
822
|
if (this.#closed) return;
|
|
687
823
|
this.#closed = true;
|
|
688
|
-
const subs =
|
|
689
|
-
await Promise.all(subs.
|
|
824
|
+
const subs = new Set(this.#subscriptions.values());
|
|
825
|
+
await Promise.all([...subs].flatMap((sub) => [...sub.members.keys()].map((cb) => this.unsubscribe(sub.topic, cb))));
|
|
690
826
|
this.#localCallbacks.clear();
|
|
691
827
|
await this.flush();
|
|
692
828
|
if (this.#writeClient.isOpen) try {
|
|
@@ -742,15 +878,11 @@ var ValkeyStreamsPubSub = class extends PubSub {
|
|
|
742
878
|
}
|
|
743
879
|
}
|
|
744
880
|
async #deliverMessage(sub, streamId, fields) {
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
event = JSON.parse(fields.event ?? "{}");
|
|
748
|
-
if (typeof event.createdAt === "string") event.createdAt = new Date(event.createdAt);
|
|
749
|
-
} catch (err) {
|
|
881
|
+
const event = this.#parseEvent(fields);
|
|
882
|
+
if (!event) {
|
|
750
883
|
this.#logger?.debug?.("valkey-streams: malformed payload, dropping", {
|
|
751
884
|
topic: sub.topic,
|
|
752
|
-
streamId
|
|
753
|
-
err: err instanceof Error ? err.message : err
|
|
885
|
+
streamId
|
|
754
886
|
});
|
|
755
887
|
try {
|
|
756
888
|
await this.#writeClient.xAck(sub.streamKey, sub.group, streamId);
|
|
@@ -832,6 +964,7 @@ var ValkeyStreamsPubSub = class extends PubSub {
|
|
|
832
964
|
const expire = async () => {
|
|
833
965
|
if (settled) return;
|
|
834
966
|
settled = true;
|
|
967
|
+
sub.settling.delete(streamId);
|
|
835
968
|
const attempt = event.deliveryAttempt ?? 1;
|
|
836
969
|
const drop = attempt >= this.#maxDeliveryAttempts;
|
|
837
970
|
const payload = drop ? "" : JSON.stringify({
|
|
@@ -857,7 +990,12 @@ var ValkeyStreamsPubSub = class extends PubSub {
|
|
|
857
990
|
err: err instanceof Error ? err.message : err
|
|
858
991
|
});
|
|
859
992
|
settled = false;
|
|
860
|
-
sub.
|
|
993
|
+
sub.settling.set(streamId, (cb) => settleMember(cb, false));
|
|
994
|
+
if (sub.isGrouped) sub.inFlight.delete(streamId);
|
|
995
|
+
else sub.inFlight.set(streamId, {
|
|
996
|
+
since: Date.now(),
|
|
997
|
+
expire
|
|
998
|
+
});
|
|
861
999
|
return;
|
|
862
1000
|
}
|
|
863
1001
|
sub.inFlight.delete(streamId);
|
|
@@ -890,19 +1028,115 @@ var ValkeyStreamsPubSub = class extends PubSub {
|
|
|
890
1028
|
]
|
|
891
1029
|
});
|
|
892
1030
|
};
|
|
1031
|
+
const waiting = /* @__PURE__ */ new Set();
|
|
1032
|
+
let anyNack = false;
|
|
1033
|
+
const settleMember = async (cb, nacked) => {
|
|
1034
|
+
if (settled || !waiting.delete(cb)) return;
|
|
1035
|
+
if (nacked) anyNack = true;
|
|
1036
|
+
if (waiting.size > 0) return;
|
|
1037
|
+
sub.settling.delete(streamId);
|
|
1038
|
+
await (anyNack ? nack() : ack());
|
|
1039
|
+
};
|
|
1040
|
+
const targets = [];
|
|
1041
|
+
for (const [cb, member] of sub.members) {
|
|
1042
|
+
if (member.joining) {
|
|
1043
|
+
member.joining.push({
|
|
1044
|
+
id: streamId,
|
|
1045
|
+
fields
|
|
1046
|
+
});
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
if (member.skipThrough) {
|
|
1050
|
+
if (compareStreamIds(streamId, member.skipThrough) <= 0) continue;
|
|
1051
|
+
member.skipThrough = void 0;
|
|
1052
|
+
}
|
|
1053
|
+
waiting.add(cb);
|
|
1054
|
+
targets.push(cb);
|
|
1055
|
+
}
|
|
1056
|
+
if (targets.length === 0) {
|
|
1057
|
+
await ack();
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
893
1060
|
sub.inFlight.set(streamId, {
|
|
894
1061
|
since: Date.now(),
|
|
895
1062
|
expire
|
|
896
1063
|
});
|
|
1064
|
+
sub.settling.set(streamId, (cb) => settleMember(cb, false));
|
|
1065
|
+
for (const cb of targets) {
|
|
1066
|
+
const memberEvent = targets.length === 1 ? event : this.#parseEvent(fields);
|
|
1067
|
+
this.#invokeCallback(cb, memberEvent, () => settleMember(cb, false), () => settleMember(cb, true), extend);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1071
|
+
* Invoke a handler without awaiting it. EventCallback is typed `=> void`
|
|
1072
|
+
* but handlers commonly return a promise (TS allows Promise<void> to satisfy
|
|
1073
|
+
* void); a rejection or synchronous throw routes to nack instead of silently
|
|
1074
|
+
* dropping the message. We do NOT await — serializing messages on a
|
|
1075
|
+
* subscription would deadlock orchestration callbacks that await their own
|
|
1076
|
+
* future events.
|
|
1077
|
+
*/
|
|
1078
|
+
#invokeCallback(cb, event, ack, nack, extend) {
|
|
897
1079
|
try {
|
|
898
|
-
const result =
|
|
1080
|
+
const result = cb(event, ack, nack, extend);
|
|
899
1081
|
if (result && typeof result.catch === "function") result.catch(async () => {
|
|
900
1082
|
await nack();
|
|
901
1083
|
});
|
|
902
1084
|
} catch {
|
|
903
|
-
|
|
1085
|
+
nack();
|
|
904
1086
|
}
|
|
905
1087
|
}
|
|
1088
|
+
#parseEvent(fields) {
|
|
1089
|
+
try {
|
|
1090
|
+
const event = JSON.parse(fields.event ?? "{}");
|
|
1091
|
+
if (typeof event.createdAt === "string") event.createdAt = new Date(event.createdAt);
|
|
1092
|
+
return event;
|
|
1093
|
+
} catch {
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Deliver an entry to one fan-out member outside the shared group's PEL:
|
|
1099
|
+
* entries the topic's reader handed out before the member joined. Ack is a
|
|
1100
|
+
* no-op (the group settles the entry on its own); nack republishes, which
|
|
1101
|
+
* redelivers to every member just as a nack on the shared entry would.
|
|
1102
|
+
*/
|
|
1103
|
+
#deliverDetached(sub, cb, fields) {
|
|
1104
|
+
const event = this.#parseEvent(fields);
|
|
1105
|
+
if (!event) return;
|
|
1106
|
+
let settled = false;
|
|
1107
|
+
const nack = async () => {
|
|
1108
|
+
if (settled) return;
|
|
1109
|
+
settled = true;
|
|
1110
|
+
const attempt = event.deliveryAttempt ?? 1;
|
|
1111
|
+
if (attempt >= this.#maxDeliveryAttempts) {
|
|
1112
|
+
this.#logger?.warn?.("valkey-streams: dropping event after max delivery attempts", {
|
|
1113
|
+
topic: sub.topic,
|
|
1114
|
+
eventType: event.type,
|
|
1115
|
+
eventId: event.id,
|
|
1116
|
+
attempt,
|
|
1117
|
+
max: this.#maxDeliveryAttempts
|
|
1118
|
+
});
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
const payload = { event: JSON.stringify({
|
|
1122
|
+
...event,
|
|
1123
|
+
deliveryAttempt: attempt + 1
|
|
1124
|
+
}) };
|
|
1125
|
+
try {
|
|
1126
|
+
if (this.#streamIdleTtlMs > 0) await this.#writeClient.multi().xAdd(sub.streamKey, "*", payload).pExpire(sub.streamKey, this.#streamIdleTtlMs).exec();
|
|
1127
|
+
else await this.#writeClient.xAdd(sub.streamKey, "*", payload);
|
|
1128
|
+
} catch (err) {
|
|
1129
|
+
this.#logger?.warn?.("valkey-streams: nack republish failed for replayed event", {
|
|
1130
|
+
topic: sub.topic,
|
|
1131
|
+
eventId: event.id,
|
|
1132
|
+
err: err instanceof Error ? err.message : err
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
};
|
|
1136
|
+
this.#invokeCallback(cb, event, async () => {
|
|
1137
|
+
settled = true;
|
|
1138
|
+
}, nack);
|
|
1139
|
+
}
|
|
906
1140
|
#deliverLocal(topic, event) {
|
|
907
1141
|
const callbacks = this.#localCallbacks.get(topic);
|
|
908
1142
|
if (!callbacks || callbacks.size === 0) return;
|