@peerbit/pubsub 5.1.6 → 5.2.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.
@@ -335,6 +335,17 @@ export type FanoutProviderQueryOptions = {
335
335
  bootstrapMaxPeers?: number;
336
336
  };
337
337
 
338
+ export type FanoutProviderWatchOptions = {
339
+ want?: number;
340
+ signal?: AbortSignal;
341
+ onProviders: (providers: FanoutProviderCandidate[]) => void;
342
+ bootstrap?: Array<string | Multiaddr>;
343
+ bootstrapDialTimeoutMs?: number;
344
+ bootstrapMaxPeers?: number;
345
+ ttlMs?: number;
346
+ renewIntervalMs?: number;
347
+ };
348
+
338
349
  export type FanoutProviderHandle = {
339
350
  close: () => void;
340
351
  };
@@ -447,6 +458,10 @@ export interface FanoutTreeEvents extends StreamEvents {
447
458
  "fanout:unicast": CustomEvent<FanoutTreeUnicastEvent>;
448
459
  "fanout:joined": CustomEvent<{ topic: string; root: string; parent: string }>;
449
460
  "fanout:kicked": CustomEvent<{ topic: string; root: string; from: string }>;
461
+ "fanout:provider": CustomEvent<{
462
+ namespace: string;
463
+ providers: FanoutProviderCandidate[];
464
+ }>;
450
465
  "fanout:peer-unreachable": CustomEvent<{
451
466
  topic: string;
452
467
  root: string;
@@ -520,6 +535,9 @@ const MSG_TRACKER_FEEDBACK = 33;
520
535
  const MSG_PROVIDER_ANNOUNCE = 34;
521
536
  const MSG_PROVIDER_QUERY = 35;
522
537
  const MSG_PROVIDER_REPLY = 36;
538
+ const MSG_PROVIDER_SUBSCRIBE = 37;
539
+ const MSG_PROVIDER_UNSUBSCRIBE = 38;
540
+ const MSG_PROVIDER_NOTIFY = 39;
523
541
 
524
542
  const JOIN_REJECT_NOT_ATTACHED = 1;
525
543
  const JOIN_REJECT_NO_CAPACITY = 2;
@@ -1081,6 +1099,12 @@ type ProviderEntry = {
1081
1099
  expiresAt: number;
1082
1100
  };
1083
1101
 
1102
+ type ProviderWatchRegistration = {
1103
+ hash: string;
1104
+ want: number;
1105
+ expiresAt: number;
1106
+ };
1107
+
1084
1108
  type ProviderAnnounceState = {
1085
1109
  id: ProviderNamespaceId;
1086
1110
  ttlMs: number;
@@ -1092,6 +1116,21 @@ type ProviderAnnounceState = {
1092
1116
  loop?: Promise<void>;
1093
1117
  };
1094
1118
 
1119
+ type ProviderWatchState = {
1120
+ id: ProviderNamespaceId;
1121
+ want: number;
1122
+ ttlMs: number;
1123
+ renewIntervalMs: number;
1124
+ bootstrapOverride?: Multiaddr[];
1125
+ bootstrapDialTimeoutMs: number;
1126
+ bootstrapMaxPeers: number;
1127
+ onProviders: (providers: FanoutProviderCandidate[]) => void;
1128
+ signal?: AbortSignal;
1129
+ closed: boolean;
1130
+ trackerPeers: string[];
1131
+ loop?: Promise<void>;
1132
+ };
1133
+
1095
1134
  const encodeProviderAnnounce = (
1096
1135
  namespaceKey: Uint8Array,
1097
1136
  ttlMs: number,
@@ -1137,13 +1176,9 @@ const encodeProviderQuery = (
1137
1176
  return buf;
1138
1177
  };
1139
1178
 
1140
- const encodeProviderReply = (
1141
- namespaceKey: Uint8Array,
1142
- reqId: number,
1143
- entries: ProviderEntry[],
1144
- ) => {
1179
+ const encodeProviderEntries = (entries: ProviderEntry[]) => {
1145
1180
  const count = Math.max(0, Math.min(255, entries.length));
1146
- let bytes = 1 + 32 + 4 + 1;
1181
+ let bytes = 1;
1147
1182
  const encoded: Array<{ hashBytes: Uint8Array; addrs: Uint8Array[] }> = [];
1148
1183
  for (let i = 0; i < count; i++) {
1149
1184
  const e = entries[i]!;
@@ -1155,6 +1190,16 @@ const encodeProviderReply = (
1155
1190
  for (const a of addrs) bytes += 2 + a.length;
1156
1191
  encoded.push({ hashBytes, addrs });
1157
1192
  }
1193
+ return { bytes, encoded };
1194
+ };
1195
+
1196
+ const encodeProviderReply = (
1197
+ namespaceKey: Uint8Array,
1198
+ reqId: number,
1199
+ entries: ProviderEntry[],
1200
+ ) => {
1201
+ const { bytes: entryBytes, encoded } = encodeProviderEntries(entries);
1202
+ let bytes = 1 + 32 + 4 + entryBytes;
1158
1203
 
1159
1204
  const buf = new Uint8Array(bytes);
1160
1205
  buf[0] = MSG_PROVIDER_REPLY;
@@ -1177,6 +1222,90 @@ const encodeProviderReply = (
1177
1222
  return buf;
1178
1223
  };
1179
1224
 
1225
+ const encodeProviderSubscribe = (
1226
+ namespaceKey: Uint8Array,
1227
+ want: number,
1228
+ ttlMs: number,
1229
+ ) => {
1230
+ const buf = new Uint8Array(1 + 32 + 2 + 4);
1231
+ buf[0] = MSG_PROVIDER_SUBSCRIBE;
1232
+ buf.set(namespaceKey, 1);
1233
+ writeU16BE(buf, 33, clampU16(want));
1234
+ writeU32BE(buf, 35, Math.max(0, Math.floor(ttlMs)) >>> 0);
1235
+ return buf;
1236
+ };
1237
+
1238
+ const encodeProviderUnsubscribe = (namespaceKey: Uint8Array) => {
1239
+ const buf = new Uint8Array(1 + 32);
1240
+ buf[0] = MSG_PROVIDER_UNSUBSCRIBE;
1241
+ buf.set(namespaceKey, 1);
1242
+ return buf;
1243
+ };
1244
+
1245
+ const encodeProviderNotify = (
1246
+ namespaceKey: Uint8Array,
1247
+ entries: ProviderEntry[],
1248
+ ) => {
1249
+ const { bytes: entryBytes, encoded } = encodeProviderEntries(entries);
1250
+ let bytes = 1 + 32 + entryBytes;
1251
+
1252
+ const buf = new Uint8Array(bytes);
1253
+ buf[0] = MSG_PROVIDER_NOTIFY;
1254
+ buf.set(namespaceKey, 1);
1255
+ buf[33] = Math.max(0, Math.min(255, encoded.length)) & 0xff;
1256
+ let offset = 34;
1257
+ for (const e of encoded) {
1258
+ buf[offset++] = e.hashBytes.length & 0xff;
1259
+ buf.set(e.hashBytes, offset);
1260
+ offset += e.hashBytes.length;
1261
+ buf[offset++] = Math.max(0, Math.min(255, e.addrs.length)) & 0xff;
1262
+ for (const a of e.addrs) {
1263
+ writeU16BE(buf, offset, a.length);
1264
+ offset += 2;
1265
+ buf.set(a, offset);
1266
+ offset += a.length;
1267
+ }
1268
+ }
1269
+ return buf;
1270
+ };
1271
+
1272
+ const decodeProviderEntries = (
1273
+ data: Uint8Array,
1274
+ offsetStart: number,
1275
+ maxCount: number,
1276
+ ) => {
1277
+ let offset = offsetStart;
1278
+ const providers: FanoutProviderCandidate[] = [];
1279
+ const limit = Math.min(maxCount, 255);
1280
+ for (let i = 0; i < limit; i++) {
1281
+ if (offset + 1 > data.length) break;
1282
+ const hashLen = data[offset++]!;
1283
+ if (offset + hashLen > data.length) break;
1284
+ const hash = textDecoder.decode(data.subarray(offset, offset + hashLen));
1285
+ offset += hashLen;
1286
+ if (offset + 1 > data.length) break;
1287
+ const addrCount = data[offset++]!;
1288
+ const addrs: Multiaddr[] = [];
1289
+ const addrMax = Math.min(addrCount, 16);
1290
+ for (let j = 0; j < addrMax; j++) {
1291
+ if (offset + 2 > data.length) break;
1292
+ const len = readU16BE(data, offset);
1293
+ offset += 2;
1294
+ if (offset + len > data.length) break;
1295
+ const bytes = data.subarray(offset, offset + len);
1296
+ offset += len;
1297
+ try {
1298
+ addrs.push(multiaddr(bytes));
1299
+ } catch {
1300
+ // ignore invalid
1301
+ }
1302
+ }
1303
+ if (!hash) continue;
1304
+ providers.push({ hash, addrs });
1305
+ }
1306
+ return { providers, offset };
1307
+ };
1308
+
1180
1309
  type ChildInfo = { bidPerByte: number };
1181
1310
 
1182
1311
  type JoinAttemptResult = {
@@ -1433,6 +1562,12 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
1433
1562
  private trackerNamespaceLru = new Map<string, number>();
1434
1563
  private providerBySuffixKey = new Map<string, Map<string, ProviderEntry>>();
1435
1564
  private providerNamespaceLru = new Map<string, number>();
1565
+ private providerNamespaceBySuffixKey = new Map<string, string>();
1566
+ private providerWatchersBySuffixKey = new Map<
1567
+ string,
1568
+ Map<string, ProviderWatchRegistration>
1569
+ >();
1570
+ private providerWatchesBySuffixKey = new Map<string, Set<ProviderWatchState>>();
1436
1571
  private underlayPeerDisconnectHandler?: (ev: any) => void;
1437
1572
  private pendingProviderQueryBySuffixKey = new Map<
1438
1573
  string,
@@ -1533,6 +1668,7 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
1533
1668
  if (!oldest) break;
1534
1669
  this.providerNamespaceLru.delete(oldest);
1535
1670
  this.providerBySuffixKey.delete(oldest);
1671
+ this.providerNamespaceBySuffixKey.delete(oldest);
1536
1672
  this.pendingProviderQueryBySuffixKey.delete(oldest);
1537
1673
  }
1538
1674
  }
@@ -1546,12 +1682,136 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
1546
1682
  }
1547
1683
  }
1548
1684
 
1685
+ private pruneProviderWatchersIfEmpty(suffixKey: string) {
1686
+ const watchers = this.providerWatchersBySuffixKey.get(suffixKey);
1687
+ if (watchers && watchers.size === 0) {
1688
+ this.providerWatchersBySuffixKey.delete(suffixKey);
1689
+ }
1690
+ }
1691
+
1692
+ private getProviderCandidatesFromCache(
1693
+ id: ProviderNamespaceId,
1694
+ options?: { now?: number; want?: number },
1695
+ ): FanoutProviderCandidate[] {
1696
+ const now = options?.now ?? Date.now();
1697
+ const cached: FanoutProviderCandidate[] = [];
1698
+ const byPeer = this.providerBySuffixKey.get(id.suffixKey);
1699
+ if (byPeer) {
1700
+ for (const [hash, e] of byPeer) {
1701
+ if (e.expiresAt <= now) {
1702
+ byPeer.delete(hash);
1703
+ continue;
1704
+ }
1705
+ if (hash === this.publicKeyHash) continue;
1706
+ const addrs: Multiaddr[] = [];
1707
+ for (const a of e.addrs) {
1708
+ try {
1709
+ addrs.push(multiaddr(a));
1710
+ } catch {
1711
+ // ignore invalid
1712
+ }
1713
+ }
1714
+ cached.push({ hash, addrs });
1715
+ if (options?.want && cached.length >= options.want) break;
1716
+ }
1717
+ this.pruneProviderNamespaceIfEmpty(id.suffixKey);
1718
+ }
1719
+ return cached;
1720
+ }
1721
+
1722
+ private rememberProviderCandidates(
1723
+ id: ProviderNamespaceId,
1724
+ candidates: FanoutProviderCandidate[],
1725
+ expiresAt: number,
1726
+ ) {
1727
+ if (candidates.length === 0) return;
1728
+ let cache = this.providerBySuffixKey.get(id.suffixKey);
1729
+ if (!cache) {
1730
+ cache = new Map<string, ProviderEntry>();
1731
+ this.providerBySuffixKey.set(id.suffixKey, cache);
1732
+ }
1733
+ for (const c of candidates) {
1734
+ cache.delete(c.hash);
1735
+ cache.set(c.hash, {
1736
+ hash: c.hash,
1737
+ addrs: c.addrs.map((a) => a.bytes),
1738
+ expiresAt,
1739
+ });
1740
+ }
1741
+ while (cache.size > PROVIDER_DIRECTORY_MAX_ENTRIES) {
1742
+ const oldest = cache.keys().next().value as string | undefined;
1743
+ if (!oldest) break;
1744
+ cache.delete(oldest);
1745
+ }
1746
+ this.touchProviderNamespace(id.suffixKey);
1747
+ }
1748
+
1749
+ private emitProviderUpdate(
1750
+ id: ProviderNamespaceId,
1751
+ providers: FanoutProviderCandidate[],
1752
+ ) {
1753
+ if (providers.length === 0) return;
1754
+ this.dispatchEvent(
1755
+ new CustomEvent("fanout:provider", {
1756
+ detail: { namespace: id.namespace, providers },
1757
+ }),
1758
+ );
1759
+ const localWatches = this.providerWatchesBySuffixKey.get(id.suffixKey);
1760
+ if (!localWatches || localWatches.size === 0) return;
1761
+ for (const watch of localWatches) {
1762
+ if (watch.closed) continue;
1763
+ try {
1764
+ watch.onProviders(providers.slice(0, watch.want));
1765
+ } catch {
1766
+ // ignore consumer callback failures
1767
+ }
1768
+ }
1769
+ }
1770
+
1771
+ private async publishProviderWatchUpdate(id: ProviderNamespaceId) {
1772
+ const watchers = this.providerWatchersBySuffixKey.get(id.suffixKey);
1773
+ if (!watchers || watchers.size === 0) return;
1774
+ const now = Date.now();
1775
+ const cached = this.getProviderCandidatesFromCache(id, { now });
1776
+ const byPeer = new Map(watchers);
1777
+ for (const [hash, watch] of byPeer) {
1778
+ if (watch.expiresAt <= now) {
1779
+ watchers.delete(hash);
1780
+ continue;
1781
+ }
1782
+ const entries = cached
1783
+ .filter((candidate) => candidate.hash !== hash)
1784
+ .slice(0, Math.max(1, watch.want))
1785
+ .map((candidate) => ({
1786
+ hash: candidate.hash,
1787
+ addrs: candidate.addrs.map((a) => a.bytes),
1788
+ expiresAt: now + 60_000,
1789
+ }));
1790
+ void this._sendControl(hash, encodeProviderNotify(id.key, entries)).catch(
1791
+ dontThrowIfDeliveryError,
1792
+ );
1793
+ }
1794
+ this.pruneProviderWatchersIfEmpty(id.suffixKey);
1795
+ }
1796
+
1549
1797
  private getProviderNamespaceId(namespace: string): ProviderNamespaceId {
1550
1798
  const key = sha256Sync(textEncoder.encode(`provider|${namespace}`));
1551
1799
  const suffixKey = toBase64(key.subarray(0, 24));
1800
+ this.providerNamespaceBySuffixKey.set(suffixKey, namespace);
1552
1801
  return { namespace, key, suffixKey };
1553
1802
  }
1554
1803
 
1804
+ private getProviderNamespaceIdFromKey(
1805
+ key: Uint8Array,
1806
+ suffixKey = toBase64(key.subarray(0, 24)),
1807
+ ): ProviderNamespaceId {
1808
+ return {
1809
+ namespace: this.providerNamespaceBySuffixKey.get(suffixKey) ?? "",
1810
+ key,
1811
+ suffixKey,
1812
+ };
1813
+ }
1814
+
1555
1815
  public provide(
1556
1816
  namespace: string,
1557
1817
  options: FanoutProviderAnnounceOptions = {},
@@ -1770,27 +2030,7 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
1770
2030
  return connected.concat(unconnected).slice(0, want);
1771
2031
  };
1772
2032
 
1773
- const cached: FanoutProviderCandidate[] = [];
1774
- const byPeer = this.providerBySuffixKey.get(id.suffixKey);
1775
- if (byPeer) {
1776
- for (const [hash, e] of byPeer) {
1777
- if (e.expiresAt <= now) {
1778
- byPeer.delete(hash);
1779
- continue;
1780
- }
1781
- if (hash === this.publicKeyHash) continue;
1782
- const addrs: Multiaddr[] = [];
1783
- for (const a of e.addrs) {
1784
- try {
1785
- addrs.push(multiaddr(a));
1786
- } catch {
1787
- // ignore invalid
1788
- }
1789
- }
1790
- cached.push({ hash, addrs });
1791
- }
1792
- this.pruneProviderNamespaceIfEmpty(id.suffixKey);
1793
- }
2033
+ const cached = this.getProviderCandidatesFromCache(id, { now });
1794
2034
 
1795
2035
  // If the cache is warm, avoid tracker queries on hot paths.
1796
2036
  if (cached.length >= want) {
@@ -1876,29 +2116,14 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
1876
2116
 
1877
2117
  // Cache (best-effort) to avoid repeated tracker lookups.
1878
2118
  if (cacheTtlMs > 0) {
1879
- const exp = Date.now() + cacheTtlMs;
1880
- let cache = this.providerBySuffixKey.get(id.suffixKey);
1881
- if (!cache) {
1882
- cache = new Map<string, ProviderEntry>();
1883
- this.providerBySuffixKey.set(id.suffixKey, cache);
1884
- }
1885
- for (const c of deduped) {
1886
- cache.delete(c.hash);
1887
- cache.set(c.hash, {
1888
- hash: c.hash,
1889
- addrs: c.addrs.map((a) => a.bytes),
1890
- expiresAt: exp,
1891
- });
1892
- }
1893
- while (cache.size > PROVIDER_DIRECTORY_MAX_ENTRIES) {
1894
- const oldest = cache.keys().next().value as string | undefined;
1895
- if (!oldest) break;
1896
- cache.delete(oldest);
1897
- }
1898
- this.touchProviderNamespace(id.suffixKey);
2119
+ this.rememberProviderCandidates(id, deduped, Date.now() + cacheTtlMs);
1899
2120
  }
1900
2121
 
1901
- return orderCandidates(deduped);
2122
+ const ordered = orderCandidates(deduped);
2123
+ if (ordered.length > 0) {
2124
+ this.emitProviderUpdate(id, ordered);
2125
+ }
2126
+ return ordered;
1902
2127
  } finally {
1903
2128
  (signal as any)?.clear?.();
1904
2129
  }
@@ -1912,6 +2137,134 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
1912
2137
  return candidates.map((c) => c.hash);
1913
2138
  }
1914
2139
 
2140
+ public watchProviders(
2141
+ namespace: string,
2142
+ options: FanoutProviderWatchOptions,
2143
+ ): FanoutProviderHandle {
2144
+ if (!this.started) {
2145
+ throw new Error("FanoutTree must be started before watching providers");
2146
+ }
2147
+
2148
+ const id = this.getProviderNamespaceId(namespace);
2149
+ const ttlMs = Math.max(1_000, Math.floor(options.ttlMs ?? 10_000));
2150
+ const renewIntervalMs = Math.max(
2151
+ 250,
2152
+ Math.min(
2153
+ ttlMs,
2154
+ Math.floor(options.renewIntervalMs ?? Math.max(500, ttlMs / 2)),
2155
+ ),
2156
+ );
2157
+ const watch: ProviderWatchState = {
2158
+ id,
2159
+ want: Math.max(1, Math.floor(options.want ?? 8)),
2160
+ ttlMs,
2161
+ renewIntervalMs,
2162
+ bootstrapOverride:
2163
+ options.bootstrap && options.bootstrap.length > 0
2164
+ ? options.bootstrap
2165
+ .map((a) => (typeof a === "string" ? multiaddr(a) : a))
2166
+ .filter((a) => Boolean(a))
2167
+ : undefined,
2168
+ bootstrapDialTimeoutMs: Math.max(
2169
+ 0,
2170
+ Math.floor(options.bootstrapDialTimeoutMs ?? 2_000),
2171
+ ),
2172
+ bootstrapMaxPeers: Math.max(
2173
+ 0,
2174
+ Math.floor(options.bootstrapMaxPeers ?? 0),
2175
+ ),
2176
+ onProviders: options.onProviders,
2177
+ signal: options.signal,
2178
+ closed: false,
2179
+ trackerPeers: [],
2180
+ };
2181
+
2182
+ let localWatches = this.providerWatchesBySuffixKey.get(id.suffixKey);
2183
+ if (!localWatches) {
2184
+ localWatches = new Set<ProviderWatchState>();
2185
+ this.providerWatchesBySuffixKey.set(id.suffixKey, localWatches);
2186
+ }
2187
+ localWatches.add(watch);
2188
+
2189
+ const cached = this.getProviderCandidatesFromCache(id, {
2190
+ want: watch.want,
2191
+ });
2192
+ if (cached.length > 0) {
2193
+ queueMicrotask(() => {
2194
+ if (watch.closed) return;
2195
+ try {
2196
+ watch.onProviders(cached.slice(0, watch.want));
2197
+ } catch {
2198
+ // ignore consumer callback failures
2199
+ }
2200
+ });
2201
+ }
2202
+
2203
+ const close = () => {
2204
+ if (watch.closed) return;
2205
+ watch.closed = true;
2206
+ localWatches?.delete(watch);
2207
+ if (localWatches && localWatches.size === 0) {
2208
+ this.providerWatchesBySuffixKey.delete(id.suffixKey);
2209
+ }
2210
+ for (const trackerHash of watch.trackerPeers) {
2211
+ void this._sendControl(
2212
+ trackerHash,
2213
+ encodeProviderUnsubscribe(id.key),
2214
+ ).catch(dontThrowIfDeliveryError);
2215
+ }
2216
+ watch.trackerPeers = [];
2217
+ };
2218
+
2219
+ const onAbort = () => close();
2220
+ watch.signal?.addEventListener("abort", onAbort, { once: true });
2221
+
2222
+ const loopSignal = watch.signal
2223
+ ? anySignal([this.closeController.signal, watch.signal])
2224
+ : this.closeController.signal;
2225
+
2226
+ watch.loop = (async () => {
2227
+ for (;;) {
2228
+ if (watch.closed || loopSignal.aborted) return;
2229
+ try {
2230
+ const bootstraps = watch.bootstrapOverride ?? this.bootstraps;
2231
+ const trackerPeers =
2232
+ bootstraps.length > 0
2233
+ ? await this.ensureBootstrapPeers(
2234
+ bootstraps,
2235
+ watch.bootstrapDialTimeoutMs,
2236
+ loopSignal,
2237
+ watch.bootstrapMaxPeers,
2238
+ )
2239
+ : [];
2240
+ watch.trackerPeers = trackerPeers;
2241
+ for (const trackerHash of trackerPeers) {
2242
+ void this._sendControl(
2243
+ trackerHash,
2244
+ encodeProviderSubscribe(id.key, watch.want, watch.ttlMs),
2245
+ ).catch(dontThrowIfDeliveryError);
2246
+ }
2247
+ } catch (error) {
2248
+ if (
2249
+ error instanceof AbortError ||
2250
+ (error && typeof error === "object" && "name" in error && error.name === "AbortError")
2251
+ ) {
2252
+ return;
2253
+ }
2254
+ }
2255
+ await delay(watch.renewIntervalMs, { signal: loopSignal }).catch(() => {});
2256
+ }
2257
+ })();
2258
+
2259
+ void watch.loop.finally(() => {
2260
+ watch.signal?.removeEventListener("abort", onAbort);
2261
+ close();
2262
+ (loopSignal as any)?.clear?.();
2263
+ });
2264
+
2265
+ return { close };
2266
+ }
2267
+
1915
2268
  public getChannelId(topic: string, root: string): FanoutTreeChannelId {
1916
2269
  const key = sha256Sync(textEncoder.encode(`fanout-tree|${root}|${topic}`));
1917
2270
  const suffixKey = toBase64(key.subarray(0, 24));
@@ -3325,6 +3678,9 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
3325
3678
  case MSG_PROVIDER_ANNOUNCE:
3326
3679
  case MSG_PROVIDER_QUERY:
3327
3680
  case MSG_PROVIDER_REPLY:
3681
+ case MSG_PROVIDER_SUBSCRIBE:
3682
+ case MSG_PROVIDER_UNSUBSCRIBE:
3683
+ case MSG_PROVIDER_NOTIFY:
3328
3684
  m.controlBytesSentTracker += sentBytes;
3329
3685
  break;
3330
3686
  default:
@@ -3388,6 +3744,9 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
3388
3744
  case MSG_PROVIDER_ANNOUNCE:
3389
3745
  case MSG_PROVIDER_QUERY:
3390
3746
  case MSG_PROVIDER_REPLY:
3747
+ case MSG_PROVIDER_SUBSCRIBE:
3748
+ case MSG_PROVIDER_UNSUBSCRIBE:
3749
+ case MSG_PROVIDER_NOTIFY:
3391
3750
  m.controlBytesReceivedTracker += bytesReceived;
3392
3751
  break;
3393
3752
  default:
@@ -5228,7 +5587,10 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
5228
5587
  kind === MSG_TRACKER_FEEDBACK ||
5229
5588
  kind === MSG_PROVIDER_ANNOUNCE ||
5230
5589
  kind === MSG_PROVIDER_QUERY ||
5231
- kind === MSG_PROVIDER_REPLY
5590
+ kind === MSG_PROVIDER_REPLY ||
5591
+ kind === MSG_PROVIDER_SUBSCRIBE ||
5592
+ kind === MSG_PROVIDER_UNSUBSCRIBE ||
5593
+ kind === MSG_PROVIDER_NOTIFY
5232
5594
  ) {
5233
5595
  if (data.length < 1 + 32) return false;
5234
5596
  const channelKey = data.subarray(1, 33);
@@ -5398,6 +5760,7 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
5398
5760
 
5399
5761
  if (kind === MSG_PROVIDER_ANNOUNCE) {
5400
5762
  if (data.length < 1 + 32 + 4 + 1) return false;
5763
+ const providerId = this.getProviderNamespaceIdFromKey(channelKey, suffixKey);
5401
5764
  const ttlMs = readU32BE(data, 33);
5402
5765
  const addrCount = data[37]!;
5403
5766
  let offset = 38;
@@ -5442,6 +5805,11 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
5442
5805
  if (ttl === 0) {
5443
5806
  byPeer.delete(fromHash);
5444
5807
  this.pruneProviderNamespaceIfEmpty(suffixKey);
5808
+ this.emitProviderUpdate(
5809
+ providerId,
5810
+ this.getProviderCandidatesFromCache(providerId, { now }),
5811
+ );
5812
+ await this.publishProviderWatchUpdate(providerId);
5445
5813
  return true;
5446
5814
  }
5447
5815
 
@@ -5458,6 +5826,11 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
5458
5826
  byPeer.delete(oldest);
5459
5827
  }
5460
5828
  this.pruneProviderNamespaceIfEmpty(suffixKey);
5829
+ this.emitProviderUpdate(
5830
+ providerId,
5831
+ this.getProviderCandidatesFromCache(providerId, { now }),
5832
+ );
5833
+ await this.publishProviderWatchUpdate(providerId);
5461
5834
  return true;
5462
5835
  }
5463
5836
 
@@ -5511,6 +5884,34 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
5511
5884
  return true;
5512
5885
  }
5513
5886
 
5887
+ if (kind === MSG_PROVIDER_SUBSCRIBE) {
5888
+ if (data.length < 1 + 32 + 2 + 4) return false;
5889
+ const want = Math.max(1, readU16BE(data, 33));
5890
+ const ttlMs = Math.min(120_000, Math.max(1_000, readU32BE(data, 35)));
5891
+ const now = Date.now();
5892
+ let watchers = this.providerWatchersBySuffixKey.get(suffixKey);
5893
+ if (!watchers) {
5894
+ watchers = new Map<string, ProviderWatchRegistration>();
5895
+ this.providerWatchersBySuffixKey.set(suffixKey, watchers);
5896
+ }
5897
+ watchers.set(fromHash, {
5898
+ hash: fromHash,
5899
+ want,
5900
+ expiresAt: now + ttlMs,
5901
+ });
5902
+ await this.publishProviderWatchUpdate(
5903
+ this.getProviderNamespaceIdFromKey(channelKey, suffixKey),
5904
+ );
5905
+ return true;
5906
+ }
5907
+
5908
+ if (kind === MSG_PROVIDER_UNSUBSCRIBE) {
5909
+ const watchers = this.providerWatchersBySuffixKey.get(suffixKey);
5910
+ watchers?.delete(fromHash);
5911
+ this.pruneProviderWatchersIfEmpty(suffixKey);
5912
+ return true;
5913
+ }
5914
+
5514
5915
  if (kind === MSG_PROVIDER_REPLY) {
5515
5916
  if (data.length < 1 + 32 + 4 + 1) return false;
5516
5917
  const reqId = readU32BE(data, 33);
@@ -5521,59 +5922,41 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
5521
5922
  pendingByReq!.delete(reqId);
5522
5923
 
5523
5924
  const count = data[37]!;
5524
- let offset = 38;
5525
- const candidates: FanoutProviderCandidate[] = [];
5526
- const max = Math.min(count, 255);
5527
-
5528
5925
  const now = Date.now();
5529
5926
  this.touchProviderNamespace(suffixKey, now);
5530
- let cache = this.providerBySuffixKey.get(suffixKey);
5531
- if (!cache) {
5532
- cache = new Map<string, ProviderEntry>();
5533
- this.providerBySuffixKey.set(suffixKey, cache);
5927
+ const { providers: candidates } = decodeProviderEntries(data, 38, count);
5928
+ this.rememberProviderCandidates(
5929
+ this.getProviderNamespaceIdFromKey(channelKey, suffixKey),
5930
+ candidates,
5931
+ now + 60_000,
5932
+ );
5933
+ this.pruneProviderNamespaceIfEmpty(suffixKey);
5934
+ if (candidates.length > 0) {
5935
+ this.emitProviderUpdate(
5936
+ this.getProviderNamespaceIdFromKey(channelKey, suffixKey),
5937
+ candidates,
5938
+ );
5534
5939
  }
5940
+ pending.resolve(candidates);
5941
+ return true;
5942
+ }
5535
5943
 
5536
- for (let i = 0; i < max; i++) {
5537
- if (offset + 1 > data.length) break;
5538
- const hashLen = data[offset++]!;
5539
- if (offset + hashLen > data.length) break;
5540
- const hash = textDecoder.decode(data.subarray(offset, offset + hashLen));
5541
- offset += hashLen;
5542
- if (offset + 1 > data.length) break;
5543
- const addrCount = data[offset++]!;
5544
- const addrs: Multiaddr[] = [];
5545
- const addrBytes: Uint8Array[] = [];
5546
- const addrMax = Math.min(addrCount, 16);
5547
- for (let j = 0; j < addrMax; j++) {
5548
- if (offset + 2 > data.length) break;
5549
- const len = readU16BE(data, offset);
5550
- offset += 2;
5551
- if (offset + len > data.length) break;
5552
- const bytes = data.subarray(offset, offset + len);
5553
- offset += len;
5554
- addrBytes.push(bytes);
5555
- try {
5556
- addrs.push(multiaddr(bytes));
5557
- } catch {
5558
- // ignore invalid multiaddrs
5559
- }
5560
- }
5561
- candidates.push({ hash, addrs });
5562
- cache.delete(hash);
5563
- cache.set(hash, {
5564
- hash,
5565
- addrs: addrBytes,
5566
- expiresAt: now + 60_000,
5567
- });
5568
- }
5569
- while (cache.size > PROVIDER_DIRECTORY_MAX_ENTRIES) {
5570
- const oldest = cache.keys().next().value as string | undefined;
5571
- if (!oldest) break;
5572
- cache.delete(oldest);
5944
+ if (kind === MSG_PROVIDER_NOTIFY) {
5945
+ if (data.length < 1 + 32 + 1) return false;
5946
+ const count = data[33]!;
5947
+ const now = Date.now();
5948
+ const { providers } = decodeProviderEntries(data, 34, count);
5949
+ this.rememberProviderCandidates(
5950
+ this.getProviderNamespaceIdFromKey(channelKey, suffixKey),
5951
+ providers,
5952
+ now + 60_000,
5953
+ );
5954
+ if (providers.length > 0) {
5955
+ this.emitProviderUpdate(
5956
+ this.getProviderNamespaceIdFromKey(channelKey, suffixKey),
5957
+ providers,
5958
+ );
5573
5959
  }
5574
- this.pruneProviderNamespaceIfEmpty(suffixKey);
5575
-
5576
- pending.resolve(candidates);
5577
5960
  return true;
5578
5961
  }
5579
5962