@peerbit/pubsub 5.2.11 → 5.3.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.
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 +241 -73
  50. package/dist/src/fanout-tree.d.ts.map +1 -1
  51. package/dist/src/fanout-tree.js +2224 -1050
  52. package/dist/src/fanout-tree.js.map +1 -1
  53. package/dist/src/index.d.ts +57 -0
  54. package/dist/src/index.d.ts.map +1 -1
  55. package/dist/src/index.js +293 -41
  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 +7 -7
  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 +4467 -2204
  66. package/src/index.ts +392 -56
  67. package/src/topic-root-control-plane.ts +48 -0
package/src/index.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  type DirectStreamComponents,
29
29
  type DirectStreamOptions,
30
30
  type PeerStreams,
31
+ type RustTopicControl,
31
32
  } from "@peerbit/stream";
32
33
  import {
33
34
  AcknowledgeAnyWhere,
@@ -64,12 +65,30 @@ import type {
64
65
  import { TopicRootControlPlane } from "./topic-root-control-plane.js";
65
66
 
66
67
  export * from "./fanout-tree.js";
68
+ // The complete /peerbit/fanout-tree/0.5.0 wire codec and the
69
+ // parent-upgrade decision module (the TS references the native rust-core
70
+ // implementations are parity-tested against).
71
+ export * as fanoutWire from "./fanout-tree-codec.js";
72
+ export * as fanoutParentUpgrade from "./fanout-tree-parent-upgrade.js";
67
73
  export * from "./fanout-channel.js";
68
74
  export * from "./topic-root-control-plane.js";
69
75
  export { toUint8Array } from "./bytes.js";
70
76
 
71
77
  export const logger = loggerFn("peerbit:transport:topic-control-plane");
72
78
  const warn = logger.newScope("warn");
79
+
80
+ const utf8Encoder = new TextEncoder();
81
+ /**
82
+ * Offset of the `data` bytes inside a borsh-serialized `PubSubData`
83
+ * (variant byte + topics vec + strict bool + data length prefix).
84
+ */
85
+ const preEncodedPubSubDataOffset = (topics: string[]): number => {
86
+ let offset = 1 + 4; // variant + topics vec length
87
+ for (const topic of topics) {
88
+ offset += 4 + utf8Encoder.encode(topic).byteLength;
89
+ }
90
+ return offset + 1 + 4; // strict + data length prefix
91
+ };
73
92
  const logError = (e?: { message: string }) => {
74
93
  logger.error(e?.message);
75
94
  };
@@ -238,6 +257,21 @@ export class TopicControlPlane
238
257
  public subscriptions: Map<string, { counter: number }>;
239
258
  // Local topics requested via debounced subscribe, not yet applied in `subscriptions`.
240
259
  private pendingSubscriptions: Set<string>;
260
+ /**
261
+ * Per-shard batching of Subscribe{requestSubscribers} responses. When many
262
+ * peers announce subscriptions in a burst (group subscribe), answering each
263
+ * announcer with a routed unicast is quadratic in subscriber count and
264
+ * melts the fanout route-resolution machinery (#983); a short window lets
265
+ * one broadcast answer all concurrent announcers instead.
266
+ */
267
+ private pendingSubscriberResponses: Map<
268
+ string,
269
+ {
270
+ targets: Set<string>;
271
+ topics: Set<string>;
272
+ timer: ReturnType<typeof setTimeout>;
273
+ }
274
+ > = new Map();
241
275
  public lastSubscriptionMessages: Map<
242
276
  string,
243
277
  Map<string, { session: bigint; timestamp: bigint }>
@@ -246,6 +280,13 @@ export class TopicControlPlane
246
280
  public readonly topicRootControlPlane: TopicRootControlPlane;
247
281
  public readonly subscriberCacheMaxEntries: number;
248
282
  public readonly fanout: FanoutTree;
283
+ // Native topic-control components of the rust-core DirectStream engine
284
+ // (`@peerbit/network-rust`). When set, the PubSubMessage codec, the topic
285
+ // hashing behind shard mapping and root selection, the root-directory
286
+ // state and the subscribe-state convergence rules run natively; the
287
+ // observable subscription maps and all events stay unchanged. Unset (the
288
+ // default) leaves every code path as-is.
289
+ private readonly nativeTopicControl?: RustTopicControl;
249
290
 
250
291
  private debounceSubscribeAggregator: DebouncedAccumulatorCounterMap;
251
292
  private debounceUnsubscribeAggregator: DebouncedAccumulatorCounterMap;
@@ -253,10 +294,7 @@ export class TopicControlPlane
253
294
  private readonly shardCount: number;
254
295
  private readonly shardTopicPrefix: string;
255
296
  private readonly hostShards: boolean;
256
- private readonly shardRootCache = new Map<
257
- string,
258
- { root: string; authoritative: boolean }
259
- >();
297
+ private readonly shardRootCache = new Map<string, { root: string; authoritative: boolean }>();
260
298
  private readonly shardTopicCache = new Map<string, string>();
261
299
  private readonly shardRefCounts = new Map<string, number>();
262
300
  private readonly pinnedShards = new Set<string>();
@@ -325,6 +363,12 @@ export class TopicControlPlane
325
363
 
326
364
  this.topicRootControlPlane =
327
365
  props?.topicRootControlPlane || new TopicRootControlPlane();
366
+ this.nativeTopicControl = this.rustCore?.topicControl;
367
+ if (this.nativeTopicControl) {
368
+ this.topicRootControlPlane.adoptNativeDirectoryState(
369
+ this.nativeTopicControl.createRootDirectoryState(),
370
+ );
371
+ }
328
372
  this.dispatchEventOnSelfPublish =
329
373
  props?.dispatchEventOnSelfPublish || false;
330
374
 
@@ -393,12 +437,18 @@ export class TopicControlPlane
393
437
  this.debounceSubscribeAggregator = debouncedAccumulatorSetCounter(
394
438
  (set) => this._subscribe([...set.values()]),
395
439
  props?.subscriptionDebounceDelay ?? 50,
440
+ (error) =>
441
+ logger.error(`Debounced subscribe failed: ${error?.message ?? error}`),
396
442
  );
397
443
  // NOTE: Unsubscribe should update local state immediately and batch only the
398
444
  // best-effort network announcements to avoid teardown stalls (program close).
399
445
  this.debounceUnsubscribeAggregator = debouncedAccumulatorSetCounter(
400
446
  (set) => this._announceUnsubscribe([...set.values()]),
401
447
  props?.subscriptionDebounceDelay ?? 50,
448
+ (error) =>
449
+ logger.error(
450
+ `Debounced unsubscribe announce failed: ${error?.message ?? error}`,
451
+ ),
402
452
  );
403
453
  }
404
454
 
@@ -497,6 +547,10 @@ export class TopicControlPlane
497
547
 
498
548
  this.subscriptions.clear();
499
549
  this.pendingSubscriptions.clear();
550
+ for (const entry of this.pendingSubscriberResponses.values()) {
551
+ clearTimeout(entry.timer);
552
+ }
553
+ this.pendingSubscriberResponses.clear();
500
554
  this.topics.clear();
501
555
  this.peerToTopic.clear();
502
556
  this.lastSubscriptionMessages.clear();
@@ -611,6 +665,12 @@ export class TopicControlPlane
611
665
  }
