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