@peerbit/pubsub 5.1.5 → 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));
@@ -3147,10 +3500,22 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
3147
3500
  root: string,
3148
3501
  payload: Uint8Array,
3149
3502
  ): Promise<boolean> {
3503
+ const id = this.getChannelId(topic, root);
3504
+ const ch = this.channelsBySuffixKey.get(id.suffixKey);
3505
+ if (!ch || ch.closed) {
3506
+ return false;
3507
+ }
3508
+
3150
3509
  try {
3151
3510
  await this.publishToChannel(topic, root, payload);
3152
3511
  return true;
3153
3512
  } catch (error) {
3513
+ if (
3514
+ error instanceof Error &&
3515
+ error.message.startsWith(`Channel not open: ${topic} (${root})`)
3516
+ ) {
3517
+ return false;
3518
+ }
3154
3519
  dontThrowIfDeliveryError(error);
3155
3520
  return false;
3156
3521
  }
@@ -3313,6 +3678,9 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
3313
3678
  case MSG_PROVIDER_ANNOUNCE:
3314
3679
  case MSG_PROVIDER_QUERY:
3315
3680
  case MSG_PROVIDER_REPLY:
3681
+ case MSG_PROVIDER_SUBSCRIBE:
3682
+ case MSG_PROVIDER_UNSUBSCRIBE:
3683
+ case MSG_PROVIDER_NOTIFY:
3316
3684
  m.controlBytesSentTracker += sentBytes;
3317
3685
  break;
3318
3686
  default:
@@ -3376,6 +3744,9 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
3376
3744
  case MSG_PROVIDER_ANNOUNCE:
3377
3745
  case MSG_PROVIDER_QUERY:
3378
3746
  case MSG_PROVIDER_REPLY:
3747
+ case MSG_PROVIDER_SUBSCRIBE:
3748
+ case MSG_PROVIDER_UNSUBSCRIBE:
3749
+ case MSG_PROVIDER_NOTIFY:
3379
3750
  m.controlBytesReceivedTracker += bytesReceived;
3380
3751
  break;
3381
3752
  default:
@@ -5216,7 +5587,10 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
5216
5587
  kind === MSG_TRACKER_FEEDBACK ||
5217
5588
  kind === MSG_PROVIDER_ANNOUNCE ||
5218
5589
  kind === MSG_PROVIDER_QUERY ||
5219
- kind === MSG_PROVIDER_REPLY
5590
+ kind === MSG_PROVIDER_REPLY ||
5591
+ kind === MSG_PROVIDER_SUBSCRIBE ||
5592
+ kind === MSG_PROVIDER_UNSUBSCRIBE ||
5593
+ kind === MSG_PROVIDER_NOTIFY
5220
5594
  ) {
5221
5595
  if (data.length < 1 + 32) return false;
5222
5596
  const channelKey = data.subarray(1, 33);
@@ -5386,6 +5760,7 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
5386
5760
 
5387
5761
  if (kind === MSG_PROVIDER_ANNOUNCE) {
5388
5762
  if (data.length < 1 + 32 + 4 + 1) return false;
5763
+ const providerId = this.getProviderNamespaceIdFromKey(channelKey, suffixKey);
5389
5764
  const ttlMs = readU32BE(data, 33);
5390
5765
  const addrCount = data[37]!;
5391
5766
  let offset = 38;
@@ -5430,6 +5805,11 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
5430
5805
  if (ttl === 0) {
5431
5806
  byPeer.delete(fromHash);
5432
5807
  this.pruneProviderNamespaceIfEmpty(suffixKey);
5808
+ this.emitProviderUpdate(
5809
+ providerId,
5810
+ this.getProviderCandidatesFromCache(providerId, { now }),
5811
+ );
5812
+ await this.publishProviderWatchUpdate(providerId);
5433
5813
  return true;
5434
5814
  }
5435
5815
 
@@ -5446,6 +5826,11 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
5446
5826
  byPeer.delete(oldest);
5447
5827
  }
5448
5828
  this.pruneProviderNamespaceIfEmpty(suffixKey);
5829
+ this.emitProviderUpdate(
5830
+ providerId,
5831
+ this.getProviderCandidatesFromCache(providerId, { now }),
5832
+ );
5833
+ await this.publishProviderWatchUpdate(providerId);
5449
5834
  return true;
5450
5835
  }
5451
5836
 
@@ -5499,6 +5884,34 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
5499
5884
  return true;
5500
5885
  }
5501
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
+
5502
5915
  if (kind === MSG_PROVIDER_REPLY) {
5503
5916
  if (data.length < 1 + 32 + 4 + 1) return false;
5504
5917
  const reqId = readU32BE(data, 33);
@@ -5509,59 +5922,41 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
5509
5922
  pendingByReq!.delete(reqId);
5510
5923
 
5511
5924
  const count = data[37]!;
5512
- let offset = 38;
5513
- const candidates: FanoutProviderCandidate[] = [];
5514
- const max = Math.min(count, 255);
5515
-
5516
5925
  const now = Date.now();
5517
5926
  this.touchProviderNamespace(suffixKey, now);
5518
- let cache = this.providerBySuffixKey.get(suffixKey);
5519
- if (!cache) {
5520
- cache = new Map<string, ProviderEntry>();
5521
- 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
+ );
5522
5939
  }
5940
+ pending.resolve(candidates);
5941
+ return true;
5942
+ }
5523
5943
 
5524
- for (let i = 0; i < max; i++) {
5525
- if (offset + 1 > data.length) break;
5526
- const hashLen = data[offset++]!;
5527
- if (offset + hashLen > data.length) break;
5528
- const hash = textDecoder.decode(data.subarray(offset, offset + hashLen));
5529
- offset += hashLen;
5530
- if (offset + 1 > data.length) break;
5531
- const addrCount = data[offset++]!;
5532
- const addrs: Multiaddr[] = [];
5533
- const addrBytes: Uint8Array[] = [];
5534
- const addrMax = Math.min(addrCount, 16);
5535
- for (let j = 0; j < addrMax; j++) {
5536
- if (offset + 2 > data.length) break;
5537
- const len = readU16BE(data, offset);
5538
- offset += 2;
5539
- if (offset + len > data.length) break;
5540
- const bytes = data.subarray(offset, offset + len);
5541
- offset += len;
5542
- addrBytes.push(bytes);
5543
- try {
5544
- addrs.push(multiaddr(bytes));
5545
- } catch {
5546
- // ignore invalid multiaddrs
5547
- }
5548
- }
5549
- candidates.push({ hash, addrs });
5550
- cache.delete(hash);
5551
- cache.set(hash, {
5552
- hash,
5553
- addrs: addrBytes,
5554
- expiresAt: now + 60_000,
5555
- });
5556
- }
5557
- while (cache.size > PROVIDER_DIRECTORY_MAX_ENTRIES) {
5558
- const oldest = cache.keys().next().value as string | undefined;
5559
- if (!oldest) break;
5560
- 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
+ );
5561
5959
  }
5562
- this.pruneProviderNamespaceIfEmpty(suffixKey);
5563
-
5564
- pending.resolve(candidates);
5565
5960
  return true;
5566
5961
  }
5567
5962