@peerbit/pubsub 4.1.4-bbf27fa → 4.1.4-cb91e7b

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +23 -20
  2. package/dist/benchmark/fanout-tree-sim-lib.d.ts +201 -0
  3. package/dist/benchmark/fanout-tree-sim-lib.d.ts.map +1 -0
  4. package/dist/benchmark/fanout-tree-sim-lib.js +1225 -0
  5. package/dist/benchmark/fanout-tree-sim-lib.js.map +1 -0
  6. package/dist/benchmark/fanout-tree-sim.d.ts +11 -0
  7. package/dist/benchmark/fanout-tree-sim.d.ts.map +1 -0
  8. package/dist/benchmark/fanout-tree-sim.js +521 -0
  9. package/dist/benchmark/fanout-tree-sim.js.map +1 -0
  10. package/dist/benchmark/index.d.ts +6 -0
  11. package/dist/benchmark/index.d.ts.map +1 -1
  12. package/dist/benchmark/index.js +38 -80
  13. package/dist/benchmark/index.js.map +1 -1
  14. package/dist/benchmark/pubsub-topic-sim-lib.d.ts +82 -0
  15. package/dist/benchmark/pubsub-topic-sim-lib.d.ts.map +1 -0
  16. package/dist/benchmark/pubsub-topic-sim-lib.js +625 -0
  17. package/dist/benchmark/pubsub-topic-sim-lib.js.map +1 -0
  18. package/dist/benchmark/pubsub-topic-sim.d.ts +9 -0
  19. package/dist/benchmark/pubsub-topic-sim.d.ts.map +1 -0
  20. package/dist/benchmark/pubsub-topic-sim.js +116 -0
  21. package/dist/benchmark/pubsub-topic-sim.js.map +1 -0
  22. package/dist/benchmark/sim/bench-utils.d.ts +25 -0
  23. package/dist/benchmark/sim/bench-utils.d.ts.map +1 -0
  24. package/dist/benchmark/sim/bench-utils.js +141 -0
  25. package/dist/benchmark/sim/bench-utils.js.map +1 -0
  26. package/dist/src/fanout-channel.d.ts +62 -0
  27. package/dist/src/fanout-channel.d.ts.map +1 -0
  28. package/dist/src/fanout-channel.js +114 -0
  29. package/dist/src/fanout-channel.js.map +1 -0
  30. package/dist/src/fanout-tree.d.ts +550 -0
  31. package/dist/src/fanout-tree.d.ts.map +1 -0
  32. package/dist/src/fanout-tree.js +4943 -0
  33. package/dist/src/fanout-tree.js.map +1 -0
  34. package/dist/src/index.d.ts +161 -39
  35. package/dist/src/index.d.ts.map +1 -1
  36. package/dist/src/index.js +1352 -455
  37. package/dist/src/index.js.map +1 -1
  38. package/dist/src/topic-root-control-plane.d.ts +43 -0
  39. package/dist/src/topic-root-control-plane.d.ts.map +1 -0
  40. package/dist/src/topic-root-control-plane.js +120 -0
  41. package/dist/src/topic-root-control-plane.js.map +1 -0
  42. package/package.json +10 -9
  43. package/src/fanout-channel.ts +150 -0
  44. package/src/fanout-tree.ts +6297 -0
  45. package/src/index.ts +1612 -595
  46. package/src/topic-root-control-plane.ts +160 -0
package/dist/src/index.js CHANGED
@@ -1,14 +1,19 @@
1
1
  import {} from "@libp2p/interface";
2
2
  import { PublicSignKey, getPublicKeyFromPeerId } from "@peerbit/crypto";
3
3
  import { logger as loggerFn } from "@peerbit/logger";
4
- import { DataEvent, GetSubscribers, PubSubData, PubSubMessage, PublishEvent, Subscribe, SubscriptionData, SubscriptionEvent, UnsubcriptionEvent, Unsubscribe, } from "@peerbit/pubsub-interface";
4
+ import { DataEvent, GetSubscribers, PubSubData, PubSubMessage, PublishEvent, Subscribe, SubscriptionData, SubscriptionEvent, TopicRootCandidates, UnsubcriptionEvent, Unsubscribe, } from "@peerbit/pubsub-interface";
5
5
  import { DirectStream, dontThrowIfDeliveryError, } from "@peerbit/stream";
6
- import { AcknowledgeDelivery, AnyWhere, DataMessage, DeliveryError, MessageHeader, NotStartedError, SeekDelivery, SilentDelivery, deliveryModeHasReceiver, } from "@peerbit/stream-interface";
6
+ import { AcknowledgeAnyWhere, AcknowledgeDelivery, AnyWhere, DataMessage, DeliveryError, MessageHeader, NotStartedError, SilentDelivery, deliveryModeHasReceiver, getMsgId, } from "@peerbit/stream-interface";
7
7
  import { AbortError, TimeoutError } from "@peerbit/time";
8
8
  import { Uint8ArrayList } from "uint8arraylist";
9
9
  import { debouncedAccumulatorSetCounter, } from "./debounced-set.js";
10
+ import { FanoutChannel } from "./fanout-channel.js";
11
+ import { TopicRootControlPlane } from "./topic-root-control-plane.js";
12
+ export * from "./fanout-tree.js";
13
+ export * from "./fanout-channel.js";
14
+ export * from "./topic-root-control-plane.js";
10
15
  export const toUint8Array = (arr) => arr instanceof Uint8ArrayList ? arr.subarray() : arr;
11
- export const logger = loggerFn("peerbit:transport:lazysub");
16
+ export const logger = loggerFn("peerbit:transport:topic-control-plane");
12
17
  const warn = logger.newScope("warn");