612
666
 
613
667
  private normalizeAutoTopicRootCandidates(candidates: string[]): string[] {
668
+ if (this.nativeTopicControl) {
669
+ return this.nativeTopicControl.normalizeAutoCandidates(
670
+ candidates,
671
+ this.publicKeyHash,
672
+ );
673
+ }
614
674
  const unique = new Set<string>();
615
675
  for (const c of candidates) {
616
676
  if (!c) continue;
@@ -680,7 +740,7 @@ export class TopicControlPlane
680
740
  if (candidates.length === 0) return;
681
741
 
682
742
  const msg = new TopicRootCandidates({ candidates });
683
- const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
743
+ const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
684
744
  mode: new AnyWhere(),
685
745
  priority: 1,
686
746
  skipRecipientValidation: true,
@@ -776,7 +836,7 @@ export class TopicControlPlane
776
836
  topics: userTopics,
777
837
  requestSubscribers: true,
778
838
  });
779
- const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
839
+ const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
780
840
  mode: new AnyWhere(),
781
841
  priority: 1,
782
842
  skipRecipientValidation: true,
@@ -925,12 +985,118 @@ export class TopicControlPlane
925
985
  const t = topic.toString();
926
986
  const cached = this.shardTopicCache.get(t);
927
987
  if (cached) return cached;
