@peerbit/pubsub 5.2.11 → 5.3.1

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 (67) hide show
  1. package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.d.ts +3 -0
  2. package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.d.ts.map +1 -0
  3. package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.js +90 -0
  4. package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.js.map +1 -0
  5. package/dist/benchmark/fanout-tree-parent-upgrade-eval.d.ts +2 -0
  6. package/dist/benchmark/fanout-tree-parent-upgrade-eval.d.ts.map +1 -0
  7. package/dist/benchmark/fanout-tree-parent-upgrade-eval.js +712 -0
  8. package/dist/benchmark/fanout-tree-parent-upgrade-eval.js.map +1 -0
  9. package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.d.ts +2 -0
  10. package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.d.ts.map +1 -0
  11. package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.js +1591 -0
  12. package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.js.map +1 -0
  13. package/dist/benchmark/fanout-tree-parent-upgrade-prepush.d.ts +2 -0
  14. package/dist/benchmark/fanout-tree-parent-upgrade-prepush.d.ts.map +1 -0
  15. package/dist/benchmark/fanout-tree-parent-upgrade-prepush.js +297 -0
  16. package/dist/benchmark/fanout-tree-parent-upgrade-prepush.js.map +1 -0
  17. package/dist/benchmark/fanout-tree-parent-upgrade-preset.d.ts +73 -0
  18. package/dist/benchmark/fanout-tree-parent-upgrade-preset.d.ts.map +1 -0
  19. package/dist/benchmark/fanout-tree-parent-upgrade-preset.js +183 -0
  20. package/dist/benchmark/fanout-tree-parent-upgrade-preset.js.map +1 -0
  21. package/dist/benchmark/fanout-tree-sim-lib.d.ts +101 -0
  22. package/dist/benchmark/fanout-tree-sim-lib.d.ts.map +1 -1
  23. package/dist/benchmark/fanout-tree-sim-lib.js +540 -38
  24. package/dist/benchmark/fanout-tree-sim-lib.js.map +1 -1
  25. package/dist/benchmark/fanout-tree-sim.d.ts +0 -9
  26. package/dist/benchmark/fanout-tree-sim.d.ts.map +1 -1
  27. package/dist/benchmark/fanout-tree-sim.js +369 -28
  28. package/dist/benchmark/fanout-tree-sim.js.map +1 -1
  29. package/dist/benchmark/index.js +20 -0
  30. package/dist/benchmark/index.js.map +1 -1
  31. package/dist/benchmark/pubsub-topic-sim-lib.d.ts +4 -0
  32. package/dist/benchmark/pubsub-topic-sim-lib.d.ts.map +1 -1
  33. package/dist/benchmark/pubsub-topic-sim-lib.js +58 -3
  34. package/dist/benchmark/pubsub-topic-sim-lib.js.map +1 -1
  35. package/dist/benchmark/pubsub-topic-sim.js +18 -0
  36. package/dist/benchmark/pubsub-topic-sim.js.map +1 -1
  37. package/dist/src/debounced-set.d.ts +1 -1
  38. package/dist/src/debounced-set.d.ts.map +1 -1
  39. package/dist/src/debounced-set.js +5 -1
  40. package/dist/src/debounced-set.js.map +1 -1
  41. package/dist/src/fanout-tree-codec.d.ts +330 -0
  42. package/dist/src/fanout-tree-codec.d.ts.map +1 -0
  43. package/dist/src/fanout-tree-codec.js +1209 -0
  44. package/dist/src/fanout-tree-codec.js.map +1 -0
  45. package/dist/src/fanout-tree-parent-upgrade.d.ts +117 -0
  46. package/dist/src/fanout-tree-parent-upgrade.d.ts.map +1 -0
  47. package/dist/src/fanout-tree-parent-upgrade.js +135 -0
  48. package/dist/src/fanout-tree-parent-upgrade.js.map +1 -0
  49. package/dist/src/fanout-tree.d.ts +245 -73
  50. package/dist/src/fanout-tree.d.ts.map +1 -1
  51. package/dist/src/fanout-tree.js +2290 -1057
  52. package/dist/src/fanout-tree.js.map +1 -1
  53. package/dist/src/index.d.ts +58 -0
  54. package/dist/src/index.d.ts.map +1 -1
  55. package/dist/src/index.js +316 -45
  56. package/dist/src/index.js.map +1 -1
  57. package/dist/src/topic-root-control-plane.d.ts +13 -0
  58. package/dist/src/topic-root-control-plane.d.ts.map +1 -1
  59. package/dist/src/topic-root-control-plane.js +45 -0
  60. package/dist/src/topic-root-control-plane.js.map +1 -1
  61. package/package.json +9 -12
  62. package/src/debounced-set.ts +5 -0
  63. package/src/fanout-tree-codec.ts +1580 -0
  64. package/src/fanout-tree-parent-upgrade.ts +353 -0
  65. package/src/fanout-tree.ts +4558 -2230
  66. package/src/index.ts +425 -59
  67. package/src/topic-root-control-plane.ts +48 -0
