@peerbit/pubsub 4.1.4-c1b15a9 → 4.1.4-c6403d3

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