13
18
  const logError = (e) => {
14
19
  logger.error(e?.message);
@@ -16,560 +21,1371 @@ const logError = (e) => {
16
21
  const logErrorIfStarted = (e) => {
17
22
  e instanceof NotStartedError === false && logError(e);
18
23
  };
19
- export class DirectSub extends DirectStream {
20
- topics; // topic -> peers --> Uint8Array subscription metadata (the latest received)
21
- peerToTopic; // peer -> topics
22
- topicsToPeers; // topic -> peers
23
- subscriptions; // topic -> subscription ids
24
+ const withAbort = async (promise, signal) => {
25
+ if (!signal)
26
+ return promise;
27
+ if (signal.aborted) {
28
+ throw signal.reason ?? new AbortError("Operation was aborted");
29
+ }
30
+ return new Promise((resolve, reject) => {
31
+ const onAbort = () => {
32
+ cleanup();
33
+ reject(signal.reason ?? new AbortError("Operation was aborted"));
34
+ };
35
+ const cleanup = () => {
36
+ try {
37
+ signal.removeEventListener("abort", onAbort);
38
+ }
39
+ catch {
40
+ // ignore
41
+ }
42
+ };
43
+ signal.addEventListener("abort", onAbort, { once: true });
44
+ promise.then((v) => {
45
+ cleanup();
46
+ resolve(v);
47
+ }, (e) => {
48
+ cleanup();
49
+ reject(e);
50
+ });
51
+ });
52
+ };
53
+ const SUBSCRIBER_CACHE_MAX_ENTRIES_HARD_CAP = 100_000;
54
+ const SUBSCRIBER_CACHE_DEFAULT_MAX_ENTRIES = 4_096;
55
+ const DEFAULT_FANOUT_PUBLISH_IDLE_CLOSE_MS = 60_000;
56
+ const DEFAULT_FANOUT_PUBLISH_MAX_EPHEMERAL_CHANNELS = 64;
57
+ const DEFAULT_PUBSUB_SHARD_COUNT = 256;
58
+ const PUBSUB_SHARD_COUNT_HARD_CAP = 16_384;
59
+ const DEFAULT_PUBSUB_SHARD_TOPIC_PREFIX = "/peerbit/pubsub-shard/1/";
60
+ const AUTO_TOPIC_ROOT_CANDIDATES_MAX = 64;
61
+ const DEFAULT_PUBSUB_FANOUT_CHANNEL_OPTIONS = {
62
+ msgRate: 30,
63
+ msgSize: 1024,
64
+ uploadLimitBps: 5_000_000,
65
+ maxChildren: 24,
66
+ repair: true,
67
+ };
68
+ const topicHash32 = (topic) => {
69
+ let hash = 0x811c9dc5; // FNV-1a
70
+ for (let index = 0; index < topic.length; index++) {
71
+ hash ^= topic.charCodeAt(index);
72
+ hash = (hash * 0x01000193) >>> 0;
73
+ }
74
+ return hash >>> 0;
75
+ };
76
+ /**
77
+ * Runtime control-plane implementation for pubsub topic membership + forwarding.
78
+ */
79
+ export class TopicControlPlane extends DirectStream {
80
+ // Tracked topics -> remote subscribers (best-effort).
81
+ topics;
82
+ // Remote peer -> tracked topics.
83
+ peerToTopic;
84
+ // Local topic -> reference count.
85
+ subscriptions;
24
86
  lastSubscriptionMessages = new Map();
25
87
  dispatchEventOnSelfPublish;
88
+ topicRootControlPlane;
89
+ subscriberCacheMaxEntries;
90
+ fanout;
26
91
  debounceSubscribeAggregator;
27
92
  debounceUnsubscribeAggregator;
93
+ shardCount;
94
+ shardTopicPrefix;
95
+ hostShards;
96
+ shardRootCache = new Map();
97
+ shardTopicCache = new Map();
98
+ shardRefCounts = new Map();
99
+ pinnedShards = new Set();
100
+ fanoutRootChannelOptions;
101
+ fanoutNodeChannelOptions;
102
+ fanoutJoinOptions;
103
+ fanoutPublishRequiresSubscribe;
104
+ fanoutPublishIdleCloseMs;
105
+ fanoutPublishMaxEphemeralChannels;
106
+ // If no shard-root candidates are configured, we fall back to an "auto" mode:
107
+ // start with `[self]` and expand candidates as underlay peers connect.
108
+ // This keeps small ad-hoc networks working without explicit bootstraps.
109
+ autoTopicRootCandidates = false;
110
+ autoTopicRootCandidateSet;
111
+ reconcileShardOverlaysInFlight;
112
+ autoCandidatesBroadcastTimers = [];
113
+ autoCandidatesGossipInterval;
114
+ autoCandidatesGossipUntil = 0;
115
+ fanoutChannels = new Map();
28
116
  constructor(components, props) {
29
- super(components, ["/lazysub/0.0.0"], props);
117
+ super(components, ["/peerbit/topic-control-plane/2.0.0"], props);
30
118
  this.subscriptions = new Map();
31
119
  this.topics = new Map();
32
- this.topicsToPeers = new Map();
33
120
  this.peerToTopic = new Map();
121
+ this.topicRootControlPlane =
122
+ props?.topicRootControlPlane || new TopicRootControlPlane();
34
123
  this.dispatchEventOnSelfPublish =
35
124
  props?.dispatchEventOnSelfPublish || false;
125
+ if (!props?.fanout) {
126
+ throw new Error("TopicControlPlane requires a FanoutTree instance (options.fanout)");
127
+ }
128
+ this.fanout = props.fanout;
129
+ // Default to a local-only shard-root candidate set so standalone peers can
130
+ // subscribe/publish without explicit bootstraps. We'll expand candidates
131
+ // opportunistically as neighbours connect.
132
+ if (this.topicRootControlPlane.getTopicRootCandidates().length === 0) {
133
+ this.autoTopicRootCandidates = true;
134
+ this.autoTopicRootCandidateSet = new Set([this.publicKeyHash]);
135
+ this.topicRootControlPlane.setTopicRootCandidates([this.publicKeyHash]);
136
+ }
137
+ const requestedShardCount = props?.shardCount ?? DEFAULT_PUBSUB_SHARD_COUNT;
138
+ this.shardCount = Math.min(PUBSUB_SHARD_COUNT_HARD_CAP, Math.max(1, Math.floor(requestedShardCount)));
139
+ const prefix = props?.shardTopicPrefix ?? DEFAULT_PUBSUB_SHARD_TOPIC_PREFIX;
140
+ this.shardTopicPrefix = prefix.endsWith("/") ? prefix : prefix + "/";
141
+ this.hostShards = props?.hostShards ?? false;
142
+ const baseFanoutChannelOptions = {
143
+ ...DEFAULT_PUBSUB_FANOUT_CHANNEL_OPTIONS,
144
+ ...(props?.fanoutChannel || {}),
145
+ };
146
+ this.fanoutRootChannelOptions = {
147
+ ...baseFanoutChannelOptions,
148
+ ...(props?.fanoutRootChannel || {}),
149
+ };
150
+ this.fanoutNodeChannelOptions = {
151
+ ...baseFanoutChannelOptions,
152
+ ...(props?.fanoutNodeChannel || {}),
153
+ };
154
+ this.fanoutJoinOptions = props?.fanoutJoin;
155
+ this.fanoutPublishRequiresSubscribe =
156
+ props?.fanoutPublishRequiresSubscribe ?? false;
157
+ const requestedIdleCloseMs = props?.fanoutPublishIdleCloseMs ?? DEFAULT_FANOUT_PUBLISH_IDLE_CLOSE_MS;
158
+ this.fanoutPublishIdleCloseMs = Math.max(0, Math.floor(requestedIdleCloseMs));
159
+ const requestedMaxEphemeral = props?.fanoutPublishMaxEphemeralChannels ??
160
+ DEFAULT_FANOUT_PUBLISH_MAX_EPHEMERAL_CHANNELS;
161
+ this.fanoutPublishMaxEphemeralChannels = Math.max(0, Math.floor(requestedMaxEphemeral));
162
+ const requestedSubscriberCacheMaxEntries = props?.subscriberCacheMaxEntries ?? SUBSCRIBER_CACHE_DEFAULT_MAX_ENTRIES;
163
+ this.subscriberCacheMaxEntries = Math.min(SUBSCRIBER_CACHE_MAX_ENTRIES_HARD_CAP, Math.max(1, Math.floor(requestedSubscriberCacheMaxEntries)));
36
164
  this.debounceSubscribeAggregator = debouncedAccumulatorSetCounter((set) => this._subscribe([...set.values()]), props?.subscriptionDebounceDelay ?? 50);
37
- this.debounceUnsubscribeAggregator = debouncedAccumulatorSetCounter((set) => this._unsubscribe([...set.values()]), props?.subscriptionDebounceDelay ?? 50);
165
+ // NOTE: Unsubscribe should update local state immediately and batch only the
166
+ // best-effort network announcements to avoid teardown stalls (program close).
167
+ this.debounceUnsubscribeAggregator = debouncedAccumulatorSetCounter((set) => this._announceUnsubscribe([...set.values()]), props?.subscriptionDebounceDelay ?? 50);
168
+ }
169
+ /**
170
+ * Configure deterministic topic-root candidates and disable the pubsub "auto"
171
+ * candidate mode.
172
+ *
173
+ * Auto mode is a convenience for small ad-hoc networks where no bootstraps/
174
+ * routers are configured. When an explicit candidate set is provided (e.g.
175
+ * from bootstraps or a test harness), we must stop mutating/gossiping the
176
+ * candidate set; otherwise shard root resolution can diverge and overlays can
177
+ * partition (especially in sparse graphs).
178
+ */
179
+ setTopicRootCandidates(candidates) {
180
+ this.topicRootControlPlane.setTopicRootCandidates(candidates);
181
+ // Disable auto mode and stop its background gossip/timers.
182
+ this.autoTopicRootCandidates = false;
183
+ this.autoTopicRootCandidateSet = undefined;
184
+ for (const t of this.autoCandidatesBroadcastTimers)
185
+ clearTimeout(t);
186
+ this.autoCandidatesBroadcastTimers = [];
187
+ if (this.autoCandidatesGossipInterval) {
188
+ clearInterval(this.autoCandidatesGossipInterval);
189
+ this.autoCandidatesGossipInterval = undefined;
190
+ }
191
+ this.autoCandidatesGossipUntil = 0;
192
+ // Re-resolve roots under the new mapping.
193
+ this.shardRootCache.clear();
194
+ // Only candidates can become deterministic roots. Avoid doing a full shard
195
+ // scan on non-candidates in large sessions.
196
+ if (candidates.includes(this.publicKeyHash)) {
197
+ void this.hostShardRootsNow().catch(() => { });
198
+ }
199
+ this.scheduleReconcileShardOverlays();
38
200
  }
39
- stop() {
201
+ async start() {
202
+ await this.fanout.start();
203
+ await super.start();
204
+ if (this.hostShards) {
205
+ await this.hostShardRootsNow();
206
+ }
207
+ }
208
+ async stop() {
209
+ for (const st of this.fanoutChannels.values()) {
210
+ if (st.idleCloseTimeout)
211
+ clearTimeout(st.idleCloseTimeout);
212
+ try {
213
+ st.channel.removeEventListener("data", st.onData);
214
+ }
215
+ catch {
216
+ // ignore
217
+ }
218
+ try {
219
+ st.channel.removeEventListener("unicast", st.onUnicast);
220
+ }
221
+ catch {
222
+ // ignore
223
+ }
224
+ try {
225
+ // Shutdown should be bounded and not depend on network I/O.
226
+ await st.channel.leave({ notifyParent: false, kickChildren: false });
227
+ }
228
+ catch {
229
+ try {
230
+ st.channel.close();
231
+ }
232
+ catch {
233
+ // ignore
234
+ }
235
+ }
236
+ }
237
+ this.fanoutChannels.clear();
238
+ for (const t of this.autoCandidatesBroadcastTimers)
239
+ clearTimeout(t);
240
+ this.autoCandidatesBroadcastTimers = [];
241
+ if (this.autoCandidatesGossipInterval) {
242
+ clearInterval(this.autoCandidatesGossipInterval);
243
+ this.autoCandidatesGossipInterval = undefined;
244
+ }
245
+ this.autoCandidatesGossipUntil = 0;
40
246
  this.subscriptions.clear();
41
247
  this.topics.clear();
42
248
  this.peerToTopic.clear();
43
- this.topicsToPeers.clear();
249
+ this.lastSubscriptionMessages.clear();
250
+ this.shardRootCache.clear();
251
+ this.shardTopicCache.clear();
252
+ this.shardRefCounts.clear();
253
+ this.pinnedShards.clear();
44
254
  this.debounceSubscribeAggregator.close();
45
255
  this.debounceUnsubscribeAggregator.close();
46
256
  return super.stop();
47
257
  }
258
+ async onPeerConnected(peerId, connection) {
259
+ await super.onPeerConnected(peerId, connection);
260
+ // If we're in auto-candidate mode, expand the deterministic shard-root
261
+ // candidate set as neighbours connect, then reconcile shard overlays and
262
+ // re-announce subscriptions so membership knowledge converges.
263
+ if (!this.autoTopicRootCandidates)
264
+ return;
265
+ let peerHash;
266
+ try {
267
+ peerHash = getPublicKeyFromPeerId(peerId).hashcode();
268
+ }
269
+ catch {
270
+ return;
271
+ }
272
+ void this.maybeUpdateAutoTopicRootCandidates(peerHash);
273
+ }
274
+ // Ensure auto-candidate mode converges even when libp2p topology callbacks
275
+ // are delayed or only fire for one side of a connection. `addPeer()` runs for
276
+ // both inbound + outbound protocol streams once the remote public key is known.
277
+ addPeer(peerId, publicKey, protocol, connId) {
278
+ const peer = super.addPeer(peerId, publicKey, protocol, connId);
279
+ if (this.autoTopicRootCandidates) {
280
+ void this.maybeUpdateAutoTopicRootCandidates(publicKey.hashcode());
281
+ this.scheduleAutoTopicRootCandidatesBroadcast([peer]);
282
+ }
283
+ return peer;
284
+ }
285
+ maybeDisableAutoTopicRootCandidatesIfExternallyConfigured() {
286
+ if (!this.autoTopicRootCandidates)
287
+ return false;
288
+ const managed = this.autoTopicRootCandidateSet;
289
+ if (!managed)
290
+ return false;
291
+ const current = this.topicRootControlPlane.getTopicRootCandidates();
292
+ const externallyConfigured = current.length !== managed.size || current.some((c) => !managed.has(c));
293
+ if (!externallyConfigured)
294
+ return false;
295
+ // Stop mutating the candidate set. Leave the externally configured candidates
296
+ // intact and reconcile shard overlays under the new mapping.
297
+ this.autoTopicRootCandidates = false;
298
+ this.autoTopicRootCandidateSet = undefined;
299
+ this.shardRootCache.clear();
300
+ // Ensure we host any shard roots we're now responsible for. This is important
301
+ // in tests where candidates may be configured before protocol streams have
302
+ // fully started; earlier `hostShardRootsNow()` attempts can be skipped,
303
+ // leading to join timeouts.
304
+ void this.hostShardRootsNow().catch(() => { });
305
+ this.scheduleReconcileShardOverlays();
306
+ return true;
307
+ }
308
+ maybeUpdateAutoTopicRootCandidates(peerHash) {
309
+ if (!this.autoTopicRootCandidates)
310
+ return;
311
+ if (!peerHash || peerHash === this.publicKeyHash)
312
+ return;
313
+ if (this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured())
314
+ return;
315
+ const current = this.topicRootControlPlane.getTopicRootCandidates();
316
+ const managed = this.autoTopicRootCandidateSet;
317
+ if (current.includes(peerHash))
318
+ return;
319
+ managed?.add(peerHash);
320
+ const next = this.normalizeAutoTopicRootCandidates(managed ? [...managed] : [...current, peerHash]);
321
+ this.autoTopicRootCandidateSet = new Set(next);
322
+ this.topicRootControlPlane.setTopicRootCandidates(next);
323
+ this.shardRootCache.clear();
324
+ this.scheduleReconcileShardOverlays();
325
+ // In auto-candidate mode, shard roots are selected deterministically across
326
+ // *all* connected peers (not just those currently subscribed to a shard).
327
+ // That means a peer can be selected as root for shards it isn't using yet.
328
+ // Ensure we proactively host the shard roots we're responsible for so other
329
+ // peers can join without timing out in small ad-hoc networks.
330
+ void this.hostShardRootsNow().catch(() => { });
331
+ // Share the updated candidate set so other peers converge on the same
332
+ // deterministic mapping even in partially connected topologies.
333
+ this.scheduleAutoTopicRootCandidatesBroadcast();
334
+ }
335
+ normalizeAutoTopicRootCandidates(candidates) {
336
+ const unique = new Set();
337
+ for (const c of candidates) {
338
+ if (!c)
339
+ continue;
340
+ unique.add(c);
341
+ }
342
+ unique.add(this.publicKeyHash);
343
+ const sorted = [...unique].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
344
+ return sorted.slice(0, AUTO_TOPIC_ROOT_CANDIDATES_MAX);
345
+ }
346
+ scheduleAutoTopicRootCandidatesBroadcast(targets) {
347
+ if (!this.autoTopicRootCandidates)
348
+ return;
349
+ if (!this.started || this.stopping)
350
+ return;
351
+ if (targets && targets.length > 0) {
352
+ void this.sendAutoTopicRootCandidates(targets).catch(() => { });
353
+ return;
354
+ }
355
+ for (const t of this.autoCandidatesBroadcastTimers)
356
+ clearTimeout(t);
357
+ this.autoCandidatesBroadcastTimers = [];
358
+ // Burst a few times to survive early "stream not writable yet" races.
359
+ const delays = [25, 500, 2_000];
360
+ for (const delayMs of delays) {
361
+ const t = setTimeout(() => {
362
+ void this.sendAutoTopicRootCandidates().catch(() => { });
363
+ }, delayMs);
364
+ t.unref?.();
365
+ this.autoCandidatesBroadcastTimers.push(t);
366
+ }
367
+ // Keep gossiping for a while after changes so partially connected topologies
368
+ // converge even under slow stream negotiation.
369
+ this.autoCandidatesGossipUntil = Date.now() + 60_000;
370
+ this.ensureAutoCandidatesGossipInterval();
371
+ }
372
+ ensureAutoCandidatesGossipInterval() {
373
+ if (!this.autoTopicRootCandidates)
374
+ return;
375
+ if (!this.started || this.stopping)
376
+ return;
377
+ if (this.autoCandidatesGossipInterval)
378
+ return;
379
+ this.autoCandidatesGossipInterval = setInterval(() => {
380
+ if (!this.started || this.stopping || !this.autoTopicRootCandidates)
381
+ return;
382
+ if (this.autoCandidatesGossipUntil > 0 &&
383
+ Date.now() > this.autoCandidatesGossipUntil) {
384
+ if (this.autoCandidatesGossipInterval) {
385
+ clearInterval(this.autoCandidatesGossipInterval);
386
+ this.autoCandidatesGossipInterval = undefined;
387
+ }
388
+ return;
389
+ }
390
+ void this.sendAutoTopicRootCandidates().catch(() => { });
391
+ }, 2_000);
392
+ this.autoCandidatesGossipInterval.unref?.();
393
+ }
394
+ async sendAutoTopicRootCandidates(targets) {
395
+ if (!this.started)
396
+ throw new NotStartedError();
397
+ const streams = targets ?? [...this.peers.values()];
398
+ if (streams.length === 0)
399
+ return;
400
+ const candidates = this.topicRootControlPlane.getTopicRootCandidates();
401
+ if (candidates.length === 0)
402
+ return;
403
+ const msg = new TopicRootCandidates({ candidates });
404
+ const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
405
+ mode: new AnyWhere(),
406
+ priority: 1,
407
+ skipRecipientValidation: true,
408
+ });
409
+ await this.publishMessage(this.publicKey, embedded, streams).catch(dontThrowIfDeliveryError);
410
+ }
411
+ mergeAutoTopicRootCandidatesFromPeer(candidates) {
412
+ if (!this.autoTopicRootCandidates)
413
+ return false;
414
+ if (this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured())
415
+ return false;
416
+ const managed = this.autoTopicRootCandidateSet;
417
+ if (!managed)
418
+ return false;
419
+ const before = this.topicRootControlPlane.getTopicRootCandidates();
420
+ for (const c of candidates) {
421
+ if (!c)
422
+ continue;
423
+ managed.add(c);
424
+ }
425
+ const next = this.normalizeAutoTopicRootCandidates([...managed]);
426
+ if (before.length === next.length &&
427
+ before.every((c, i) => c === next[i])) {
428
+ return false;
429
+ }
430
+ this.autoTopicRootCandidateSet = new Set(next);
431
+ this.topicRootControlPlane.setTopicRootCandidates(next);
432
+ this.shardRootCache.clear();
433
+ this.scheduleReconcileShardOverlays();
434
+ void this.hostShardRootsNow().catch(() => { });
435
+ this.scheduleAutoTopicRootCandidatesBroadcast();
436
+ return true;
437
+ }
438
+ scheduleReconcileShardOverlays() {
439
+ if (this.reconcileShardOverlaysInFlight)
440
+ return;
441
+ this.reconcileShardOverlaysInFlight = this.reconcileShardOverlays()
442
+ .catch(() => {
443
+ // best-effort retry: fanout streams/roots might not be ready yet.
444
+ if (!this.started || this.stopping)
445
+ return;
446
+ const t = setTimeout(() => this.scheduleReconcileShardOverlays(), 250);
447
+ t.unref?.();
448
+ })
449
+ .finally(() => {
450
+ this.reconcileShardOverlaysInFlight = undefined;
451
+ });
452
+ }
453
+ async reconcileShardOverlays() {
454
+ if (!this.started)
455
+ return;
456
+ const byShard = new Map();
457
+ for (const topic of this.subscriptions.keys()) {
458
+ const shardTopic = this.getShardTopicForUserTopic(topic);
459
+ byShard.set(shardTopic, [...(byShard.get(shardTopic) ?? []), topic]);
460
+ }
461
+ // Ensure shard overlays are joined using the current root mapping (may
462
+ // migrate channels if roots changed), then re-announce subscriptions.
463
+ await Promise.all([...byShard.entries()].map(async ([shardTopic, userTopics]) => {
464
+ if (userTopics.length === 0)
465
+ return;
466
+ await this.ensureFanoutChannel(shardTopic, { ephemeral: false });
467
+ const msg = new Subscribe({
468
+ topics: userTopics,
469
+ requestSubscribers: true,
470
+ });
471
+ const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
472
+ mode: new AnyWhere(),
473
+ priority: 1,
474
+ skipRecipientValidation: true,
475
+ });
476
+ const st = this.fanoutChannels.get(shardTopic);
477
+ if (!st)
478
+ return;
479
+ await st.channel.publish(toUint8Array(embedded.bytes()));
480
+ this.touchFanoutChannel(shardTopic);
481
+ }));
482
+ }
483
+ isTrackedTopic(topic) {
484
+ return this.topics.has(topic);
485
+ }
48
486
  initializeTopic(topic) {
49
487
  this.topics.get(topic) || this.topics.set(topic, new Map());
50
- this.topicsToPeers.get(topic) || this.topicsToPeers.set(topic, new Set());
488
+ }
489
+ untrackTopic(topic) {
490
+ const peers = this.topics.get(topic);
491
+ this.topics.delete(topic);
492
+ if (!peers)
493
+ return;
494
+ for (const peerHash of peers.keys()) {
495
+ this.peerToTopic.get(peerHash)?.delete(topic);
496
+ this.lastSubscriptionMessages.get(peerHash)?.delete(topic);
497
+ if (!this.peerToTopic.get(peerHash)?.size) {
498
+ this.peerToTopic.delete(peerHash);
499
+ this.lastSubscriptionMessages.delete(peerHash);
500
+ }
501
+ }
51
502
  }
52
503
  initializePeer(publicKey) {
53
504
  this.peerToTopic.get(publicKey.hashcode()) ||
54
505
  this.peerToTopic.set(publicKey.hashcode(), new Set());
55
506
  }
507
+ pruneTopicSubscribers(topic) {
508
+ const peers = this.topics.get(topic);
509
+ if (!peers)
510
+ return;
511
+ while (peers.size > this.subscriberCacheMaxEntries) {
512
+ const oldest = peers.keys().next().value;
513
+ if (!oldest)
514
+ break;
515
+ peers.delete(oldest);
516
+ this.peerToTopic.get(oldest)?.delete(topic);
517
+ this.lastSubscriptionMessages.get(oldest)?.delete(topic);
518
+ if (!this.peerToTopic.get(oldest)?.size) {
519
+ this.peerToTopic.delete(oldest);
520
+ this.lastSubscriptionMessages.delete(oldest);
521
+ }
522
+ }
523
+ }
524
+ getSubscriptionOverlap(topics) {
525
+ const subscriptions = [];
526
+ if (topics) {
527
+ for (const topic of topics) {
528
+ if (this.subscriptions.get(topic))
529
+ subscriptions.push(topic);
530
+ }
531
+ return subscriptions;
532
+ }
533
+ for (const [topic] of this.subscriptions)
534
+ subscriptions.push(topic);
535
+ return subscriptions;
536
+ }
537
+ clearFanoutIdleClose(st) {
538
+ if (st.idleCloseTimeout) {
539
+ clearTimeout(st.idleCloseTimeout);
540
+ st.idleCloseTimeout = undefined;
541
+ }
542
+ }
543
+ scheduleFanoutIdleClose(topic) {
544
+ const st = this.fanoutChannels.get(topic);
545
+ if (!st || !st.ephemeral)
546
+ return;
547
+ this.clearFanoutIdleClose(st);
548
+ if (this.fanoutPublishIdleCloseMs <= 0)
549
+ return;
550
+ st.idleCloseTimeout = setTimeout(() => {
551
+ const cur = this.fanoutChannels.get(topic);
552
+ if (!cur || !cur.ephemeral)
553
+ return;
554
+ const idleMs = Date.now() - cur.lastUsedAt;
555
+ if (idleMs >= this.fanoutPublishIdleCloseMs) {
556
+ void this.closeFanoutChannel(topic);
557
+ return;
558
+ }
559
+ this.scheduleFanoutIdleClose(topic);
560
+ }, this.fanoutPublishIdleCloseMs);
561
+ }
562
+ touchFanoutChannel(topic) {
563
+ const st = this.fanoutChannels.get(topic);
564
+ if (!st)
565
+ return;
566
+ st.lastUsedAt = Date.now();
567
+ if (st.ephemeral) {
568
+ this.scheduleFanoutIdleClose(topic);
569
+ }
570
+ }
571
+ evictEphemeralFanoutChannels(exceptTopic) {
572
+ const max = this.fanoutPublishMaxEphemeralChannels;
573
+ if (max < 0)
574
+ return;
575
+ const exceptIsEphemeral = exceptTopic
576
+ ? this.fanoutChannels.get(exceptTopic)?.ephemeral === true
577
+ : false;
578
+ const keep = Math.max(0, max - (exceptIsEphemeral ? 1 : 0));
579
+ const candidates = [];
580
+ for (const [t, st] of this.fanoutChannels) {
581
+ if (!st.ephemeral)
582
+ continue;
583
+ if (exceptTopic && t === exceptTopic)
584
+ continue;
585
+ candidates.push([t, st]);
586
+ }
587
+ if (candidates.length <= keep)
588
+ return;
589
+ candidates.sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt);
590
+ const toClose = candidates.length - keep;
591
+ for (let i = 0; i < toClose; i++) {
592
+ const t = candidates[i][0];
593
+ void this.closeFanoutChannel(t);
594
+ }
595
+ }
596
+ getShardTopicForUserTopic(topic) {
597
+ const t = topic.toString();
598
+ const cached = this.shardTopicCache.get(t);
599
+ if (cached)
600
+ return cached;
601
+ const index = topicHash32(t) % this.shardCount;
602
+ const shardTopic = `${this.shardTopicPrefix}${index}`;
603
+ this.shardTopicCache.set(t, shardTopic);
604
+ return shardTopic;
605
+ }
606
+ async resolveShardRoot(shardTopic) {
607
+ // If someone configured topic-root candidates externally (e.g. TestSession router
608
+ // selection or Peerbit.bootstrap) after this peer entered auto mode, disable auto
609
+ // mode before we cache any roots based on a stale candidate set.
610
+ if (this.autoTopicRootCandidates) {
611
+ this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured();
612
+ }
613
+ const cached = this.shardRootCache.get(shardTopic);
614
+ if (cached)
615
+ return cached;
616
+ const resolved = await this.topicRootControlPlane.resolveTopicRoot(shardTopic);
617
+ if (!resolved) {
618
+ throw new Error(`No root resolved for shard topic ${shardTopic}. Configure TopicRootControlPlane candidates/resolver/trackers.`);
619
+ }
620
+ this.shardRootCache.set(shardTopic, resolved);
621
+ return resolved;
622
+ }
623
+ async ensureFanoutChannel(shardTopic, options) {
624
+ const t = shardTopic.toString();
625
+ const pin = options?.pin === true;
626
+ const wantEphemeral = options?.ephemeral === true;
627
+ // Allow callers that already resolved the shard root (e.g. hostShardRootsNow)
628
+ // to pass it through to avoid a race where the candidate set changes between
629
+ // two resolve calls, causing an unnecessary (and potentially slow) join.
630
+ let root = options?.root;
631
+ const existing = this.fanoutChannels.get(t);
632
+ if (existing) {
633
+ root = root ?? (await this.resolveShardRoot(t));
634
+ if (root === existing.root) {
635
+ existing.lastUsedAt = Date.now();
636
+ if (existing.ephemeral && !wantEphemeral) {
637
+ existing.ephemeral = false;
638
+ this.clearFanoutIdleClose(existing);
639
+ }
640
+ else if (existing.ephemeral) {
641
+ this.scheduleFanoutIdleClose(t);
642
+ }
643
+ if (pin)
644
+ this.pinnedShards.add(t);
645
+ await withAbort(existing.join, options?.signal);
646
+ return;
647
+ }
648
+ // Root mapping changed (candidate set updated): migrate to the new overlay.
649
+ await withAbort(this.closeFanoutChannel(t, { force: true }), options?.signal);
650
+ }
651
+ root = root ?? (await this.resolveShardRoot(t));
652
+ const channel = new FanoutChannel(this.fanout, { topic: t, root });
653
+ const onPayload = (payload) => {
654
+ let dm;
655
+ try {
656
+ dm = DataMessage.from(new Uint8ArrayList(payload));
657
+ }
658
+ catch {
659
+ return;
660
+ }
661
+ if (!dm?.data)
662
+ return;
663
+ if (!dm.header.signatures?.signatures?.length)
664
+ return;
665
+ const signedBySelf = dm.header.signatures?.publicKeys.some((x) => x.equals(this.publicKey)) ?? false;
666
+ if (signedBySelf)
667
+ return;
668
+ let pubsubMessage;
669
+ try {
670
+ pubsubMessage = PubSubMessage.from(dm.data);
671
+ }
672
+ catch {
673
+ return;
674
+ }
675
+ // Fast filter before hashing/verifying.
676
+ if (pubsubMessage instanceof PubSubData) {
677
+ const forMe = pubsubMessage.topics.some((x) => this.subscriptions.has(x));
678
+ if (!forMe)
679
+ return;
680
+ }
681
+ else if (pubsubMessage instanceof Subscribe ||
682
+ pubsubMessage instanceof Unsubscribe) {
683
+ const relevant = pubsubMessage.topics.some((x) => this.isTrackedTopic(x));
684
+ const needRespond = pubsubMessage instanceof Subscribe && pubsubMessage.requestSubscribers
685
+ ? pubsubMessage.topics.some((x) => this.subscriptions.has(x))
686
+ : false;
687
+ if (!relevant && !needRespond)
688
+ return;
689
+ }
690
+ else if (pubsubMessage instanceof GetSubscribers) {
691
+ const overlap = pubsubMessage.topics.some((x) => this.subscriptions.has(x));
692
+ if (!overlap)
693
+ return;
694
+ }
695
+ else {
696
+ return;
697
+ }
698
+ void (async () => {
699
+ const msgId = await getMsgId(payload);
700
+ const seen = this.seenCache.get(msgId);
701
+ this.seenCache.add(msgId, seen ? seen + 1 : 1);
702
+ if (seen)
703
+ return;
704
+ if ((await this.verifyAndProcess(dm)) === false) {
705
+ return;
706
+ }
707
+ const sender = dm.header.signatures.signatures[0].publicKey;
708
+ await this.processShardPubSubMessage({
709
+ pubsubMessage,
710
+ message: dm,
711
+ from: sender,
712
+ shardTopic: t,
713
+ });
714
+ })();
715
+ };
716
+ const onData = (ev) => {
717
+ const detail = ev?.detail;
718
+ if (!detail)
719
+ return;
720
+ onPayload(detail.payload);
721
+ };
722
+ const onUnicast = (ev) => {
723
+ const detail = ev?.detail;
724
+ if (!detail)
725
+ return;
726
+ if (detail.to && detail.to !== this.publicKeyHash)
727
+ return;
728
+ onPayload(detail.payload);
729
+ };
730
+ channel.addEventListener("data", onData);
731
+ channel.addEventListener("unicast", onUnicast);
732
+ const join = (async () => {
733
+ try {
734
+ if (root === this.publicKeyHash) {
735
+ channel.openAsRoot(this.fanoutRootChannelOptions);
736
+ return;
737
+ }
738
+ // Joining by root hash is much more reliable if the fanout protocol
739
+ // stream is already established (especially in small test nets without
740
+ // trackers/bootstraps). Best-effort only: join can still succeed via
741
+ // trackers/other routing if this times out.
742
+ try {
743
+ await this.fanout.waitFor(root, {
744
+ target: "neighbor",
745
+ // Best-effort pre-check only: do not block subscribe/publish setup
746
+ // for long periods if the root is not yet a direct stream neighbor.
747
+ timeout: 1_000,
748
+ });
749
+ }
750
+ catch {
751
+ // ignore
752
+ }
753
+ const joinOpts = options?.signal
754
+ ? { ...(this.fanoutJoinOptions ?? {}), signal: options.signal }
755
+ : this.fanoutJoinOptions;
756
+ await channel.join(this.fanoutNodeChannelOptions, joinOpts);
757
+ }
758
+ catch (error) {
759
+ try {
760
+ channel.removeEventListener("data", onData);
761
+ }
762
+ catch {
763
+ // ignore
764
+ }
765
+ try {
766
+ channel.removeEventListener("unicast", onUnicast);
767
+ }
768
+ catch {
769
+ // ignore
770
+ }
771
+ try {
772
+ channel.close();
773
+ }
774
+ catch {
775
+ // ignore
776
+ }
777
+ throw error;
778
+ }
779
+ })();
780
+ const lastUsedAt = Date.now();
781
+ if (pin)
782
+ this.pinnedShards.add(t);
783
+ this.fanoutChannels.set(t, {
784
+ root,
785
+ channel,
786
+ join,
787
+ onData,
788
+ onUnicast,
789
+ ephemeral: wantEphemeral,
790
+ lastUsedAt,
791
+ });
792
+ join.catch(() => {
793
+ this.fanoutChannels.delete(t);
794
+ });
795
+ join
796
+ .then(() => {
797
+ const st = this.fanoutChannels.get(t);
798
+ if (st?.ephemeral)
799
+ this.scheduleFanoutIdleClose(t);
800
+ })
801
+ .catch(() => {
802
+ // ignore
803
+ });
804
+ if (wantEphemeral) {
805
+ this.evictEphemeralFanoutChannels(t);
806
+ }
807
+ await withAbort(join, options?.signal);
808
+ }
809
+ async closeFanoutChannel(shardTopic, options) {
810
+ const t = shardTopic.toString();
811
+ if (!options?.force && this.pinnedShards.has(t))
812
+ return;
813
+ if (options?.force)
814
+ this.pinnedShards.delete(t);
815
+ const st = this.fanoutChannels.get(t);
816
+ if (!st)
817
+ return;
818
+ this.fanoutChannels.delete(t);
819
+ this.clearFanoutIdleClose(st);
820
+ try {
821
+ st.channel.removeEventListener("data", st.onData);
822
+ }
823
+ catch {
824
+ // ignore
825
+ }
826
+ try {
827
+ st.channel.removeEventListener("unicast", st.onUnicast);
828
+ }
829
+ catch {
830
+ // ignore
831
+ }
832
+ try {
833
+ await st.channel.leave({ notifyParent: true });
834
+ }
835
+ catch {
836
+ try {
837
+ st.channel.close();
838
+ }
839
+ catch {
840
+ // ignore
841
+ }
842
+ }
843
+ }
844
+ async hostShardRootsNow() {
845
+ if (!this.started)
846
+ throw new NotStartedError();
847
+ const joins = [];
848
+ for (let i = 0; i < this.shardCount; i++) {
849
+ const shardTopic = `${this.shardTopicPrefix}${i}`;
850
+ const root = await this.resolveShardRoot(shardTopic);
851
+ if (root !== this.publicKeyHash)
852
+ continue;
853
+ joins.push(this.ensureFanoutChannel(shardTopic, { pin: true, root }));
854
+ }
855
+ await Promise.all(joins);
856
+ }
56
857
  async subscribe(topic) {
57
- // this.debounceUnsubscribeAggregator.delete(topic);
58
858
  return this.debounceSubscribeAggregator.add({ key: topic });
59
859
  }