package/dist/src/index.js CHANGED
@@ -11,11 +11,28 @@ import { debouncedAccumulatorSetCounter, } from "./debounced-set.js";
11
11
  import { FanoutChannel } from "./fanout-channel.js";
12
12
  import { TopicRootControlPlane } from "./topic-root-control-plane.js";
13
13
  export * from "./fanout-tree.js";
14
+ // The complete /peerbit/fanout-tree/0.5.0 wire codec and the
15
+ // parent-upgrade decision module (the TS references the native rust-core
16
+ // implementations are parity-tested against).
17
+ export * as fanoutWire from "./fanout-tree-codec.js";
18
+ export * as fanoutParentUpgrade from "./fanout-tree-parent-upgrade.js";
14
19
  export * from "./fanout-channel.js";
15
20
  export * from "./topic-root-control-plane.js";
16
21
  export { toUint8Array } from "./bytes.js";
17
22
  export const logger = loggerFn("peerbit:transport:topic-control-plane");
18
23
  const warn = logger.newScope("warn");
24
+ const utf8Encoder = new TextEncoder();
25
+ /**
26
+ * Offset of the `data` bytes inside a borsh-serialized `PubSubData`
27
+ * (variant byte + topics vec + strict bool + data length prefix).
28
+ */
29
+ const preEncodedPubSubDataOffset = (topics) => {
30
+ let offset = 1 + 4; // variant + topics vec length
31
+ for (const topic of topics) {
32
+ offset += 4 + utf8Encoder.encode(topic).byteLength;
33
+ }
34
+ return offset + 1 + 4; // strict + data length prefix
35
+ };
19
36
  const logError = (e) => {
20
37
  logger.error(e?.message);
21
38
  };
@@ -62,6 +79,7 @@ const AUTO_TOPIC_ROOT_CANDIDATES_MAX = 64;
62
79
  // Topic-root queries may need to wait for the responder to finish opening an
63
80
  // outbound stream back to the requester after an inbound-only dial.
64
81
  const DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS = 12_000;