928
- const index = topicHash32(t) % this.shardCount;
929
- const shardTopic = `${this.shardTopicPrefix}${index}`;
988
+ const shardTopic = this.nativeTopicControl
989
+ ? this.nativeTopicControl.shardTopic(
990
+ t,
991
+ this.shardCount,
992
+ this.shardTopicPrefix,
993
+ )
994
+ : `${this.shardTopicPrefix}${topicHash32(t) % this.shardCount}`;
930
995
  this.shardTopicCache.set(t, shardTopic);
931
996
  return shardTopic;
932
997
  }
933
998
 
999
+ /**
1000
+ * Serialize a control-plane message (borsh `PubSubMessage`); rust-core
1001
+ * mode encodes natively (byte-identical, covered by the parity suite).
1002
+ */
1003
+ private encodePubSubMessage(message: PubSubMessage): Uint8Array {
1004
+ const native = this.nativeTopicControl;
1005
+ if (native) {
1006
+ if (message instanceof PubSubData) {
1007
+ return native.encodePubSubData(
1008
+ message.topics,
1009
+ message.strict,
1010
+ message.data,
1011
+ );
1012
+ }
1013
+ if (message instanceof Subscribe) {
1014
+ return native.encodeSubscribe(
1015
+ message.topics,
1016
+ message.requestSubscribers,
1017
+ );
1018
+ }
1019
+ if (message instanceof Unsubscribe) {
1020
+ return native.encodeUnsubscribe(message.topics);
1021
+ }
1022
+ if (message instanceof GetSubscribers) {
1023
+ return native.encodeGetSubscribers(message.topics);
1024
+ }
1025
+ if (message instanceof TopicRootCandidates) {
1026
+ return native.encodeTopicRootCandidates(message.candidates);
1027
+ }
1028
+ if (message instanceof PeerUnavailable) {
1029
+ return native.encodePeerUnavailable(
1030
+ message.publicKeyHash,
1031
+ message.session,
1032
+ message.timestamp,
1033
+ message.topics,
1034
+ );
1035
+ }
1036
+ if (message instanceof TopicRootQuery) {
1037
+ return native.encodeTopicRootQuery(message.requestId, message.topic);
1038
+ }
1039
+ if (message instanceof TopicRootQueryResponse) {
1040
+ return native.encodeTopicRootQueryResponse(
1041
+ message.requestId,
1042
+ message.topic,
1043
+ message.root,
1044
+ );
1045
+ }
1046
+ }
1047
+ return toUint8Array(message.bytes());
1048
+ }
1049
+
1050
+ /**
1051
+ * Parse a control-plane payload; rust-core mode decodes natively into the
1052
+ * same message classes (decode failures throw either way, so callers keep
1053
+ * their fallback behavior).
1054
+ */
1055
+ private decodePubSubMessage(bytes: Uint8Array): PubSubMessage {
1056
+ const native = this.nativeTopicControl;
1057
+ if (native) {
1058
+ const decoded = native.decodePubSubMessage(bytes);
1059
+ switch (decoded.type) {
1060
+ case "data":
1061
+ return new PubSubData({
1062
+ topics: decoded.topics,
1063
+ data: decoded.data,
1064
+ strict: decoded.strict,
1065
+ });
1066
+ case "subscribe":
1067
+ return new Subscribe({
1068
+ topics: decoded.topics,
1069
+ requestSubscribers: decoded.requestSubscribers,
1070
+ });
1071
+ case "unsubscribe":
1072
+ return new Unsubscribe({ topics: decoded.topics });
1073
+ case "get-subscribers":
1074
+ return new GetSubscribers({ topics: decoded.topics });
1075
+ case "topic-root-candidates":
1076
+ return new TopicRootCandidates({ candidates: decoded.candidates });
1077
+ case "peer-unavailable":
1078
+ return new PeerUnavailable({
1079
+ publicKeyHash: decoded.publicKeyHash,
1080
+ session: decoded.session,
1081
+ timestamp: decoded.timestamp,
1082
+ topics: decoded.topics,
1083
+ });
1084
+ case "topic-root-query":
1085
+ return new TopicRootQuery({
1086
+ requestId: decoded.requestId,
1087
+ topic: decoded.topic,
1088
+ });
1089
+ case "topic-root-query-response":
1090
+ return new TopicRootQueryResponse({
1091
+ requestId: decoded.requestId,
1092
+ topic: decoded.topic,
1093
+ root: decoded.root,
1094
+ });
1095
+ }
1096
+ }
1097
+ return PubSubMessage.from(bytes);
1098
+ }
1099
+
934
1100
  private async resolveTopicRootState(
935
1101
  topic: string,
936
1102
  ): Promise<{ root?: string; authoritative: boolean }> {
@@ -1024,7 +1190,7 @@ export class TopicControlPlane
1024
1190
  peer: PeerStreams,
1025
1191
  pubsubMessage: PubSubMessage,
1026
1192
  ) {
1027
- const embedded = await this.createMessage(toUint8Array(pubsubMessage.bytes()), {
1193
+ const embedded = await this.createMessage(this.encodePubSubMessage(pubsubMessage), {
1028
1194
  mode: new SilentDelivery({
1029
1195
  to: [peer.publicKey.hashcode()],
1030
1196
  redundancy: 1,
@@ -1137,6 +1303,7 @@ export class TopicControlPlane
1137
1303
  signal?: AbortSignal;
1138
1304
  },
1139
1305
  ): Promise<void> {
1306
+ if (!this.started) throw new NotStartedError();
1140
1307
  const t = shardTopic.toString();
1141
1308
  const pin = options?.pin === true;
1142
1309
  const wantEphemeral = options?.ephemeral === true;
@@ -1186,7 +1353,7 @@ export class TopicControlPlane
1186
1353
 
1187
1354
  let pubsubMessage: PubSubMessage;
1188
1355
  try {
1189
- pubsubMessage = PubSubMessage.from(dm.data);
1356
+ pubsubMessage = this.decodePubSubMessage(dm.data);
1190
1357
  } catch {
1191
1358
  return;
1192
1359
  }
@@ -1232,6 +1399,9 @@ export class TopicControlPlane
1232
1399
  this.seenCache.add(msgId, seen ? seen + 1 : 1);
1233
1400
  if (seen) return;
1234
1401
 
1402
+ // fanout-delivered frames bypass the inbound stream decode, so
1403
+ // the nested envelope is verified natively here when possible
1404
+ this.seedNativeWireVerification(dm, payload);
1235
1405
  if ((await this.verifyAndProcess(dm)) === false) {
1236
1406
  return;
1237
1407
  }
@@ -1419,7 +1589,7 @@ export class TopicControlPlane
1419
1589
  topics: userTopics,
1420
1590
  requestSubscribers: true,
1421
1591
  });
1422
- const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
1592
+ const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
1423
1593
  mode: new AnyWhere(),
1424
1594
  priority: 1,
1425
1595
  skipRecipientValidation: true,
@@ -1498,7 +1668,7 @@ export class TopicControlPlane
1498
1668
  // Announce first.
1499
1669
  try {
1500
1670
  const msg = new Unsubscribe({ topics: userTopics });
1501
- const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
1671
+ const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
1502
1672
  mode: new AnyWhere(),
1503
1673
  priority: 1,
1504
1674
  skipRecipientValidation: true,
@@ -1571,7 +1741,7 @@ export class TopicControlPlane
1571
1741
  timestamp: batch.timestamp,
1572
1742
  topics: batch.topics,
1573
1743
  });
1574
- const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
1744
+ const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
1575
1745
  mode: new AnyWhere(),
1576
1746
  priority: 1,
1577
1747
  skipRecipientValidation: true,
@@ -1601,7 +1771,7 @@ export class TopicControlPlane
1601
1771
  timestamp: 0n,
1602
1772
  topics: [],
1603
1773
  });
1604
- const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
1774
+ const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
1605
1775
  mode: new AnyWhere(),
1606
1776
  priority: 1,
1607
1777
  skipRecipientValidation: true,
@@ -1702,7 +1872,7 @@ export class TopicControlPlane
1702
1872
  const persistent = (this.shardRefCounts.get(shardTopic) ?? 0) > 0;
1703
1873
  await this.ensureFanoutChannel(shardTopic, { ephemeral: !persistent });
1704
1874
 
1705
- const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
1875
+ const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
1706
1876
  mode: new AnyWhere(),
1707
1877
  priority: 1,
1708
1878
  skipRecipientValidation: true,
@@ -1754,10 +1924,13 @@ export class TopicControlPlane
1754
1924
  const msg = data
1755
1925
  ? new PubSubData({ topics: topicsAll, data, strict: true })
1756
1926
  : undefined;
1757
- const message = await this.createMessage(msg?.bytes(), {
1758
- ...options,
1759
- skipRecipientValidation: this.dispatchEventOnSelfPublish,
1760
- });
1927
+ const message = await this.createMessage(
1928
+ msg ? this.encodePubSubMessage(msg) : undefined,
1929
+ {
1930
+ ...options,
1931
+ skipRecipientValidation: this.dispatchEventOnSelfPublish,
1932
+ },
1933
+ );
1761
1934
 
1762
1935
  if (msg) {
1763
1936
  this.dispatchEvent(
@@ -1800,7 +1973,7 @@ export class TopicControlPlane
1800
1973
  }
1801
1974
 
1802
1975
  const msg = new PubSubData({ topics: topicsAll, data, strict: false });
1803
- const embedded = await this.createMessage(toUint8Array(msg.bytes()), {
1976
+ const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
1804
1977
  mode: new AnyWhere(),
1805
1978
  priority: options?.priority,
1806
1979
  responsePriority: options?.responsePriority,
@@ -1862,6 +2035,76 @@ export class TopicControlPlane
1862
2035
  return embedded.id;
1863
2036
  }
1864
2037
 
2038
+ /**
2039
+ * Publish a pre-encoded `PubSubData` payload to explicit recipients.
2040
+ *
2041
+ * `payload` must be the exact borsh serialization of
2042
+ * `new PubSubData({ topics, data, strict: true })` — callers that encode
2043
+ * the payload elsewhere (e.g. the shared-log fused send path serializing
2044
+ * inside wasm) hand the finished bytes here so the wrapping `PubSubData`
2045
+ * copy of the regular `publish` path is skipped. Everything else matches
2046
+ * the explicit-recipient branch of {@link publish}: the payload becomes a
2047
+ * signed `DataMessage` delivered over DirectStream.
2048
+ */
2049
+ async publishPreEncodedData(
2050
+ payload: Uint8Array,
2051
+ properties: {
2052
+ topics: string[];
2053
+ },
2054
+ options: {
2055
+ mode: SilentDelivery | AcknowledgeDelivery;
2056
+ } & { client?: string } & PriorityOptions &
2057
+ ResponsePriorityOptions &
2058
+ ExpiresAtOptions &
2059
+ IdOptions &
2060
+ WithExtraSigners & { signal?: AbortSignal },
2061
+ ): Promise<Uint8Array | undefined> {
2062
+ if (!this.started) throw new NotStartedError();
2063
+ if (!options?.mode || !deliveryModeHasReceiver(options.mode)) {
2064
+ throw new Error(
2065
+ "publishPreEncodedData requires a delivery mode with explicit recipients",
2066
+ );
2067
+ }
2068
+
2069
+ const message = await this.createMessage(payload, {
2070
+ ...options,
2071
+ skipRecipientValidation: this.dispatchEventOnSelfPublish,
2072
+ });
2073
+
2074
+ this.dispatchEvent(
2075
+ new CustomEvent("publish", {
2076
+ detail: new PublishEvent({
2077
+ client: options?.client,
2078
+ data: new PubSubData({
2079
+ topics: properties.topics,
2080
+ data: payload.subarray(
2081
+ preEncodedPubSubDataOffset(properties.topics),
2082
+ ),
2083
+ strict: true,
2084
+ }),
2085
+ message,
2086
+ }),
2087
+ }),
2088
+ );
2089
+
2090
+ const silentDelivery = options.mode instanceof SilentDelivery;
2091
+ try {
2092
+ await this.publishMessage(
2093
+ this.publicKey,
2094
+ message,
2095
+ undefined,
2096
+ undefined,
2097
+ options?.signal,
2098
+ );
2099
+ } catch (error) {
2100
+ if (error instanceof DeliveryError && silentDelivery !== false) {
2101
+ return message.id;
2102
+ }
2103
+ throw error;
2104
+ }
2105
+ return message.id;
2106
+ }
2107
+
1865
2108
  public onPeerSession(key: PublicSignKey, _session: number): void {
1866
2109
  if (!Number.isFinite(_session) || _session < 0) {
1867
2110
  return;
@@ -1999,23 +2242,44 @@ export class TopicControlPlane
1999
2242
  timestamp: bigint,
2000
2243
  relevantTopics: string[],
2001
2244
  ) {
2002
- for (const topic of relevantTopics) {
2003
- const last = this.lastSubscriptionMessages
2004
- .get(subscriberKey)
2005
- ?.get(topic);
2006
- if (!last) {
2007
- continue;
2008
- }
2009
- if (last.session > session) {
2010
- return false;
2245
+ if (this.nativeTopicControl) {
2246
+ const lasts: bigint[] = [];
2247
+ for (const topic of relevantTopics) {
2248
+ const last = this.lastSubscriptionMessages
2249
+ .get(subscriberKey)
2250
+ ?.get(topic);
2251
+ if (last) {
2252
+ lasts.push(last.session, last.timestamp);
2253
+ }
2011
2254
  }
2012
2255
  if (
2013
- timestamp !== 0n &&
2014
- last.session === session &&
2015
- last.timestamp > timestamp
2256
+ !this.nativeTopicControl.subscriptionIsLatest(
2257
+ BigUint64Array.from(lasts),
2258
+ session,
2259
+ timestamp,
2260
+ )
2016
2261
  ) {
2017
2262
  return false;
2018
2263
  }
2264
+ } else {
2265
+ for (const topic of relevantTopics) {
2266
+ const last = this.lastSubscriptionMessages
2267
+ .get(subscriberKey)
2268
+ ?.get(topic);
2269
+ if (!last) {
2270
+ continue;
2271
+ }
2272
+ if (last.session > session) {
2273
+ return false;
2274
+ }
2275
+ if (
2276
+ timestamp !== 0n &&
2277
+ last.session === session &&
2278
+ last.timestamp > timestamp
2279
+ ) {
2280
+ return false;
2281
+ }
2282
+ }
2019
2283
  }
2020
2284
 
2021
2285
  for (const topic of relevantTopics) {
@@ -2032,6 +2296,23 @@ export class TopicControlPlane
2032
2296
  return true;
2033
2297
  }
2034
2298
 
2299
+ /**
2300
+ * `Subscribe` replaces tracked subscription data for new subscribers or
2301
+ * strictly newer sessions; equal/older sessions only refresh cache order.
2302
+ */
2303
+ private subscribeShouldReplace(
2304
+ existing: SubscriptionData | undefined,
2305
+ session: bigint,
2306
+ ): boolean {
2307
+ if (this.nativeTopicControl) {
2308
+ return this.nativeTopicControl.subscribeShouldReplace(
2309
+ existing?.session,
2310
+ session,
2311
+ );
2312
+ }
2313
+ return !existing || existing.session < session;
2314
+ }
2315
+
2035
2316
  private subscriptionMessageIsLatest(
2036
2317
  message: DataMessage,
2037
2318
  _pubsubMessage: Subscribe | Unsubscribe,
@@ -2046,6 +2327,78 @@ export class TopicControlPlane
2046
2327
  );
2047
2328
  }
2048
2329
 
2330
+ /**
2331
+ * Answer Subscribe{requestSubscribers} announces without letting bursts go
2332
+ * quadratic (#983). Leading edge: the first announcer is answered
2333
+ * immediately with a targeted unicast — a lone joiner sees no added
2334
+ * latency. Announcers arriving within the trailing window that this opens
2335
+ * are batched and answered with ONE broadcast, instead of S(S-1) routed
2336
+ * unicasts whose cold route resolutions flood the shard tree.
2337
+ */
2338
+ private queueSubscriberResponse(
2339
+ shardTopic: string,
2340
+ targetHash: string,
2341
+ topics: string[],
2342
+ ) {
2343
+ let entry = this.pendingSubscriberResponses.get(shardTopic);
2344
+ if (!entry) {
2345
+ const created = {
2346
+ targets: new Set<string>(),
2347
+ topics: new Set<string>(),
2348
+ timer: setTimeout(
2349
+ () => {
2350
+ this.pendingSubscriberResponses.delete(shardTopic);
2351
+ if (created.targets.size === 0) return;
2352
+ void this.flushSubscriberResponses(shardTopic, created).catch(
2353
+ logErrorIfStarted,
2354
+ );
2355
+ },
2356
+ 150 + Math.floor(Math.random() * 150),
2357
+ ),
2358
+ };
2359
+ this.pendingSubscriberResponses.set(shardTopic, created);
2360
+ // Leading edge: respond to the burst opener right away.
2361
+ void this.flushSubscriberResponses(shardTopic, {
2362
+ targets: new Set([targetHash]),
2363
+ topics: new Set(topics),
2364
+ }).catch(logErrorIfStarted);
2365
+ return;
2366
+ }
2367
+ entry.targets.add(targetHash);
2368
+ for (const t of topics) entry.topics.add(t);
2369
+ }
2370
+
2371
+ private async flushSubscriberResponses(
2372
+ shardTopic: string,
2373
+ entry: { targets: Set<string>; topics: Set<string> },
2374
+ ) {
2375
+ if (!this.started) return;
2376
+ // Re-filter: our subscriptions may have changed within the window.
2377
+ const topics = this.getSubscriptionOverlap([...entry.topics]);
2378
+ if (topics.length === 0) return;
2379
+ const response = new Subscribe({
2380
+ topics,
2381
+ requestSubscribers: false,
2382
+ });
2383
+ const embedded = await this.createMessage(
2384
+ this.encodePubSubMessage(response),
2385
+ {
2386
+ mode: new AnyWhere(),
2387
+ priority: 1,
2388
+ skipRecipientValidation: true,
2389
+ } as any,
2390
+ );
2391
+ const payload = toUint8Array(embedded.bytes());
2392
+ const [firstTarget] = entry.targets;
2393
+ if (entry.targets.size === 1 && firstTarget) {
2394
+ await this.sendFanoutUnicastOrBroadcast(shardTopic, firstTarget, payload);
2395
+ return;
2396
+ }
2397
+ const st = this.fanoutChannels.get(shardTopic);
2398
+ if (!st) return;
2399
+ await st.channel.publishMaybe(payload);
2400
+ }
2401
+
2049
2402
  private async sendFanoutUnicastOrBroadcast(
2050
2403
  shardTopic: string,
2051
2404
  targetHash: string,
@@ -2127,7 +2480,7 @@ export class TopicControlPlane
2127
2480
  this.initializePeer(sender);
2128
2481
 
2129
2482
  const existing = peers.get(senderKey);
2130
- if (!existing || existing.session < message.header.session) {
2483
+ if (this.subscribeShouldReplace(existing, message.header.session)) {
2131
2484
  peers.delete(senderKey);
2132
2485
  peers.set(
2133
2486
  senderKey,
@@ -2140,7 +2493,7 @@ export class TopicControlPlane
2140
2493
  changed.push(topic);
2141
2494
  } else {
2142
2495
  peers.delete(senderKey);
2143
- peers.set(senderKey, existing);
2496
+ peers.set(senderKey, existing!);
2144
2497
  }
2145
2498
 
2146
2499
  if (!existing) {
@@ -2236,7 +2589,7 @@ export class TopicControlPlane
2236
2589
  this.initializePeer(sender);
2237
2590
 
2238
2591
  const existing = peers.get(senderKey);
2239
- if (!existing || existing.session < message.header.session) {
2592
+ if (this.subscribeShouldReplace(existing, message.header.session)) {
2240
2593
  peers.delete(senderKey);
2241
2594
  peers.set(
2242
2595
  senderKey,
@@ -2249,7 +2602,7 @@ export class TopicControlPlane
2249
2602
  changed.push(topic);
2250
2603
  } else {
2251
2604
  peers.delete(senderKey);
2252
- peers.set(senderKey, existing);
2605
+ peers.set(senderKey, existing!);
2253
2606
  }
2254
2607
 
2255
2608
  if (!existing) {
@@ -2270,24 +2623,7 @@ export class TopicControlPlane
2270
2623
  if (pubsubMessage.requestSubscribers) {
2271
2624
  const overlap = this.getSubscriptionOverlap(pubsubMessage.topics);
2272
2625
  if (overlap.length > 0) {
2273
- const response = new Subscribe({
2274
- topics: overlap,
2275
- requestSubscribers: false,
2276
- });
2277
- const embedded = await this.createMessage(
2278
- toUint8Array(response.bytes()),
2279
- {
2280
- mode: new AnyWhere(),
2281
- priority: 1,
2282
- skipRecipientValidation: true,
2283
- } as any,
2284
- );
2285
- const payload = toUint8Array(embedded.bytes());
2286
- await this.sendFanoutUnicastOrBroadcast(
2287
- shardTopic,
2288
- senderKey,
2289
- payload,
2290
- );
2626
+ this.queueSubscriberResponse(shardTopic, senderKey, overlap);
2291
2627
  }
2292
2628
  }
2293
2629
  return;
@@ -2406,7 +2742,7 @@ export class TopicControlPlane
2406
2742
  requestSubscribers: false,
2407
2743
  });
2408
2744
  const embedded = await this.createMessage(
2409
- toUint8Array(response.bytes()),
2745
+ this.encodePubSubMessage(response),
2410
2746
  {
2411
2747
  mode: new AnyWhere(),
2412
2748
  priority: 1,
@@ -2432,7 +2768,7 @@ export class TopicControlPlane
2432
2768
 
2433
2769
  let pubsubMessage: PubSubMessage;
2434
2770
  try {
2435
- pubsubMessage = PubSubMessage.from(message.data);
2771
+ pubsubMessage = this.decodePubSubMessage(message.data);
2436
2772
  } catch {
2437
2773
  return super.onDataMessage(from, stream, message, seenBefore);
2438
2774
  }