60
- /**
61
- * Subscribes to a given topic.
62
- */
63
860
  async _subscribe(topics) {
64
- if (!this.started) {
861
+ if (!this.started)
65
862
  throw new NotStartedError();
66
- }
67
- if (topics.length === 0) {
863
+ if (topics.length === 0)
68
864
  return;
69
- }
70
- const newTopicsForTopicData = [];
865
+ const byShard = new Map();
866
+ const joins = [];
71
867
  for (const { key: topic, counter } of topics) {
72
868
  let prev = this.subscriptions.get(topic);
73
869
  if (prev) {
74
870
  prev.counter += counter;
871
+ continue;
75
872
  }
76
- else {
77
- prev = {
78
- counter: counter,
79
- };
80
- this.subscriptions.set(topic, prev);
81
- newTopicsForTopicData.push(topic);
82
- this.listenForSubscribers(topic);
83
- }
84
- }
85
- if (newTopicsForTopicData.length > 0) {
86
- const message = new DataMessage({
87
- data: toUint8Array(new Subscribe({
88
- topics: newTopicsForTopicData,
89
- requestSubscribers: true,
90
- }).bytes()),
91
- header: new MessageHeader({
92
- priority: 1,
93
- mode: new SeekDelivery({ redundancy: 2 }),
94
- session: this.session,
95
- }),
96
- });
97
- await this.publishMessage(this.publicKey, await message.sign(this.sign));
873
+ this.subscriptions.set(topic, { counter });
874
+ this.initializeTopic(topic);
875
+ const shardTopic = this.getShardTopicForUserTopic(topic);
876
+ byShard.set(shardTopic, [...(byShard.get(shardTopic) ?? []), topic]);
877
+ this.shardRefCounts.set(shardTopic, (this.shardRefCounts.get(shardTopic) ?? 0) + 1);
878
+ joins.push(this.ensureFanoutChannel(shardTopic));
98
879
  }
880
+ await Promise.all(joins);
881
+ // Announce subscriptions per shard overlay.
882
+ await Promise.all([...byShard.entries()].map(async ([shardTopic, userTopics]) => {
883
+ if (userTopics.length === 0)
884
+ return;
885
+ const msg = new Subscribe({
886
+ topics: userTopics,
887
+ requestSubscribers: true,
888
+ });
889
+ const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
890
+ mode: new AnyWhere(),
891
+ priority: 1,
892
+ skipRecipientValidation: true,
893
+ });
894
+ const st = this.fanoutChannels.get(shardTopic);
895
+ if (!st)
896
+ throw new Error(`Fanout channel missing for shard: ${shardTopic}`);
897
+ await st.channel.publish(toUint8Array(embedded.bytes()));
898
+ this.touchFanoutChannel(shardTopic);
899
+ }));
99
900
  }