82
+ const DIRECT_SHARD_ROOT_CONFIRM_TIMEOUT_MS = 2_000;
65
83
  const DEFAULT_PUBSUB_FANOUT_CHANNEL_OPTIONS = {
66
84
  msgRate: 30,
67
85
  msgSize: 1024,
@@ -89,11 +107,26 @@ export class TopicControlPlane extends DirectStream {
89
107
  subscriptions;
90
108
  // Local topics requested via debounced subscribe, not yet applied in `subscriptions`.
91
109
  pendingSubscriptions;
110
+ /**
111
+ * Per-shard batching of Subscribe{requestSubscribers} responses. When many
112
+ * peers announce subscriptions in a burst (group subscribe), answering each
113
+ * announcer with a routed unicast is quadratic in subscriber count and
114
+ * melts the fanout route-resolution machinery (#983); a short window lets
115
+ * one broadcast answer all concurrent announcers instead.
116
+ */
117
+ pendingSubscriberResponses = new Map();
92
118
  lastSubscriptionMessages = new Map();
93
119
  dispatchEventOnSelfPublish;
94
120
  topicRootControlPlane;
95
121
  subscriberCacheMaxEntries;
96
122
  fanout;
123
+ // Native topic-control components of the rust-core DirectStream engine
124
+ // (`@peerbit/network-rust`). When set, the PubSubMessage codec, the topic
125
+ // hashing behind shard mapping and root selection, the root-directory
126
+ // state and the subscribe-state convergence rules run natively; the
127
+ // observable subscription maps and all events stay unchanged. Unset (the
128
+ // default) leaves every code path as-is.
129
+ nativeTopicControl;
97
130
  debounceSubscribeAggregator;
98
131
  debounceUnsubscribeAggregator;
99
132
  shardCount;
@@ -132,6 +165,10 @@ export class TopicControlPlane extends DirectStream {
132
165
  this.peerToTopic = new Map();
133
166
  this.topicRootControlPlane =
134
167
  props?.topicRootControlPlane || new TopicRootControlPlane();
168
+ this.nativeTopicControl = this.rustCore?.topicControl;
169
+ if (this.nativeTopicControl) {
170
+ this.topicRootControlPlane.adoptNativeDirectoryState(this.nativeTopicControl.createRootDirectoryState());
171
+ }
135
172
  this.dispatchEventOnSelfPublish =
136
173
  props?.dispatchEventOnSelfPublish || false;
137
174
  if (!props?.fanout) {
@@ -173,10 +210,10 @@ export class TopicControlPlane extends DirectStream {
173
210
  this.fanoutPublishMaxEphemeralChannels = Math.max(0, Math.floor(requestedMaxEphemeral));
174
211
  const requestedSubscriberCacheMaxEntries = props?.subscriberCacheMaxEntries ?? SUBSCRIBER_CACHE_DEFAULT_MAX_ENTRIES;
175
212
  this.subscriberCacheMaxEntries = Math.min(SUBSCRIBER_CACHE_MAX_ENTRIES_HARD_CAP, Math.max(1, Math.floor(requestedSubscriberCacheMaxEntries)));
176
- this.debounceSubscribeAggregator = debouncedAccumulatorSetCounter((set) => this._subscribe([...set.values()]), props?.subscriptionDebounceDelay ?? 50);
213
+ this.debounceSubscribeAggregator = debouncedAccumulatorSetCounter((set) => this._subscribe([...set.values()]), props?.subscriptionDebounceDelay ?? 50, (error) => logger.error(`Debounced subscribe failed: ${error?.message ?? error}`));
177
214
  // NOTE: Unsubscribe should update local state immediately and batch only the
178
215
  // best-effort network announcements to avoid teardown stalls (program close).
179
- this.debounceUnsubscribeAggregator = debouncedAccumulatorSetCounter((set) => this._announceUnsubscribe([...set.values()]), props?.subscriptionDebounceDelay ?? 50);
216
+ this.debounceUnsubscribeAggregator = debouncedAccumulatorSetCounter((set) => this._announceUnsubscribe([...set.values()]), props?.subscriptionDebounceDelay ?? 50, (error) => logger.error(`Debounced unsubscribe announce failed: ${error?.message ?? error}`));
180
217
  }
181
218
  /**
182
219
  * Configure deterministic topic-root candidates and disable the pubsub "auto"
@@ -268,6 +305,10 @@ export class TopicControlPlane extends DirectStream {
268
305
  this.hostOwnedShardRootsDirty = false;
269
306
  this.subscriptions.clear();
270
307
  this.pendingSubscriptions.clear();
308
+ for (const entry of this.pendingSubscriberResponses.values()) {
309
+ clearTimeout(entry.timer);
310
+ }
311
+ this.pendingSubscriberResponses.clear();
271
312
  this.topics.clear();
272
313
  this.peerToTopic.clear();
273
314
  this.lastSubscriptionMessages.clear();
@@ -362,6 +403,9 @@ export class TopicControlPlane extends DirectStream {
362
403
  this.scheduleAutoTopicRootCandidatesBroadcast();
363
404
  }
364
405
  normalizeAutoTopicRootCandidates(candidates) {
406
+ if (this.nativeTopicControl) {
407
+ return this.nativeTopicControl.normalizeAutoCandidates(candidates, this.publicKeyHash);
408
+ }
365
409
  const unique = new Set();
366
410
  for (const c of candidates) {
367
411
  if (!c)
@@ -430,7 +474,7 @@ export class TopicControlPlane extends DirectStream {
430
474
  if (candidates.length === 0)
431
475
  return;
432
476
  const msg = new TopicRootCandidates({ candidates });
433
- const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
477
+ const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
434
478
  mode: new AnyWhere(),
435
479
  priority: 1,
436
480
  skipRecipientValidation: true,
@@ -527,7 +571,7 @@ export class TopicControlPlane extends DirectStream {
527
571
  topics: userTopics,
528
572
  requestSubscribers: true,
529
573
  });
530
- const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
574
+ const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
531
575
  mode: new AnyWhere(),
532
576
  priority: 1,
533
577
  skipRecipientValidation: true,
@@ -671,11 +715,95 @@ export class TopicControlPlane extends DirectStream {
671
715
  const cached = this.shardTopicCache.get(t);
672
716
  if (cached)
673
717
  return cached;
674
- const index = topicHash32(t) % this.shardCount;
675
- const shardTopic = `${this.shardTopicPrefix}${index}`;
718
+ const shardTopic = this.nativeTopicControl
719
+ ? this.nativeTopicControl.shardTopic(t, this.shardCount, this.shardTopicPrefix)
720
+ : `${this.shardTopicPrefix}${topicHash32(t) % this.shardCount}`;
676
721
  this.shardTopicCache.set(t, shardTopic);
677
722
  return shardTopic;
678
723
  }
724
+ /**
725
+ * Serialize a control-plane message (borsh `PubSubMessage`); rust-core
726
+ * mode encodes natively (byte-identical, covered by the parity suite).
727
+ */
728
+ encodePubSubMessage(message) {
729
+ const native = this.nativeTopicControl;
730
+ if (native) {
731
+ if (message instanceof PubSubData) {
732
+ return native.encodePubSubData(message.topics, message.strict, message.data);
733
+ }
734
+ if (message instanceof Subscribe) {
735
+ return native.encodeSubscribe(message.topics, message.requestSubscribers);
736
+ }
737
+ if (message instanceof Unsubscribe) {
738
+ return native.encodeUnsubscribe(message.topics);
739
+ }
740
+ if (message instanceof GetSubscribers) {
741
+ return native.encodeGetSubscribers(message.topics);
742
+ }
743
+ if (message instanceof TopicRootCandidates) {
744
+ return native.encodeTopicRootCandidates(message.candidates);
745
+ }
746
+ if (message instanceof PeerUnavailable) {
747
+ return native.encodePeerUnavailable(message.publicKeyHash, message.session, message.timestamp, message.topics);
748
+ }
749
+ if (message instanceof TopicRootQuery) {
750
+ return native.encodeTopicRootQuery(message.requestId, message.topic);
751
+ }
752
+ if (message instanceof TopicRootQueryResponse) {
753
+ return native.encodeTopicRootQueryResponse(message.requestId, message.topic, message.root);
754
+ }
755
+ }
756
+ return toUint8Array(message.bytes());
757
+ }
758
+ /**
759
+ * Parse a control-plane payload; rust-core mode decodes natively into the
760
+ * same message classes (decode failures throw either way, so callers keep
761
+ * their fallback behavior).
762
+ */
763
+ decodePubSubMessage(bytes) {
764
+ const native = this.nativeTopicControl;
765
+ if (native) {
766
+ const decoded = native.decodePubSubMessage(bytes);
767
+ switch (decoded.type) {
768
+ case "data":
769
+ return new PubSubData({
770
+ topics: decoded.topics,
771
+ data: decoded.data,
772
+ strict: decoded.strict,
773
+ });
774
+ case "subscribe":
775
+ return new Subscribe({
776
+ topics: decoded.topics,
777
+ requestSubscribers: decoded.requestSubscribers,
778
+ });
779
+ case "unsubscribe":
780
+ return new Unsubscribe({ topics: decoded.topics });
781
+ case "get-subscribers":
782
+ return new GetSubscribers({ topics: decoded.topics });
783
+ case "topic-root-candidates":
784
+ return new TopicRootCandidates({ candidates: decoded.candidates });
785
+ case "peer-unavailable":
786
+ return new PeerUnavailable({
787
+ publicKeyHash: decoded.publicKeyHash,
788
+ session: decoded.session,
789
+ timestamp: decoded.timestamp,
790
+ topics: decoded.topics,
791
+ });
792
+ case "topic-root-query":
793
+ return new TopicRootQuery({
794
+ requestId: decoded.requestId,
795
+ topic: decoded.topic,
796
+ });
797
+ case "topic-root-query-response":
798
+ return new TopicRootQueryResponse({
799
+ requestId: decoded.requestId,
800
+ topic: decoded.topic,
801
+ root: decoded.root,
802
+ });
803
+ }
804
+ }
805
+ return PubSubMessage.from(bytes);
806
+ }
679
807
  async resolveTopicRootState(topic) {
680
808
  const tracked = await this.topicRootControlPlane.resolveTrackedTopicRoot(topic);
681
809
  if (tracked) {
@@ -747,7 +875,7 @@ export class TopicControlPlane extends DirectStream {
747
875
  } while (true);
748
876
  }
749
877
  async sendDirectControlMessage(peer, pubsubMessage) {
750
- const embedded = await this.createMessage(toUint8Array(pubsubMessage.bytes()), {
878
+ const embedded = await this.createMessage(this.encodePubSubMessage(pubsubMessage), {
751
879
  mode: new SilentDelivery({
752
880
  to: [peer.publicKey.hashcode()],
753
881
  redundancy: 1,
@@ -767,7 +895,7 @@ export class TopicControlPlane extends DirectStream {
767
895
  pending.resolve(message.root);
768
896
  return true;
769
897
  }
770
- async queryTopicRootFromPeer(peer, topic) {
898
+ async queryTopicRootFromPeer(peer, topic, timeoutMs = DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS) {
771
899
  if (!this.started || this.stopping)
772
900
  return undefined;
773
901
  const requestId = this.nextTopicRootRequestIdValue();
@@ -775,7 +903,7 @@ export class TopicControlPlane extends DirectStream {
775
903
  const timer = setTimeout(() => {
776
904
  this.pendingTopicRootQueries.delete(requestId);
777
905
  resolve(undefined);
778
- }, DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS);
906
+ }, Math.max(1, Math.floor(timeoutMs)));
779
907
  timer.unref?.();
780
908
  this.pendingTopicRootQueries.set(requestId, { topic, resolve, timer });
781
909
  });
@@ -792,6 +920,23 @@ export class TopicControlPlane extends DirectStream {
792
920
  }
793
921
  return responsePromise;
794
922
  }
923
+ async confirmDirectShardRoot(shardTopic, root, signal) {
924
+ if (root === this.publicKeyHash)
925
+ return root;
926
+ const rootPeer = this.peers.get(root);
927
+ if (!rootPeer)
928
+ return root;
929
+ const confirmation = await withAbort(this.queryTopicRootFromPeer(rootPeer, shardTopic, DIRECT_SHARD_ROOT_CONFIRM_TIMEOUT_MS), signal);
930
+ if (!confirmation)
931
+ return root;
932
+ if (confirmation !== root) {
933
+ this.shardRootCache.set(shardTopic, {
934
+ root: confirmation,
935
+ authoritative: true,
936
+ });
937
+ }
938
+ return confirmation;
939
+ }
795
940
  async resolveQueryableTopicRoot(topic) {
796
941
  const root = await this.topicRootControlPlane.resolveCanonicalTopicRoot(topic);
797
942
  if (root !== this.publicKeyHash) {
@@ -828,6 +973,8 @@ export class TopicControlPlane extends DirectStream {
828
973
  return undefined;
829
974
  }
830
975
  async ensureFanoutChannel(shardTopic, options) {
976
+ if (!this.started)
977
+ throw new NotStartedError();
831
978
  const t = shardTopic.toString();
832
979
  const pin = options?.pin === true;
833
980
  const wantEphemeral = options?.ephemeral === true;
@@ -856,6 +1003,7 @@ export class TopicControlPlane extends DirectStream {
856
1003
  await withAbort(this.closeFanoutChannel(t, { force: true }), options?.signal);
857
1004
  }
858
1005
  root = root ?? (await this.resolveShardRoot(t));
1006
+ root = await this.confirmDirectShardRoot(t, root, options?.signal);
859
1007
  const channel = new FanoutChannel(this.fanout, { topic: t, root });
860
1008
  const onPayload = (payload) => {
861
1009
  let dm;
@@ -874,7 +1022,7 @@ export class TopicControlPlane extends DirectStream {
874
1022
  return;
875
1023
  let pubsubMessage;
876
1024
  try {
877
- pubsubMessage = PubSubMessage.from(dm.data);
1025
+ pubsubMessage = this.decodePubSubMessage(dm.data);
878
1026
  }
879
1027
  catch {
880
1028
  return;
@@ -915,6 +1063,9 @@ export class TopicControlPlane extends DirectStream {
915
1063
  this.seenCache.add(msgId, seen ? seen + 1 : 1);
916
1064
  if (seen)
917
1065
  return;
1066
+ // fanout-delivered frames bypass the inbound stream decode, so
1067
+ // the nested envelope is verified natively here when possible
1068
+ this.seedNativeWireVerification(dm, payload);
918
1069
  if ((await this.verifyAndProcess(dm)) === false) {
919
1070
  return;
920
1071
  }
@@ -983,10 +1134,10 @@ export class TopicControlPlane extends DirectStream {
983
1134
  // ignore
984
1135
  }
985
1136
  try {
986
- channel.close();
1137
+ await channel.leave({ notifyParent: false, kickChildren: false });
987
1138
  }
988
1139
  catch {
989
- // ignore
1140
+ channel.close();
990
1141
  }
991
1142
  throw error;
992
1143
  }
@@ -1106,7 +1257,7 @@ export class TopicControlPlane extends DirectStream {
1106
1257
  topics: userTopics,
1107
1258
  requestSubscribers: true,
1108
1259
  });
1109
- const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
1260
+ const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
1110
1261
  mode: new AnyWhere(),
1111
1262
  priority: 1,
1112
1263
  skipRecipientValidation: true,
@@ -1173,7 +1324,7 @@ export class TopicControlPlane extends DirectStream {
1173
1324
  // Announce first.
1174
1325
  try {
1175
1326
  const msg = new Unsubscribe({ topics: userTopics });
1176
- const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
1327
+ const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
1177
1328
  mode: new AnyWhere(),
1178
1329
  priority: 1,
1179
1330
  skipRecipientValidation: true,
@@ -1238,7 +1389,7 @@ export class TopicControlPlane extends DirectStream {
1238
1389
  timestamp: batch.timestamp,
1239
1390
  topics: batch.topics,
1240
1391
  });
1241
- const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
1392
+ const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
1242
1393
  mode: new AnyWhere(),
1243
1394
  priority: 1,
1244
1395
  skipRecipientValidation: true,
@@ -1265,7 +1416,7 @@ export class TopicControlPlane extends DirectStream {
1265
1416
  timestamp: 0n,
1266
1417
  topics: [],
1267
1418
  });
1268
- const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
1419
+ const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
1269
1420
  mode: new AnyWhere(),
1270
1421
  priority: 1,
1271
1422
  skipRecipientValidation: true,
@@ -1349,7 +1500,7 @@ export class TopicControlPlane extends DirectStream {
1349
1500
  }
1350
1501
  const persistent = (this.shardRefCounts.get(shardTopic) ?? 0) > 0;
1351
1502
  await this.ensureFanoutChannel(shardTopic, { ephemeral: !persistent });
1352
- const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
1503
+ const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
1353
1504
  mode: new AnyWhere(),
1354
1505
  priority: 1,
1355
1506
  skipRecipientValidation: true,
@@ -1384,7 +1535,7 @@ export class TopicControlPlane extends DirectStream {
1384
1535
  const msg = data
1385
1536
  ? new PubSubData({ topics: topicsAll, data, strict: true })
1386
1537
  : undefined;
1387
- const message = await this.createMessage(msg?.bytes(), {
1538
+ const message = await this.createMessage(msg ? this.encodePubSubMessage(msg) : undefined, {
1388
1539
  ...options,
1389
1540
  skipRecipientValidation: this.dispatchEventOnSelfPublish,
1390
1541
  });
@@ -1417,7 +1568,7 @@ export class TopicControlPlane extends DirectStream {
1417
1568
  }
1418
1569
  }
1419
1570
  const msg = new PubSubData({ topics: topicsAll, data, strict: false });
1420
- const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
1571
+ const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
1421
1572
  mode: new AnyWhere(),
1422
1573
  priority: options?.priority,
1423
1574
  responsePriority: options?.responsePriority,
@@ -1467,6 +1618,50 @@ export class TopicControlPlane extends DirectStream {
1467
1618
  }
1468
1619
  return embedded.id;
1469
1620
  }
1621
+ /**
1622
+ * Publish a pre-encoded `PubSubData` payload to explicit recipients.
1623
+ *
1624
+ * `payload` must be the exact borsh serialization of
1625
+ * `new PubSubData({ topics, data, strict: true })` — callers that encode
1626
+ * the payload elsewhere (e.g. the shared-log fused send path serializing
1627
+ * inside wasm) hand the finished bytes here so the wrapping `PubSubData`
1628
+ * copy of the regular `publish` path is skipped. Everything else matches
1629
+ * the explicit-recipient branch of {@link publish}: the payload becomes a
1630
+ * signed `DataMessage` delivered over DirectStream.
1631
+ */
1632
+ async publishPreEncodedData(payload, properties, options) {
1633
+ if (!this.started)
1634
+ throw new NotStartedError();
1635
+ if (!options?.mode || !deliveryModeHasReceiver(options.mode)) {
1636
+ throw new Error("publishPreEncodedData requires a delivery mode with explicit recipients");
1637
+ }
1638
+ const message = await this.createMessage(payload, {
1639
+ ...options,
1640
+ skipRecipientValidation: this.dispatchEventOnSelfPublish,
1641
+ });
1642
+ this.dispatchEvent(new CustomEvent("publish", {
1643
+ detail: new PublishEvent({
1644
+ client: options?.client,
1645
+ data: new PubSubData({
1646
+ topics: properties.topics,
1647
+ data: payload.subarray(preEncodedPubSubDataOffset(properties.topics)),
1648
+ strict: true,
1649
+ }),
1650
+ message,
1651
+ }),
1652
+ }));
1653
+ const silentDelivery = options.mode instanceof SilentDelivery;
1654
+ try {
1655
+ await this.publishMessage(this.publicKey, message, undefined, undefined, options?.signal);
1656
+ }
1657
+ catch (error) {
1658
+ if (error instanceof DeliveryError && silentDelivery !== false) {
1659
+ return message.id;
1660
+ }
1661
+ throw error;
1662
+ }
1663
+ return message.id;
1664
+ }
1470
1665
  onPeerSession(key, _session) {
1471
1666
  if (!Number.isFinite(_session) || _session < 0) {
1472
1667
  return;
@@ -1575,20 +1770,36 @@ export class TopicControlPlane extends DirectStream {
1575
1770
  return changed;
1576
1771
  }
1577
1772
  subscriptionStateIsLatest(subscriberKey, session, timestamp, relevantTopics) {
1578
- for (const topic of relevantTopics) {
1579
- const last = this.lastSubscriptionMessages
1580
- .get(subscriberKey)
1581
- ?.get(topic);
1582
- if (!last) {
1583
- continue;
1773
+ if (this.nativeTopicControl) {
1774
+ const lasts = [];
1775
+ for (const topic of relevantTopics) {
1776
+ const last = this.lastSubscriptionMessages
1777
+ .get(subscriberKey)
1778
+ ?.get(topic);
1779
+ if (last) {
1780
+ lasts.push(last.session, last.timestamp);
1781
+ }
1584
1782
  }
1585
- if (last.session > session) {
1783
+ if (!this.nativeTopicControl.subscriptionIsLatest(BigUint64Array.from(lasts), session, timestamp)) {
1586
1784
  return false;
1587
1785
  }
1588
- if (timestamp !== 0n &&
1589
- last.session === session &&
1590
- last.timestamp > timestamp) {
1591
- return false;
1786
+ }
1787
+ else {
1788
+ for (const topic of relevantTopics) {
1789
+ const last = this.lastSubscriptionMessages
1790
+ .get(subscriberKey)
1791
+ ?.get(topic);
1792
+ if (!last) {
1793
+ continue;
1794
+ }
1795
+ if (last.session > session) {
1796
+ return false;
1797
+ }
1798
+ if (timestamp !== 0n &&
1799
+ last.session === session &&
1800
+ last.timestamp > timestamp) {
1801
+ return false;
1802
+ }
1592
1803
  }
1593
1804
  }
1594
1805
  for (const topic of relevantTopics) {
@@ -1604,10 +1815,80 @@ export class TopicControlPlane extends DirectStream {
1604
1815
  }
1605
1816
  return true;
1606
1817
  }
1818
+ /**
1819
+ * `Subscribe` replaces tracked subscription data for new subscribers or
1820
+ * strictly newer sessions; equal/older sessions only refresh cache order.
1821
+ */
1822
+ subscribeShouldReplace(existing, session) {
1823
+ if (this.nativeTopicControl) {
1824
+ return this.nativeTopicControl.subscribeShouldReplace(existing?.session, session);
1825
+ }
1826
+ return !existing || existing.session < session;
1827
+ }
1607
1828
  subscriptionMessageIsLatest(message, _pubsubMessage, relevantTopics) {
1608
1829
  const subscriber = message.header.signatures.signatures[0].publicKey;
1609
1830
  return this.subscriptionStateIsLatest(subscriber.hashcode(), message.header.session, message.header.timestamp, relevantTopics);
1610
1831
  }
1832
+ /**
1833
+ * Answer Subscribe{requestSubscribers} announces without letting bursts go
1834
+ * quadratic (#983). Leading edge: the first announcer is answered
1835
+ * immediately with a targeted unicast — a lone joiner sees no added
1836
+ * latency. Announcers arriving within the trailing window that this opens
1837
+ * are batched and answered with ONE broadcast, instead of S(S-1) routed
1838
+ * unicasts whose cold route resolutions flood the shard tree.
1839
+ */
1840
+ queueSubscriberResponse(shardTopic, targetHash, topics) {
1841
+ let entry = this.pendingSubscriberResponses.get(shardTopic);
1842
+ if (!entry) {
1843
+ const created = {
1844
+ targets: new Set(),
1845
+ topics: new Set(),
1846
+ timer: setTimeout(() => {
1847
+ this.pendingSubscriberResponses.delete(shardTopic);
1848
+ if (created.targets.size === 0)
1849
+ return;
1850
+ void this.flushSubscriberResponses(shardTopic, created).catch(logErrorIfStarted);
1851
+ }, 150 + Math.floor(Math.random() * 150)),
1852
+ };
1853
+ this.pendingSubscriberResponses.set(shardTopic, created);
1854
+ // Leading edge: respond to the burst opener right away.
1855
+ void this.flushSubscriberResponses(shardTopic, {
1856
+ targets: new Set([targetHash]),
1857
+ topics: new Set(topics),
1858
+ }).catch(logErrorIfStarted);
1859
+ return;
1860
+ }
1861
+ entry.targets.add(targetHash);
1862
+ for (const t of topics)
1863
+ entry.topics.add(t);
1864
+ }
1865
+ async flushSubscriberResponses(shardTopic, entry) {
1866
+ if (!this.started)
1867
+ return;
1868
+ // Re-filter: our subscriptions may have changed within the window.
1869
+ const topics = this.getSubscriptionOverlap([...entry.topics]);
1870
+ if (topics.length === 0)
1871
+ return;
1872
+ const response = new Subscribe({
1873
+ topics,
1874
+ requestSubscribers: false,
1875
+ });
1876
+ const embedded = await this.createMessage(this.encodePubSubMessage(response), {
1877
+ mode: new AnyWhere(),
1878
+ priority: 1,
1879
+ skipRecipientValidation: true,
1880
+ });
1881
+ const payload = toUint8Array(embedded.bytes());
1882
+ const [firstTarget] = entry.targets;
1883
+ if (entry.targets.size === 1 && firstTarget) {
1884
+ await this.sendFanoutUnicastOrBroadcast(shardTopic, firstTarget, payload);
1885
+ return;
1886
+ }
1887
+ const st = this.fanoutChannels.get(shardTopic);
1888
+ if (!st)
1889
+ return;
1890
+ await st.channel.publishMaybe(payload);
1891
+ }
1611
1892
  async sendFanoutUnicastOrBroadcast(shardTopic, targetHash, payload) {
1612
1893
  const st = this.fanoutChannels.get(shardTopic);
1613
1894
  if (!st)
@@ -1667,7 +1948,7 @@ export class TopicControlPlane extends DirectStream {
1667
1948
  continue;
1668
1949
  this.initializePeer(sender);
1669
1950
  const existing = peers.get(senderKey);
1670
- if (!existing || existing.session < message.header.session) {
1951
+ if (this.subscribeShouldReplace(existing, message.header.session)) {
1671
1952
  peers.delete(senderKey);
1672
1953
  peers.set(senderKey, new SubscriptionData({
1673
1954
  session: message.header.session,
@@ -1746,7 +2027,7 @@ export class TopicControlPlane extends DirectStream {
1746
2027
  continue;
1747
2028
  this.initializePeer(sender);
1748
2029
  const existing = peers.get(senderKey);
1749
- if (!existing || existing.session < message.header.session) {
2030
+ if (this.subscribeShouldReplace(existing, message.header.session)) {
1750
2031
  peers.delete(senderKey);
1751
2032
  peers.set(senderKey, new SubscriptionData({
1752
2033
  session: message.header.session,
@@ -1773,17 +2054,7 @@ export class TopicControlPlane extends DirectStream {
1773
2054
  if (pubsubMessage.requestSubscribers) {
1774
2055
  const overlap = this.getSubscriptionOverlap(pubsubMessage.topics);
1775
2056
  if (overlap.length > 0) {
1776
- const response = new Subscribe({
1777
- topics: overlap,
1778
- requestSubscribers: false,
1779
- });
1780
- const embedded = await this.createMessage(toUint8Array(response.bytes()), {
1781
- mode: new AnyWhere(),
1782
- priority: 1,
1783
- skipRecipientValidation: true,
1784
- });
1785
- const payload = toUint8Array(embedded.bytes());
1786
- await this.sendFanoutUnicastOrBroadcast(shardTopic, senderKey, payload);
2057
+ this.queueSubscriberResponse(shardTopic, senderKey, overlap);
1787
2058
  }
1788
2059
  }
1789
2060
  return;
@@ -1873,7 +2144,7 @@ export class TopicControlPlane extends DirectStream {
1873
2144
  topics: overlap,
1874
2145
  requestSubscribers: false,
1875
2146
  });
1876
- const embedded = await this.createMessage(toUint8Array(response.bytes()), {
2147
+ const embedded = await this.createMessage(this.encodePubSubMessage(response), {
1877
2148
  mode: new AnyWhere(),
1878
2149
  priority: 1,
1879
2150
  skipRecipientValidation: true,
@@ -1891,7 +2162,7 @@ export class TopicControlPlane extends DirectStream {
1891
2162
  return false;
1892
2163
  let pubsubMessage;
1893
2164
  try {
1894
- pubsubMessage = PubSubMessage.from(message.data);
2165
+ pubsubMessage = this.decodePubSubMessage(message.data);
1895
2166
  }
1896
2167
  catch {
1897
2168
  return super.onDataMessage(from, stream, message, seenBefore);