100
- async unsubscribe(topic) {
901
+ async unsubscribe(topic, options) {
101
902
  if (this.debounceSubscribeAggregator.has(topic)) {
102
- this.debounceSubscribeAggregator.delete(topic); // cancel subscription before it performed
903
+ this.debounceSubscribeAggregator.delete(topic);
904
+ return false;
905
+ }
906
+ const sub = this.subscriptions.get(topic);
907
+ if (!sub)
103
908
  return false;
909
+ if (options?.force) {
910
+ sub.counter = 0;
104
911
  }
105
- const subscriptions = this.subscriptions.get(topic);
106
- await this.debounceUnsubscribeAggregator.add({ key: topic });
107
- return !!subscriptions;
912
+ else {
913
+ sub.counter -= 1;
914
+ }
915
+ if (sub.counter > 0)
916
+ return true;
917
+ // Remove local subscription immediately so `publish()`/delivery paths observe
918
+ // the change without waiting for batched control-plane announces.
919
+ this.subscriptions.delete(topic);
920
+ this.untrackTopic(topic);
921
+ // Update shard refcount immediately. The debounced announcer will close the
922
+ // channel if this was the last local subscription for that shard.
923
+ const shardTopic = this.getShardTopicForUserTopic(topic);
924
+ const next = (this.shardRefCounts.get(shardTopic) ?? 0) - 1;
925
+ if (next <= 0) {
926
+ this.shardRefCounts.delete(shardTopic);
927
+ }
928
+ else {
929
+ this.shardRefCounts.set(shardTopic, next);
930
+ }
931
+ // Best-effort: do not block callers on network I/O (can hang under teardown).
932
+ void this.debounceUnsubscribeAggregator.add({ key: topic }).catch(logErrorIfStarted);
933
+ return true;
108
934
  }
109
- async _unsubscribe(topics, options) {
110
- if (!this.started) {
935
+ async _announceUnsubscribe(topics) {
936
+ if (!this.started)
111
937
  throw new NotStartedError();
112
- }
113
- let topicsToUnsubscribe = [];
114
- for (const { key: topic, counter } of topics) {
115
- if (counter <= 0) {
938
+ const byShard = new Map();
939
+ for (const { key: topic } of topics) {
940
+ // If the topic got re-subscribed before this debounced batch ran, skip.
941
+ if (this.subscriptions.has(topic))
116
942
  continue;
117
- }
118
- const subscriptions = this.subscriptions.get(topic);
119
- logger.trace(`unsubscribe from ${topic} - am subscribed with subscriptions ${JSON.stringify(subscriptions)}`);
120
- const peersOnTopic = this.topicsToPeers.get(topic);
121
- if (peersOnTopic) {
122
- for (const peer of peersOnTopic) {
123
- this.lastSubscriptionMessages.delete(peer);
943
+ const shardTopic = this.getShardTopicForUserTopic(topic);
944
+ byShard.set(shardTopic, [...(byShard.get(shardTopic) ?? []), topic]);
945
+ }
946
+ await Promise.all([...byShard.entries()].map(async ([shardTopic, userTopics]) => {
947
+ if (userTopics.length === 0)
948
+ return;
949
+ // Announce first.
950
+ try {
951
+ const msg = new Unsubscribe({ topics: userTopics });
952
+ const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
953
+ mode: new AnyWhere(),
954
+ priority: 1,
955
+ skipRecipientValidation: true,
956
+ });
957
+ const st = this.fanoutChannels.get(shardTopic);
958
+ if (st) {
959
+ // Best-effort: do not let a stuck proxy publish stall teardown.
960
+ void st.channel
961
+ .publish(toUint8Array(embedded.bytes()))
962
+ .catch(() => { });
963
+ this.touchFanoutChannel(shardTopic);
124
964
  }
125
965
  }
126
- if (!subscriptions) {
127
- return false;
128
- }
129
- if (subscriptions?.counter && subscriptions?.counter >= 0) {
130
- subscriptions.counter -= counter;
966
+ catch {
967
+ // best-effort
131
968
  }
132
- if (!subscriptions.counter || options?.force) {
133
- topicsToUnsubscribe.push(topic);
134
- this.subscriptions.delete(topic);
135
- this.topics.delete(topic);
136
- this.topicsToPeers.delete(topic);
969
+ // Close shard overlay if no local topics remain.
970
+ if ((this.shardRefCounts.get(shardTopic) ?? 0) <= 0) {
971
+ try {
972
+ // Shutdown should be bounded and not depend on network I/O.
973
+ await this.closeFanoutChannel(shardTopic);
974
+ }
975
+ catch {
976
+ // best-effort
977
+ }
137
978
  }
138
- }
139
- if (topicsToUnsubscribe.length > 0) {
140
- await this.publishMessage(this.publicKey, await new DataMessage({
141
- header: new MessageHeader({
142
- mode: new AnyWhere(), // TODO make this better
143
- session: this.session,
144
- priority: 1,
145
- }),
146
- data: toUint8Array(new Unsubscribe({
147
- topics: topicsToUnsubscribe,
148
- }).bytes()),
149
- }).sign(this.sign));
150
- }
979
+ }));
151
980
  }
152
981
  getSubscribers(topic) {
153
- const remote = this.topics.get(topic.toString());
154
- if (!remote) {
155
- return undefined;
982
+ const t = topic.toString();
983
+ const remote = this.topics.get(t);
984
+ const includeSelf = this.subscriptions.has(t);
985
+ if (!remote || remote.size == 0) {
986
+ return includeSelf ? [this.publicKey] : undefined;
156
987
  }
157
988
  const ret = [];
158
- for (const v of remote.values()) {
989
+ for (const v of remote.values())
159
990
  ret.push(v.publicKey);
160
- }
161
- if (this.subscriptions.get(topic)) {
991
+ if (includeSelf)
162
992
  ret.push(this.publicKey);
163
- }
164
993
  return ret;
165
994
  }
166
- listenForSubscribers(topic) {
167
- this.initializeTopic(topic);
168
- }
169
995
  async requestSubscribers(topic, to) {
170
- if (!this.started) {
996
+ if (!this.started)
171
997
  throw new NotStartedError();
172
- }
173
- if (topic == null) {
998
+ if (topic == null)
174
999
  throw new Error("ERR_NOT_VALID_TOPIC");
175
- }
176
- if (topic.length === 0) {
1000
+ if (topic.length === 0)
177
1001
  return;
1002
+ const topicsAll = (typeof topic === "string" ? [topic] : topic).map((t) => t.toString());
1003
+ for (const t of topicsAll)
1004
+ this.initializeTopic(t);
1005
+ const byShard = new Map();
1006
+ for (const t of topicsAll) {
1007
+ const shardTopic = this.getShardTopicForUserTopic(t);
1008
+ byShard.set(shardTopic, [...(byShard.get(shardTopic) ?? []), t]);
178
1009
  }
179
- const topics = typeof topic === "string" ? [topic] : topic;
180
- for (const topic of topics) {
181
- this.listenForSubscribers(topic);
182
- }
183
- return this.publishMessage(this.publicKey, await new DataMessage({
184
- data: toUint8Array(new GetSubscribers({ topics }).bytes()),
185
- header: new MessageHeader({
186
- mode: new SeekDelivery({
187
- to: to ? [to.hashcode()] : undefined,
188
- redundancy: 2,
189
- }),
190
- session: this.session,
1010
+ await Promise.all([...byShard.entries()].map(async ([shardTopic, userTopics]) => {
1011
+ const persistent = (this.shardRefCounts.get(shardTopic) ?? 0) > 0;
1012
+ await this.ensureFanoutChannel(shardTopic, { ephemeral: !persistent });
1013
+ const msg = new GetSubscribers({ topics: userTopics });
1014
+ const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
1015
+ mode: new AnyWhere(),
191
1016
  priority: 1,
192
- }),
193
- }).sign(this.sign));
194
- }
195
- getPeersOnTopics(topics) {
196
- const newPeers = new Set();
197
- if (topics?.length) {
198
- for (const topic of topics) {
199
- const peersOnTopic = this.topicsToPeers.get(topic);
200
- if (peersOnTopic) {
201
- peersOnTopic.forEach((peer) => {
202
- newPeers.add(peer);
1017
+ skipRecipientValidation: true,
1018
+ });
1019
+ const payload = toUint8Array(embedded.bytes());
1020
+ const st = this.fanoutChannels.get(shardTopic);
1021
+ if (!st)
1022
+ throw new Error(`Fanout channel missing for shard: ${shardTopic}`);
1023
+ if (to) {
1024
+ try {
1025
+ await st.channel.unicastToAck(to.hashcode(), payload, {
1026
+ timeoutMs: 5_000,
203
1027
  });
204
1028
  }
1029
+ catch {
1030
+ await st.channel.publish(payload);
1031
+ }
205
1032
  }
206
- }
207
- return newPeers;
208
- }
209
- /* getStreamsWithTopics(topics: string[], otherPeers?: string[]): PeerStreams[] {
210
- const peers = this.getNeighboursWithTopics(topics, otherPeers);
211
- return [...this.peers.values()].filter((s) =>
212
- peers.has(s.publicKey.hashcode())
213
- );
214
- } */
215
- shouldSendMessage(tos) {
216
- if (Array.isArray(tos) &&
217
- (tos.length === 0 || (tos.length === 1 && tos[0] === this.publicKeyHash))) {
218
- // skip this one
219
- return false;
220
- }
221
- if (tos instanceof Set &&
222
- (tos.size === 0 || (tos.size === 1 && tos.has(this.publicKeyHash)))) {
223
- // skip this one
224
- return false;
225
- }
226
- return true;
1033
+ else {
1034
+ await st.channel.publish(payload);
1035
+ }
1036
+ this.touchFanoutChannel(shardTopic);
1037
+ }));
227
1038
  }
228
1039
  async publish(data, options) {
229
- if (!this.started) {
1040
+ if (!this.started)
230
1041
  throw new NotStartedError();
231
- }
232
- const topics = options.topics?.map((x) => x.toString()) || [];
1042
+ const topicsAll = options.topics?.map((x) => x.toString()) || [];
233
1043
  const hasExplicitTOs = options?.mode && deliveryModeHasReceiver(options.mode);
234
- const tos = hasExplicitTOs
235
- ? options.mode?.to
236
- : this.getPeersOnTopics(topics);
237
- // Embedd topic info before the data so that peers/relays can also use topic info to route messages efficiently
238
- const dataMessage = data
239
- ? new PubSubData({
240
- topics: topics.map((x) => x.toString()),
241
- data,
242
- strict: hasExplicitTOs,
243
- })
244
- : undefined;
245
- const bytes = dataMessage?.bytes();
246
- const silentDelivery = options?.mode instanceof SilentDelivery;
247
- // do send check before creating and signing the message
248
- if (!this.dispatchEventOnSelfPublish && !this.shouldSendMessage(tos)) {
249
- return;
250
- }
251
- const message = await this.createMessage(bytes, {
252
- ...options,
253
- to: tos,
254
- skipRecipientValidation: this.dispatchEventOnSelfPublish,
255
- });
256
- if (dataMessage) {
257
- this.dispatchEvent(new CustomEvent("publish", {
258
- detail: new PublishEvent({
259
- client: options?.client,
260
- data: dataMessage,
261
- message,
262
- }),
263
- }));
264
- }
265
- // for emitSelf we do this check here, since we don't want to send the message to ourselves
266
- if (this.dispatchEventOnSelfPublish && !this.shouldSendMessage(tos)) {
1044
+ // Explicit recipients: use DirectStream delivery (no shard broadcast).
1045
+ if (hasExplicitTOs || !data) {
1046
+ const msg = data
1047
+ ? new PubSubData({ topics: topicsAll, data, strict: true })
1048
+ : undefined;
1049
+ const message = await this.createMessage(msg?.bytes(), {
1050
+ ...options,
1051
+ skipRecipientValidation: this.dispatchEventOnSelfPublish,
1052
+ });
1053
+ if (msg) {
1054
+ this.dispatchEvent(new CustomEvent("publish", {
1055
+ detail: new PublishEvent({
1056
+ client: options?.client,
1057
+ data: msg,
1058
+ message,
1059
+ }),
1060
+ }));
1061
+ }
1062
+ const silentDelivery = options?.mode instanceof SilentDelivery;
1063
+ try {
1064
+ await this.publishMessage(this.publicKey, message, undefined, undefined, options?.signal);
1065
+ }
1066
+ catch (error) {
1067
+ if (error instanceof DeliveryError && silentDelivery !== false) {
1068
+ return message.id;
1069
+ }
1070
+ throw error;
1071
+ }
267
1072
  return message.id;
268
1073
  }
269
- // send to all the other peers
270
- try {
271
- await this.publishMessage(this.publicKey, message, undefined, undefined, options?.signal);
272
- }
273
- catch (error) {
274
- if (error instanceof DeliveryError) {
275
- if (silentDelivery === false) {
276
- // If we are not in silent mode, we should throw the error
277
- throw error;
1074
+ if (this.fanoutPublishRequiresSubscribe) {
1075
+ for (const t of topicsAll) {
1076
+ if (!this.subscriptions.has(t)) {
1077
+ throw new Error(`Cannot publish to topic ${t} without subscribing (fanoutPublishRequiresSubscribe=true)`);
278
1078
  }
279
- return message.id;
280
1079
  }
281
- throw error;
282
1080
  }
283
- return message.id;
284
- }
285
- deletePeerFromTopic(topic, publicKeyHash) {
286
- const peers = this.topics.get(topic);
287
- let change = undefined;
288
- if (peers) {
289
- change = peers.get(publicKeyHash);
1081
+ const msg = new PubSubData({ topics: topicsAll, data, strict: false });
1082
+ const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
1083
+ mode: new AnyWhere(),
1084
+ priority: options?.priority,
1085
+ id: options?.id,
1086
+ extraSigners: options?.extraSigners,
1087
+ skipRecipientValidation: true,
1088
+ });
1089
+ this.dispatchEvent(new CustomEvent("publish", {
1090
+ detail: new PublishEvent({
1091
+ client: options?.client,
1092
+ data: msg,
1093
+ message: embedded,
1094
+ }),
1095
+ }));
1096
+ const byShard = new Map();
1097
+ for (const t of topicsAll) {
1098
+ const shardTopic = this.getShardTopicForUserTopic(t);
1099
+ byShard.set(shardTopic, [...(byShard.get(shardTopic) ?? []), t]);
290
1100
  }
291
- this.topics.get(topic)?.delete(publicKeyHash);
292
- this.peerToTopic.get(publicKeyHash)?.delete(topic);
293
- if (!this.peerToTopic.get(publicKeyHash)?.size) {
294
- this.peerToTopic.delete(publicKeyHash);
1101
+ for (const shardTopic of byShard.keys()) {
1102
+ const persistent = (this.shardRefCounts.get(shardTopic) ?? 0) > 0;
1103
+ await this.ensureFanoutChannel(shardTopic, {
1104
+ ephemeral: !persistent,
1105
+ signal: options?.signal,
1106
+ });
295
1107
  }
296
- this.topicsToPeers.get(topic)?.delete(publicKeyHash);
297
- return change;
298
- }
299
- getSubscriptionOverlap(topics) {
300
- const subscriptions = [];
301
- if (topics) {
302
- for (const topic of topics) {
303
- const subscription = this.subscriptions.get(topic);
304
- if (subscription) {
305
- subscriptions.push(topic);
306
- }
1108
+ const payload = toUint8Array(embedded.bytes());
1109
+ await Promise.all([...byShard.keys()].map(async (shardTopic) => {
1110
+ if (options?.signal?.aborted) {
1111
+ throw new AbortError("Publish was aborted");
307
1112
  }
308
- }
309
- else {
310
- for (const [topic, _subscription] of this.subscriptions) {
311
- subscriptions.push(topic);
1113
+ const st = this.fanoutChannels.get(shardTopic);
1114
+ if (!st) {
1115
+ throw new Error(`Fanout channel missing for shard: ${shardTopic}`);
1116
+ }
1117
+ await withAbort(st.channel.publish(payload), options?.signal);
1118
+ this.touchFanoutChannel(shardTopic);
1119
+ }));
1120
+ if (this.fanoutPublishIdleCloseMs == 0 ||
1121
+ this.fanoutPublishMaxEphemeralChannels == 0) {
1122
+ for (const shardTopic of byShard.keys()) {
1123
+ const st = this.fanoutChannels.get(shardTopic);
1124
+ if (st?.ephemeral)
1125
+ await this.closeFanoutChannel(shardTopic);
312
1126
  }
313
1127
  }
314
- return subscriptions;
1128
+ return embedded.id;
315
1129
  }
316
- onPeerSession(key, session) {
317
- // reset subs, the peer has restarted
1130
+ onPeerSession(key, _session) {
318
1131
  this.removeSubscriptions(key);
319
1132
  }
320
- async onPeerReachable(publicKey) {
321
- // Aggregate subscribers for my topics through this new peer because if we don't do this we might end up with a situtation where
322
- // we act as a relay and relay messages for a topic, but don't forward it to this new peer because we never learned about their subscriptions
323
- const resp = super.onPeerReachable(publicKey);
324
- const stream = this.peers.get(publicKey.hashcode());
325
- const isNeigbour = !!stream;
326
- if (this.subscriptions.size > 0) {
327
- // tell the peer about all topics we subscribe to
328
- this.publishMessage(this.publicKey, await new DataMessage({
329
- data: toUint8Array(new Subscribe({
330
- topics: this.getSubscriptionOverlap(), // TODO make the protocol more efficient, do we really need to share *everything* ?
331
- requestSubscribers: true,
332
- }).bytes()),
333
- header: new MessageHeader({
334
- // is new neighbour ? then send to all, though that connection (potentially find new peers)
335
- // , else just try to reach the remote
336
- priority: 1,
337
- mode: new SeekDelivery({
338
- redundancy: 2,
339
- to: isNeigbour ? undefined : [publicKey.hashcode()],
340
- }),
341
- session: this.session,
342
- }),
343
- }).sign(this.sign)).catch(dontThrowIfDeliveryError); // peer might have become unreachable immediately
344
- }
345
- return resp;
346
- }
347
1133
  onPeerUnreachable(publicKeyHash) {
348
1134
  super.onPeerUnreachable(publicKeyHash);
349
- this.removeSubscriptions(this.peerKeyHashToPublicKey.get(publicKeyHash));
1135
+ const key = this.peerKeyHashToPublicKey.get(publicKeyHash);
1136
+ if (key)
1137
+ this.removeSubscriptions(key);
350
1138
  }
351
1139
  removeSubscriptions(publicKey) {
352
- const peerTopics = this.peerToTopic.get(publicKey.hashcode());
1140
+ const peerHash = publicKey.hashcode();
1141
+ const peerTopics = this.peerToTopic.get(peerHash);
353
1142
  const changed = [];
354
1143
  if (peerTopics) {
355
1144
  for (const topic of peerTopics) {
356
- const change = this.deletePeerFromTopic(topic, publicKey.hashcode());
357
- if (change) {
1145
+ const peers = this.topics.get(topic);
1146
+ if (!peers)
1147
+ continue;
1148
+ if (peers.delete(peerHash)) {
358
1149
  changed.push(topic);
359
1150
  }
360
1151
  }
361
1152
  }
362
- this.lastSubscriptionMessages.delete(publicKey.hashcode());
1153
+ this.peerToTopic.delete(peerHash);
1154
+ this.lastSubscriptionMessages.delete(peerHash);
363
1155
  if (changed.length > 0) {
364
1156
  this.dispatchEvent(new CustomEvent("unsubscribe", {
365
1157
  detail: new UnsubcriptionEvent(publicKey, changed),
366
1158
  }));
367
1159
  }
368
1160
  }
369
- subscriptionMessageIsLatest(message, pubsubMessage) {
1161
+ subscriptionMessageIsLatest(message, pubsubMessage, relevantTopics) {
370
1162
  const subscriber = message.header.signatures.signatures[0].publicKey;
371
- const subscriberKey = subscriber.hashcode(); // Assume first signature is the one who is signing
372
- for (const topic of pubsubMessage.topics) {
1163
+ const subscriberKey = subscriber.hashcode();
1164
+ const messageTimestamp = message.header.timestamp;
1165
+ for (const topic of relevantTopics) {
373
1166
  const lastTimestamp = this.lastSubscriptionMessages
374
1167
  .get(subscriberKey)
375
- ?.get(topic)?.header.timestamp;
376
- if (lastTimestamp != null && lastTimestamp > message.header.timestamp) {
377
- return false; // message is old
1168
+ ?.get(topic);
1169
+ if (lastTimestamp != null && lastTimestamp > messageTimestamp) {
1170
+ return false;
378
1171
  }
379
1172
  }
380
- for (const topic of pubsubMessage.topics) {
1173
+ for (const topic of relevantTopics) {
381
1174
  if (!this.lastSubscriptionMessages.has(subscriberKey)) {
382
1175
  this.lastSubscriptionMessages.set(subscriberKey, new Map());
383
1176
  }
384
- this.lastSubscriptionMessages.get(subscriberKey)?.set(topic, message);
1177
+ this.lastSubscriptionMessages
1178
+ .get(subscriberKey)
1179
+ .set(topic, messageTimestamp);
385
1180
  }
386
1181
  return true;
387
1182
  }
388
- addPeersOnTopic(message, topics) {
389
- const existingPeers = new Set(message.header.mode.to);
390
- const allPeersOnTopic = this.getPeersOnTopics(topics);
391
- for (const existing of existingPeers) {
392
- allPeersOnTopic.add(existing);
1183
+ async sendFanoutUnicastOrBroadcast(shardTopic, targetHash, payload) {
1184
+ const st = this.fanoutChannels.get(shardTopic);
1185
+ if (!st)
1186
+ return;
1187
+ try {
1188
+ await st.channel.unicastToAck(targetHash, payload, { timeoutMs: 5_000 });
1189
+ return;
1190
+ }
1191
+ catch {
1192
+ // ignore and fall back
1193
+ }
1194
+ try {
1195
+ await st.channel.publish(payload);
1196
+ }
1197
+ catch {
1198
+ // ignore
393
1199
  }
394
- allPeersOnTopic.delete(this.publicKeyHash);
395
- message.header.mode.to = [...allPeersOnTopic];
396
1200
  }
397
- async onDataMessage(from, stream, message, seenBefore) {
398
- if (!message.data || message.data.length === 0) {
399
- return super.onDataMessage(from, stream, message, seenBefore);
1201
+ async processDirectPubSubMessage(input) {
1202
+ const { pubsubMessage, message } = input;
1203
+ if (pubsubMessage instanceof TopicRootCandidates) {
1204
+ // Used only to converge deterministic shard-root candidates in auto mode.
1205
+ this.mergeAutoTopicRootCandidatesFromPeer(pubsubMessage.candidates);
1206
+ return;
400
1207
  }
401
- if (this.shouldIgnore(message, seenBefore)) {
402
- return false;
1208
+ if (pubsubMessage instanceof PubSubData) {
1209
+ this.dispatchEvent(new CustomEvent("data", {
1210
+ detail: new DataEvent({
1211
+ data: pubsubMessage,
1212
+ message,
1213
+ }),
1214
+ }));
1215
+ return;
403
1216
  }
404
- const pubsubMessage = PubSubMessage.from(message.data);
1217
+ }
1218
+ async processShardPubSubMessage(input) {
1219
+ const { pubsubMessage, message, from, shardTopic } = input;
405
1220
  if (pubsubMessage instanceof PubSubData) {
406
- if (message.header.mode instanceof AnyWhere) {
407
- throw new Error("Unexpected mode for PubSubData messages");
408
- }
409
- /**
410
- * See if we know more subscribers of the message topics. If so, add aditional end receivers of the message
411
- */
412
- const meInTOs = !!message.header.mode.to?.find((x) => this.publicKeyHash === x);
413
- let isForMe;
414
- if (pubsubMessage.strict) {
415
- isForMe =
416
- !!pubsubMessage.topics.find((topic) => this.subscriptions.has(topic)) && meInTOs;
417
- }
418
- else {
419
- isForMe =
420
- !!pubsubMessage.topics.find((topic) => this.subscriptions.has(topic)) ||
421
- (pubsubMessage.topics.length === 0 && meInTOs);
422
- }
423
- if (isForMe) {
424
- if ((await this.verifyAndProcess(message)) === false) {
425
- warn("Recieved message that did not verify PubSubData");
426
- return false;
1221
+ this.dispatchEvent(new CustomEvent("data", {
1222
+ detail: new DataEvent({
1223
+ data: pubsubMessage,
1224
+ message,
1225
+ }),
1226
+ }));
1227
+ return;
1228
+ }
1229
+ if (pubsubMessage instanceof Subscribe) {
1230
+ const sender = from;
1231
+ const senderKey = sender.hashcode();
1232
+ const relevantTopics = pubsubMessage.topics.filter((t) => this.isTrackedTopic(t));
1233
+ if (relevantTopics.length > 0 &&
1234
+ this.subscriptionMessageIsLatest(message, pubsubMessage, relevantTopics)) {
1235
+ const changed = [];
1236
+ for (const topic of relevantTopics) {
1237
+ const peers = this.topics.get(topic);
1238
+ if (!peers)
1239
+ continue;
1240
+ this.initializePeer(sender);
1241
+ const existing = peers.get(senderKey);
1242
+ if (!existing || existing.session < message.header.session) {
1243
+ peers.delete(senderKey);
1244
+ peers.set(senderKey, new SubscriptionData({
1245
+ session: message.header.session,
1246
+ timestamp: message.header.timestamp,
1247
+ publicKey: sender,
1248
+ }));
1249
+ changed.push(topic);
1250
+ }
1251
+ else {
1252
+ peers.delete(senderKey);
1253
+ peers.set(senderKey, existing);
1254
+ }
1255
+ if (!existing) {
1256
+ this.peerToTopic.get(senderKey).add(topic);
1257
+ }
1258
+ this.pruneTopicSubscribers(topic);
427
1259
  }
428
- }
429
- await this.maybeAcknowledgeMessage(stream, message, seenBefore);
430
- if (isForMe) {
431
- if (seenBefore === 0) {
432
- this.dispatchEvent(new CustomEvent("data", {
433
- detail: new DataEvent({
434
- data: pubsubMessage,
435
- message,
436
- }),
1260
+ if (changed.length > 0) {
1261
+ this.dispatchEvent(new CustomEvent("subscribe", {
1262
+ detail: new SubscriptionEvent(sender, changed),
437
1263
  }));
438
1264
  }
439
1265
  }
440
- // Forward
441
- if (!pubsubMessage.strict) {
442
- this.addPeersOnTopic(message, pubsubMessage.topics);
443
- }
444
- // Only relay if we got additional receivers
445
- // or we are NOT subscribing ourselves (if we are not subscribing ourselves we are)
446
- // If we are not subscribing ourselves, then we don't have enough information to "stop" message propagation here
447
- if (message.header.mode.to?.length ||
448
- !pubsubMessage.topics.find((topic) => this.topics.has(topic)) ||
449
- message.header.mode instanceof SeekDelivery) {
450
- // DONT await this since it might introduce a dead-lock
451
- this.relayMessage(from, message).catch(logErrorIfStarted);
1266
+ if (pubsubMessage.requestSubscribers) {
1267
+ const overlap = this.getSubscriptionOverlap(pubsubMessage.topics);
1268
+ if (overlap.length > 0) {
1269
+ const response = new Subscribe({
1270
+ topics: overlap,
1271
+ requestSubscribers: false,
1272
+ });
1273
+ const embedded = await this.createMessage(toUint8Array(response.bytes()), {
1274
+ mode: new AnyWhere(),
1275
+ priority: 1,
1276
+ skipRecipientValidation: true,
1277
+ });
1278
+ const payload = toUint8Array(embedded.bytes());
1279
+ await this.sendFanoutUnicastOrBroadcast(shardTopic, senderKey, payload);
1280
+ }
452
1281
  }
1282
+ return;
453
1283
  }
454
- else {
455
- if ((await this.verifyAndProcess(message)) === false) {
456
- warn("Recieved message that did not verify Unsubscribe");
457
- return false;
458
- }
459
- if (message.header.signatures.signatures.length === 0) {
460
- warn("Recieved subscription message with no signers");
461
- return false;
462
- }
463
- await this.maybeAcknowledgeMessage(stream, message, seenBefore);
464
- const sender = message.header.signatures.signatures[0].publicKey;
465
- const senderKey = sender.hashcode(); // Assume first signature is the one who is signing
466
- if (pubsubMessage instanceof Subscribe) {
467
- if (seenBefore === 0 &&
468
- this.subscriptionMessageIsLatest(message, pubsubMessage) &&
469
- pubsubMessage.topics.length > 0) {
470
- const changed = [];
471
- pubsubMessage.topics.forEach((topic) => {
472
- const peers = this.topics.get(topic);
473
- if (peers == null) {
474
- return;
475
- }
476
- this.initializePeer(sender);
477
- // if no subscription data, or new subscription has data (and is newer) then overwrite it.
478
- // subscription where data is undefined is not intended to replace existing data
479
- const existingSubscription = peers.get(senderKey);
480
- if (!existingSubscription ||
481
- existingSubscription.session < message.header.session) {
482
- peers.set(senderKey, new SubscriptionData({
483
- session: message.header.session,
484
- timestamp: message.header.timestamp, // TODO update timestamps on all messages?
485
- publicKey: sender,
486
- }));
487
- changed.push(topic);
488
- }
489
- if (!existingSubscription) {
490
- this.topicsToPeers.get(topic)?.add(senderKey);
491
- this.peerToTopic.get(senderKey)?.add(topic);
492
- }
493
- });
494
- if (changed.length > 0) {
495
- this.dispatchEvent(new CustomEvent("subscribe", {
496
- detail: new SubscriptionEvent(sender, changed),
497
- }));
498
- }
499
- if (pubsubMessage.requestSubscribers) {
500
- // respond if we are subscribing
501
- const mySubscriptions = this.getSubscriptionOverlap(pubsubMessage.topics);
502
- if (mySubscriptions.length > 0) {
503
- const response = new DataMessage({
504
- data: toUint8Array(new Subscribe({
505
- topics: mySubscriptions,
506
- requestSubscribers: false,
507
- }).bytes()),
508
- // needs to be Ack or Silent else we will run into a infite message loop
509
- header: new MessageHeader({
510
- session: this.session,
511
- priority: 1,
512
- mode: new SeekDelivery({
513
- redundancy: 2,
514
- to: [senderKey],
515
- }),
516
- }),
517
- });
518
- this.publishMessage(this.publicKey, await response.sign(this.sign)).catch(dontThrowIfDeliveryError);
519
- }
1284
+ if (pubsubMessage instanceof Unsubscribe) {
1285
+ const sender = from;
1286
+ const senderKey = sender.hashcode();
1287
+ const relevantTopics = pubsubMessage.topics.filter((t) => this.isTrackedTopic(t));
1288
+ if (relevantTopics.length > 0 &&
1289
+ this.subscriptionMessageIsLatest(message, pubsubMessage, relevantTopics)) {
1290
+ const changed = [];
1291
+ for (const topic of relevantTopics) {
1292
+ const peers = this.topics.get(topic);
1293
+ if (!peers)
1294
+ continue;
1295
+ if (peers.delete(senderKey)) {
1296
+ changed.push(topic);
1297
+ this.peerToTopic.get(senderKey)?.delete(topic);
520
1298
  }
521
1299
  }
522
- // Forward
523
- // DONT await this since it might introduce a dead-lock
524
- this.relayMessage(from, message).catch(logErrorIfStarted);
525
- }
526
- else if (pubsubMessage instanceof Unsubscribe) {
527
- if (this.subscriptionMessageIsLatest(message, pubsubMessage)) {
528
- const changed = [];
529
- for (const unsubscription of pubsubMessage.topics) {
530
- const change = this.deletePeerFromTopic(unsubscription, senderKey);
531
- if (change) {
532
- changed.push(unsubscription);
533
- }
534
- }
535
- if (changed.length > 0 && seenBefore === 0) {
536
- this.dispatchEvent(new CustomEvent("unsubscribe", {
537
- detail: new UnsubcriptionEvent(sender, changed),
538
- }));
539
- }
540
- // Forwarding
541
- if (message.header.mode instanceof SeekDelivery ||
542
- message.header.mode instanceof SilentDelivery ||
543
- message.header.mode instanceof AcknowledgeDelivery) {
544
- this.addPeersOnTopic(message, pubsubMessage.topics);
545
- }
1300
+ if (!this.peerToTopic.get(senderKey)?.size) {
1301
+ this.peerToTopic.delete(senderKey);
1302
+ this.lastSubscriptionMessages.delete(senderKey);
546
1303
  }
547
- // DONT await this since it might introduce a dead-lock
548
- this.relayMessage(from, message).catch(logErrorIfStarted);
549
- }
550
- else if (pubsubMessage instanceof GetSubscribers) {
551
- const subscriptionsToSend = this.getSubscriptionOverlap(pubsubMessage.topics);
552
- if (subscriptionsToSend.length > 0) {
553
- // respond
554
- this.publishMessage(this.publicKey, await new DataMessage({
555
- data: toUint8Array(new Subscribe({
556
- topics: subscriptionsToSend,
557
- requestSubscribers: false,
558
- }).bytes()),
559
- header: new MessageHeader({
560
- priority: 1,
561
- mode: new SilentDelivery({
562
- redundancy: 2,
563
- to: [sender.hashcode()],
564
- }),
565
- session: this.session,
566
- }),
567
- }).sign(this.sign), [stream]).catch(dontThrowIfDeliveryError); // send back to same stream
1304
+ if (changed.length > 0) {
1305
+ this.dispatchEvent(new CustomEvent("unsubscribe", {
1306
+ detail: new UnsubcriptionEvent(sender, changed),
1307
+ }));
568
1308
  }
569
- // Forward
570
- // DONT await this since it might introduce a dead-lock
571
- this.relayMessage(from, message).catch(logErrorIfStarted);
572
1309
  }
1310
+ return;
1311
+ }
1312
+ if (pubsubMessage instanceof GetSubscribers) {
1313
+ const sender = from;
1314
+ const senderKey = sender.hashcode();
1315
+ const overlap = this.getSubscriptionOverlap(pubsubMessage.topics);
1316
+ if (overlap.length === 0)
1317
+ return;
1318
+ const response = new Subscribe({
1319
+ topics: overlap,
1320
+ requestSubscribers: false,
1321
+ });
1322
+ const embedded = await this.createMessage(toUint8Array(response.bytes()), {
1323
+ mode: new AnyWhere(),
1324
+ priority: 1,
1325
+ skipRecipientValidation: true,
1326
+ });
1327
+ const payload = toUint8Array(embedded.bytes());
1328
+ await this.sendFanoutUnicastOrBroadcast(shardTopic, senderKey, payload);
1329
+ return;
1330
+ }
1331
+ }
1332
+ async onDataMessage(from, stream, message, seenBefore) {
1333
+ if (!message.data || message.data.length === 0) {
1334
+ return super.onDataMessage(from, stream, message, seenBefore);
1335
+ }
1336
+ if (this.shouldIgnore(message, seenBefore))
1337
+ return false;
1338
+ let pubsubMessage;
1339
+ try {
1340
+ pubsubMessage = PubSubMessage.from(message.data);
1341
+ }
1342
+ catch {
1343
+ return super.onDataMessage(from, stream, message, seenBefore);
1344
+ }
1345
+ // DirectStream only supports targeted pubsub data and a small set of utility
1346
+ // messages. All membership/control traffic is shard-only.
1347
+ if (!(pubsubMessage instanceof PubSubData) &&
1348
+ !(pubsubMessage instanceof TopicRootCandidates)) {
1349
+ return true;
1350
+ }
1351
+ // Determine if this node should process it.
1352
+ let isForMe = false;
1353
+ if (deliveryModeHasReceiver(message.header.mode)) {
1354
+ isForMe = message.header.mode.to.includes(this.publicKeyHash);
1355
+ }
1356
+ else if (message.header.mode instanceof AnyWhere ||
1357
+ message.header.mode instanceof AcknowledgeAnyWhere) {
1358
+ isForMe = true;
1359
+ }
1360
+ if (pubsubMessage instanceof PubSubData) {
1361
+ const wantsTopic = pubsubMessage.topics.some((t) => this.subscriptions.has(t));
1362
+ isForMe = pubsubMessage.strict ? isForMe && wantsTopic : wantsTopic;
1363
+ }
1364
+ if (isForMe) {
1365
+ if ((await this.verifyAndProcess(message)) === false)
1366
+ return false;
1367
+ await this.maybeAcknowledgeMessage(stream, message, seenBefore);
1368
+ if (seenBefore === 0) {
1369
+ await this.processDirectPubSubMessage({ pubsubMessage, message });
1370
+ }
1371
+ }
1372
+ // Forward direct PubSubData only (subscription control lives on fanout shards).
1373
+ if (!(pubsubMessage instanceof PubSubData)) {
1374
+ return true;
1375
+ }
1376
+ if (message.header.mode instanceof SilentDelivery ||
1377
+ message.header.mode instanceof AcknowledgeDelivery) {
1378
+ if (message.header.mode.to.length === 1 &&
1379
+ message.header.mode.to[0] === this.publicKeyHash) {
1380
+ return true;
1381
+ }
1382
+ }
1383
+ const shouldForward = seenBefore === 0 ||
1384
+ ((message.header.mode instanceof AcknowledgeDelivery ||
1385
+ message.header.mode instanceof AcknowledgeAnyWhere) &&
1386
+ seenBefore < message.header.mode.redundancy);
1387
+ if (shouldForward) {
1388
+ this.relayMessage(from, message).catch(logErrorIfStarted);
573
1389
  }
574
1390
  return true;
575
1391
  }
@@ -591,20 +1407,35 @@ export const waitForSubscribers = async (libp2p, peersToWait, topic, options) =>
591
1407
  : getPublicKeyFromPeerId(id).hashcode();
592
1408
  });
593
1409
  return new Promise((resolve, reject) => {
1410
+ if (peerIdsToWait.length === 0) {
1411
+ resolve();
1412
+ return;
1413
+ }
594
1414
  let settled = false;
595
- let counter = 0;
596
1415
  let timeout = undefined;
597
1416
  let interval = undefined;
1417
+ let pollInFlight = false;
1418
+ const wanted = new Set(peerIdsToWait);
1419
+ const seen = new Set();
1420
+ const pubsub = libp2p.services.pubsub;
1421
+ const shouldRejectWithTimeoutError = options?.timeout != null;
598
1422
  const clear = () => {
599
- if (interval) {
600
- clearInterval(interval);
601
- interval = undefined;
602
- }
603
1423
  if (timeout) {
604
1424
  clearTimeout(timeout);
605
1425
  timeout = undefined;
606
1426
  }
1427
+ if (interval) {
1428
+ clearInterval(interval);
1429
+ interval = undefined;
1430
+ }
607
1431
  options?.signal?.removeEventListener("abort", onAbort);
1432
+ try {
1433
+ pubsub.removeEventListener("subscribe", onSubscribe);
1434
+ pubsub.removeEventListener("unsubscribe", onUnsubscribe);
1435
+ }
1436
+ catch {
1437
+ // ignore
1438
+ }
608
1439
  };
609
1440
  const resolveOnce = () => {
610
1441
  if (settled)
@@ -623,34 +1454,100 @@ export const waitForSubscribers = async (libp2p, peersToWait, topic, options) =>
623
1454
  const onAbort = () => {
624
1455
  rejectOnce(new AbortError("waitForSubscribers was aborted"));
625
1456
  };
1457
+ const updateSeen = (hash, isSubscribed) => {
1458
+ if (!hash)
1459
+ return false;
1460
+ if (!wanted.has(hash))
1461
+ return false;
1462
+ if (isSubscribed) {
1463
+ seen.add(hash);
1464
+ }
1465
+ else {
1466
+ seen.delete(hash);
1467
+ }
1468
+ return seen.size === wanted.size;
1469
+ };
1470
+ const reconcileFromSubscribers = (peers) => {
1471
+ const current = new Set();
1472
+ for (const peer of peers || [])
1473
+ current.add(peer.hashcode());
1474
+ for (const hash of wanted) {
1475
+ if (current.has(hash))
1476
+ seen.add(hash);
1477
+ else
1478
+ seen.delete(hash);
1479
+ }
1480
+ if (seen.size === wanted.size)
1481
+ resolveOnce();
1482
+ };
1483
+ const onSubscribe = (ev) => {
1484
+ const detail = ev?.detail;
1485
+ if (!detail)
1486
+ return;
1487
+ if (!detail.topics || detail.topics.length === 0)
1488
+ return;
1489
+ if (!detail.topics.includes(topic))
1490
+ return;
1491
+ const hash = detail.from?.hashcode?.();
1492
+ if (updateSeen(hash, true)) {
1493
+ resolveOnce();
1494
+ }
1495
+ };
1496
+ const onUnsubscribe = (ev) => {
1497
+ const detail = ev?.detail;
1498
+ if (!detail)
1499
+ return;
1500
+ if (!detail.topics || detail.topics.length === 0)
1501
+ return;
1502
+ if (!detail.topics.includes(topic))
1503
+ return;
1504
+ const hash = detail.from?.hashcode?.();
1505
+ updateSeen(hash, false);
1506
+ };
626
1507
  if (options?.signal?.aborted) {
627
1508
  rejectOnce(new AbortError("waitForSubscribers was aborted"));
628
1509
  return;
629
1510
  }
630
1511
  options?.signal?.addEventListener("abort", onAbort);
631
- if (options?.timeout != null) {
1512
+ // Preserve previous behavior: without an explicit timeout, fail after ~20s.
1513
+ const timeoutMs = Math.max(0, Math.floor(options?.timeout ?? 20_000));
1514
+ if (timeoutMs > 0) {
632
1515
  timeout = setTimeout(() => {
633
- rejectOnce(new TimeoutError("waitForSubscribers timed out"));
634
- }, options.timeout);
1516
+ rejectOnce(shouldRejectWithTimeoutError
1517
+ ? new TimeoutError("waitForSubscribers timed out")
1518
+ : new Error("Failed to find expected subscribers for topic: " + topic));
1519
+ }, timeoutMs);
635
1520
  }
636
- interval = setInterval(async () => {
637
- counter += 1;
638
- if (counter > 100) {
639
- rejectOnce(new Error("Failed to find expected subscribers for topic: " + topic));
1521
+ // Observe new subscriptions.
1522
+ try {
1523
+ void pubsub.addEventListener("subscribe", onSubscribe);
1524
+ void pubsub.addEventListener("unsubscribe", onUnsubscribe);
1525
+ }
1526
+ catch (e) {
1527
+ rejectOnce(e);
1528
+ return;
1529
+ }
1530
+ const poll = () => {
1531
+ if (settled)
640
1532
  return;
641
- }
642
- try {
643
- const peers = await libp2p.services.pubsub.getSubscribers(topic);
644
- const hasAllPeers = peerIdsToWait.filter((e) => !peers?.find((x) => x.hashcode() === e))
645
- .length === 0;
646
- if (hasAllPeers) {
647
- resolveOnce();
648
- }
649
- }
650
- catch (e) {
651
- rejectOnce(e);
652
- }
653
- }, 200);
1533
+ if (pollInFlight)
1534
+ return;
1535
+ pollInFlight = true;
1536
+ void Promise.resolve(pubsub.getSubscribers(topic))
1537
+ .then((peers) => {
1538
+ if (settled)
1539
+ return;
1540
+ reconcileFromSubscribers(peers || []);
1541
+ })
1542
+ .catch((e) => rejectOnce(e))
1543
+ .finally(() => {
1544
+ pollInFlight = false;
1545
+ });
1546
+ };
1547
+ // Polling is a fallback for cases where no event is emitted (e.g. local subscribe completion),
1548
+ // and keeps behavior stable across implementations.
1549
+ poll();
1550
+ interval = setInterval(poll, 200);
654
1551
  });
655
1552
  };
656
1553
  //# sourceMappingURL=index.js.map