@dcasegr/skein 0.1.2 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/host/p2p.js CHANGED
@@ -16,8 +16,9 @@
16
16
  // compressed key), so the key reads straight out of the peer ID.
17
17
  // node GossipSub (StrictSign: every message is signed by its publisher's
18
18
  // peer key), noise + yamux, TCP and WebSocket listeners (WSS with a
19
- // certificate), Kademlia DHT off | client | server, mDNS on/off,
20
- // bootstrap peers kept connected, circuit relays.
19
+ // certificate), Kademlia DHT off | auto | client | server, AutoNAT,
20
+ // UPnP/NAT-PMP port mapping, mDNS on/off, bootstrap peers kept
21
+ // connected, circuit relays.
21
22
  // topics the instance's `libp2p.topics`, its dispatch table's libp2p
22
23
  // rows' topics (libp2pConfig) and the topics its apps subscribed
23
24
  // (#119: `subscribe {topic, program, fn}` / `unsubscribe {topic}`,
@@ -53,10 +54,31 @@
53
54
  // read from a dialled stream handed back (`frames`) to become a
54
55
  // message to the instance — the next frame arrives as an entry.
55
56
  //
57
+ // The host node (#156, one node): the node of the HOST key (hostKey: on a one-key host the key
58
+ // every instance's node uses, so the instances join it) runs whether or not an instance declares
59
+ // libp2p — the Teranode headers tracker rides it (teranode.ts: the block topic subscribed as a host
60
+ // topic, hostTopic, judged by the tracker, never the front door). Every node carries ONE Kademlia
61
+ // DHT, ours: `skeinDht` on `/skein/kad/<network>/1.0.0` for the overlay's topics (discover()
62
+ // provides and finds on it). There is no Teranode DHT (2026-11-02, plans/teranode-dht-off-2026-11-02.md):
63
+ // the tracker's block-topic peers come from its bootstrap list (SKEIN_TERANODE_BOOTSTRAP, the
64
+ // network's defaults unioned) and GossipSub. Teranode's DHT is the public IPFS DHT; even a kad client
65
+ // crawls it and fills a js-libp2p node's connection cap, which then refuses every newcomer.
66
+ //
56
67
  // Host-wide settings (hostP2PConfig): SKEIN_LIBP2P_LISTEN, _BOOTSTRAP, _RELAYS
57
- // (comma-separated multiaddrs), _DHT (off | client | server), _MDNS (on | off),
58
- // _TLS_CERT / _TLS_KEY (PEM files: the WebSocket listener serves TLS, so
59
- // browsers can dial /tls/ws); from the environment, else $SKEIN_HOME/host.env.
68
+ // (comma-separated multiaddrs), _DHT (off | auto | client | server, default auto: the skein DHT's mode), _UPNP
69
+ // (on | off, default on), _MDNS (on | off), _TLS_CERT / _TLS_KEY (PEM files: the WebSocket listener
70
+ // serves TLS, so browsers can dial /tls/ws); from the environment, else $SKEIN_HOME/host.env.
71
+ //
72
+ // The defaults are tuned for discovery (2026-11-01, plans/open-defaults-2026-11-01.md): TCP on every
73
+ // interface, IPv4 and IPv6, on ONE fixed port (DEFAULT_PORT, the same on every install, so a user who
74
+ // opens one port knows which; taken at start: one line, and an OS-chosen port on the same interfaces);
75
+ // the WebSocket listener stays on loopback (plain WS off loopback is dev only). DHT (the skein DHT) `auto` = kad-DHT's
76
+ // own switch (clientMode left unset: a client until the node has a confirmed public address, then a
77
+ // server, and back) with AutoNAT confirming addresses; `client` = only ever a client; `server` = a
78
+ // server from the start (a hosted node behind a known-open port need not wait for AutoNAT). UPnP asks
79
+ // the router to map the port where it allows it (a router that refuses or none found: a line, nothing
80
+ // else); its mapped address is confirmed by AutoNAT before it is announced, so AutoNAT runs whenever
81
+ // UPnP is on or the DHT is auto or server.
60
82
  import { existsSync, readFileSync } from "node:fs";
61
83
  import { join } from "node:path";
62
84
  import { noise } from "@chainsafe/libp2p-noise";
@@ -65,14 +87,18 @@ import { circuitRelayTransport } from "@libp2p/circuit-relay-v2";
65
87
  import { privateKeyFromRaw } from "@libp2p/crypto/keys";
66
88
  import { gossipsub, TopicValidatorResult } from "@libp2p/gossipsub";
67
89
  import { identify } from "@libp2p/identify";
90
+ import { autoNAT } from "@libp2p/autonat";
68
91
  import { kadDHT, passthroughMapper, removePrivateAddressesMapper } from "@libp2p/kad-dht";
92
+ import { defaultLogger } from "@libp2p/logger";
69
93
  import { mdns } from "@libp2p/mdns";
70
94
  import { peerIdFromPrivateKey, peerIdFromString } from "@libp2p/peer-id";
71
95
  import { ping } from "@libp2p/ping";
72
96
  import { tcp } from "@libp2p/tcp";
97
+ import { uPnPNAT } from "@libp2p/upnp-nat";
73
98
  import { isPrivate, lpStream } from "@libp2p/utils";
74
99
  import { webSockets } from "@libp2p/websockets";
75
100
  import { multiaddr } from "@multiformats/multiaddr";
101
+ import { PrivateKey } from "@bsv/sdk";
76
102
  import * as dagCbor from "@ipld/dag-cbor";
77
103
  import { createLibp2p } from "libp2p";
78
104
  import { CID } from "multiformats/cid";
@@ -80,8 +106,22 @@ import { sha256 } from "multiformats/hashes/sha2";
80
106
  import { MESSAGE_KEY_ID, MESSAGE_PROTOCOL as SIGN_PROTOCOL } from "./providers.js";
81
107
  import { verifyAnyone } from "../runtime/identity.js";
82
108
  import { Liveness } from "./liveness.js";
83
- /** The default listeners: loopback TCP and WebSocket, ports chosen by the OS (plain WS on loopback: dev only). */
84
- export const DEFAULT_LISTEN = ["/ip4/127.0.0.1/tcp/0", "/ip4/127.0.0.1/tcp/0/ws"];
109
+ /** SKEIN_NETWORK's names as the networks' own (the Teranode block topic's, the skein DHT's). */
110
+ export const NETWORK_NAMES = { main: "mainnet", test: "testnet", regtest: "regtest" };
111
+ /** The skein DHT's protocol for a network: the overlay's provider records, never merged with another network's. */
112
+ export const skeinDhtProtocol = (network = "main") => `/skein/kad/${NETWORK_NAMES[network] ?? network}/1.0.0`;
113
+ /**
114
+ * The default TCP port (2026-11-01): one fixed port on every install, so a user who opens one port knows
115
+ * which. 7534 is "SKEI" on a phone keypad; unassigned in the IANA registry (not IPFS's 4001, not
116
+ * Teranode's 9905/9915).
117
+ */
118
+ export const DEFAULT_PORT = 7534;
119
+ /** The default listeners: TCP on every interface, IPv4 and IPv6, on DEFAULT_PORT; WebSocket on loopback, its port the OS's (plain WS off loopback: dev only). */
120
+ export const DEFAULT_LISTEN = [`/ip4/0.0.0.0/tcp/${DEFAULT_PORT}`, `/ip6/::/tcp/${DEFAULT_PORT}`, "/ip4/127.0.0.1/tcp/0/ws"];
121
+ /** Loopback only, ports chosen by the OS: the router's listeners when it is given no host settings (tests, an embedded router). */
122
+ export const LOOPBACK_LISTEN = ["/ip4/127.0.0.1/tcp/0", "/ip4/127.0.0.1/tcp/0/ws"];
123
+ /** `listen` with DEFAULT_PORT made an OS-chosen port (the fallback when the default port is taken). */
124
+ export const anyPort = (listen) => listen.map((a) => a.replace(new RegExp(`/tcp/${DEFAULT_PORT}(?=/|$)`), "/tcp/0"));
85
125
  /** The stream protocol a signed message travels on to an instance (#70). */
86
126
  export const MESSAGE_PROTOCOL = "/skein/message/1.0.0";
87
127
  /**
@@ -116,29 +156,69 @@ export async function topicCid(topic) {
116
156
  }
117
157
  // ---------------------------------------------------------------- config
118
158
  const list = (s) => (s ?? "").split(/[,\s]+/).map((x) => x.trim()).filter(Boolean);
119
- /** The host-wide settings: the environment's SKEIN_LIBP2P_*, else $SKEIN_HOME/host.env's. */
159
+ /**
160
+ * The network's overlay bootstrap peers (SKEIN_NETWORK's names): the hosted Open Exchange node per network,
161
+ * dialled beside SKEIN_LIBP2P_BOOTSTRAP's (unioned, as TERANODE_BOOTSTRAP is with SKEIN_TERANODE_BOOTSTRAP).
162
+ * A node whose own peer id is one of them skips it (discover()). Mainnet and regtest have none yet.
163
+ */
164
+ export const OVERLAY_BOOTSTRAP = {
165
+ test: ["/dns4/p2p.testnet.openex.dev/tcp/7534/p2p/16Uiu2HAmVN7Cc7TrFGVrbdoV1mMGBF8LQBvPZ5trzgPArfagBRSB"],
166
+ };
167
+ /** SKEIN_LIBP2P_BOOTSTRAP over the network's defaults: unset, the network's; a list, unioned with them; `off`, none at all. */
168
+ export function overlayBootstrap(network, named) {
169
+ if ((named ?? "").trim().toLowerCase() === "off")
170
+ return [];
171
+ return [...new Set([...(OVERLAY_BOOTSTRAP[network] ?? []), ...list(named)])];
172
+ }
173
+ /**
174
+ * The bootstrap addresses a node dials now: not its own peer id (the hosted node is its network's default
175
+ * bootstrap), not a peer already connected; an address that does not parse is reported and skipped.
176
+ */
177
+ export function bootstrapDials(bootstrap, self, connected, bad = () => { }) {
178
+ const out = [];
179
+ for (const a of bootstrap) {
180
+ let ma;
181
+ try {
182
+ ma = multiaddr(a);
183
+ }
184
+ catch {
185
+ bad(a);
186
+ continue;
187
+ }
188
+ const id = ma.getComponents().find((c) => c.name === "p2p")?.value;
189
+ if (id === self || (id && connected.has(id)))
190
+ continue;
191
+ out.push(a);
192
+ }
193
+ return out;
194
+ }
195
+ /** The host-wide settings: the environment's SKEIN_LIBP2P_* (and SKEIN_NETWORK), else $SKEIN_HOME/host.env's. */
120
196
  export function hostP2PConfig(vars, home) {
121
197
  const file = {};
122
198
  const envFile = home ? join(home, "host.env") : undefined;
123
199
  if (envFile && existsSync(envFile)) {
124
200
  for (const line of readFileSync(envFile, "utf8").split("\n")) {
125
- const m = /^\s*(?:export\s+)?(SKEIN_LIBP2P_[A-Z_]+)=(.*)$/.exec(line);
201
+ const m = /^\s*(?:export\s+)?(SKEIN_LIBP2P_[A-Z_]+|SKEIN_NETWORK)=(.*)$/.exec(line);
126
202
  if (m)
127
203
  file[m[1]] = m[2].trim().replace(/^(["'])(.*)\1$/, "$2");
128
204
  }
129
205
  }
130
206
  const get = (k) => vars[k] ?? file[k];
131
- const dht = (get("SKEIN_LIBP2P_DHT") || "off").toLowerCase();
132
- if (dht !== "off" && dht !== "client" && dht !== "server")
133
- throw new Error(`SKEIN_LIBP2P_DHT: off | client | server, not ${dht}`);
207
+ const dht = (get("SKEIN_LIBP2P_DHT") || "auto").toLowerCase(); // 2026-11-01: auto by default; 2026-11-02: the skein DHT's mode only (Teranode's is always a client; off = neither)
208
+ if (dht !== "off" && dht !== "auto" && dht !== "client" && dht !== "server")
209
+ throw new Error(`SKEIN_LIBP2P_DHT: off | auto | client | server, not ${dht}`);
210
+ const upnp = (get("SKEIN_LIBP2P_UPNP") || "on").toLowerCase();
211
+ if (!/^(on|off|1|0|true|false|yes|no)$/.test(upnp))
212
+ throw new Error(`SKEIN_LIBP2P_UPNP: on | off, not ${upnp}`);
134
213
  const cert = get("SKEIN_LIBP2P_TLS_CERT"), key = get("SKEIN_LIBP2P_TLS_KEY");
135
214
  const listen = list(get("SKEIN_LIBP2P_LISTEN"));
136
215
  return {
137
216
  listen: listen.length ? listen : DEFAULT_LISTEN,
138
- bootstrap: list(get("SKEIN_LIBP2P_BOOTSTRAP")),
217
+ bootstrap: overlayBootstrap(get("SKEIN_NETWORK") || "main", get("SKEIN_LIBP2P_BOOTSTRAP")),
139
218
  dht,
140
219
  relays: list(get("SKEIN_LIBP2P_RELAYS")),
141
220
  mdns: /^(on|1|true|yes)$/i.test(get("SKEIN_LIBP2P_MDNS") ?? ""),
221
+ upnp: /^(on|1|true|yes)$/.test(upnp),
142
222
  ...(cert && key ? { tls: { cert: readFileSync(cert, "utf8"), key: readFileSync(key, "utf8") } } : {}),
143
223
  };
144
224
  }
@@ -358,8 +438,8 @@ const u64 = (n) => { const b = new Uint8Array(8); new DataView(b.buffer).setBigU
358
438
  export const TOPIC_REQUIRED = "topic required";
359
439
  export class P2PHost {
360
440
  o;
361
- /** The liveness tool's state (#138): the apps' liveness and the beats kept, in memory. */
362
- live = new Liveness();
441
+ /** The liveness tool's state (#138): the apps' liveness and the beats kept (persisted in `o.beats` when given). */
442
+ live;
363
443
  /** The nodes, by peer ID (#160: one per key). */
364
444
  nodes = new Map();
365
445
  /** Each handle's node. */
@@ -369,15 +449,85 @@ export class P2PHost {
369
449
  /** Each key's declares, in order (one at a time). */
370
450
  starting = new Map();
371
451
  stopped = false;
452
+ /** The host node's key (hostKey, else a fresh one) and its peer ID. */
453
+ hostKey;
454
+ hostId;
372
455
  constructor(o) {
373
456
  this.o = o;
457
+ this.live = new Liveness(Date.now, o.beats);
458
+ this.hostKey = o.hostKey ?? PrivateKey.fromRandom();
459
+ this.hostId = peerIdOf(this.hostKey).toString();
374
460
  if (o.beat)
375
461
  this.live.kept = (h, topic, b) => { if (b.from !== this.peerId(h))
376
462
  o.beat(h, topic, b); };
377
463
  }
378
464
  say(source, line) { this.o.log?.(source, line); }
379
- /** A node's name in a log line: its one handle (as before #160), else its first. */
380
- nameOf(n) { return n.handles.keys().next().value ?? n.id; }
465
+ /** A node's name in a log line: its one handle (as before #160), else its first; the host node with none: the router. */
466
+ nameOf(n) { return n.handles.keys().next().value ?? (n.host ? "router" : n.id); }
467
+ // ---------------------------------------------------------------- the host node (#156)
468
+ /**
469
+ * The host node: the node of the host key, started (once) with no handles when no instance has
470
+ * started it yet; an instance on that key that starts later joins it (#160), one that started
471
+ * earlier made it.
472
+ */
473
+ async hostNode() {
474
+ if (this.stopped)
475
+ throw new Error("libp2p: stopped");
476
+ const key = this.hostId;
477
+ const prev = this.starting.get(key) ?? Promise.resolve();
478
+ const next = prev.catch(() => { }).then(async () => {
479
+ if (this.stopped)
480
+ throw new Error("libp2p: stopped");
481
+ const n = this.nodes.get(key);
482
+ if (n) {
483
+ n.host = true;
484
+ return n;
485
+ }
486
+ const made = await this.start(key, this.hostKey, undefined, undefined);
487
+ if (!made)
488
+ throw new Error("libp2p: stopped");
489
+ return made;
490
+ });
491
+ this.starting.set(key, next);
492
+ void next.catch(() => { }).finally(() => { if (this.starting.get(key) === next)
493
+ this.starting.delete(key); });
494
+ return (await next).node;
495
+ }
496
+ /** The host node's record, once it runs. */
497
+ hostOf() { const n = this.nodes.get(this.hostId); return n?.host ? n : undefined; }
498
+ /**
499
+ * A host topic on the host node (#156: the Teranode block topic): subscribed, each message judged
500
+ * by `take` alone (its verdict GossipSub's), never the front door, never advertised on the skein
501
+ * DHT. Undefined `take`: the topic left.
502
+ */
503
+ async hostTopic(topic, take) {
504
+ await this.hostNode();
505
+ const n = this.hostOf();
506
+ if (take)
507
+ n.hostTopics.set(topic, take);
508
+ else
509
+ n.hostTopics.delete(topic);
510
+ this.resubscribe(n);
511
+ }
512
+ /** The peers the host node knows subscribe `topic` (GossipSub's view). */
513
+ hostSubscribers(topic) {
514
+ const n = this.hostOf();
515
+ return n ? this.pubsub(n).getSubscribers(topic).map((p) => p.toString()) : [];
516
+ }
517
+ /** The host node's skein DHT's mode now (client | server; undefined: off or no host node yet). */
518
+ hostDhtMode() {
519
+ return this.hostDht()?.getMode();
520
+ }
521
+ /** The host node's confirmed public addresses (AutoNAT's, or UPnP's once AutoNAT confirmed them): libp2p announces only confirmed ones; relay addresses aside. */
522
+ hostPublicAddrs() {
523
+ const n = this.hostOf();
524
+ return n ? n.node.getMultiaddrs().filter((m) => !isPrivateAddr(m) && !m.toString().includes("/p2p-circuit")).map(String) : [];
525
+ }
526
+ /** The host node's DHT service, the skein DHT (undefined: SKEIN_LIBP2P_DHT=off, or no host node yet). */
527
+ hostDht() {
528
+ const n = this.hostOf();
529
+ return n ? dhtOf(n.node) : undefined;
530
+ }
381
531
  /** The node of an instance, if it has one. */
382
532
  node(handle) { return this.byHandle.get(handle)?.node; }
383
533
  /** An instance's peer ID (derived: whether or not its node runs). */
@@ -412,7 +562,7 @@ export class P2PHost {
412
562
  const n = this.byHandle.get(handle) ?? this.nodes.get(key);
413
563
  if (n)
414
564
  return this.reconfigure(n, handle, config);
415
- await this.start(key, handle, config);
565
+ await this.start(key, this.o.keyOf(handle), handle, config);
416
566
  });
417
567
  this.starting.set(key, next);
418
568
  // Only clears the slot: the error reaches this call's caller through `await next` below.
@@ -461,9 +611,9 @@ export class P2PHost {
461
611
  }
462
612
  /** The topics `handle` takes: its config's and its apps' liveness topics (#138). */
463
613
  takes(handle, h) { return [...h.config.topics, ...this.live.topics(handle)]; }
464
- /** Subscribe what the handles' configs and liveness tools (#138) take, unsubscribe the rest: the topics added and removed. */
614
+ /** Subscribe what the handles' configs and liveness tools (#138) take, and the host's own topics (#156), unsubscribe the rest: the topics added and removed. */
465
615
  resubscribe(n) {
466
- const want = new Set([...n.handles].flatMap(([handle, h]) => this.takes(handle, h)));
616
+ const want = new Set([...[...n.handles].flatMap(([handle, h]) => this.takes(handle, h)), ...n.hostTopics.keys()]);
467
617
  const ps = this.pubsub(n);
468
618
  const removed = [...n.subscribed].filter((t) => !want.has(t));
469
619
  const added = [...want].filter((t) => !n.subscribed.has(t));
@@ -545,20 +695,26 @@ export class P2PHost {
545
695
  async handleProtocol(n, protocol) {
546
696
  await n.node.handle(protocol, (stream, connection) => { void this.serve(n, protocol, stream, connection.remotePeer); }, { runOnLimitedConnection: true });
547
697
  }
548
- async start(key, handle, config) {
698
+ /** A node for `key` (its private key `pk`): with `handle` and its config, or the host node with none (#156). */
699
+ async start(key, pk, handle, config) {
549
700
  const h = this.o.host;
550
- const n = { id: key, node: undefined, handles: new Map([[handle, { config, streams: new Map(), beats: new Map() }]]), seqnos: new Map(), subscribed: new Set(), handled: new Set() };
551
- const listen = [...(config.listen ?? h.listen), ...h.relays.map((r) => `${r.replace(/\/+$/, "")}/p2p-circuit`)];
701
+ const n = {
702
+ id: key, node: undefined, handles: new Map(handle && config ? [[handle, { config, streams: new Map(), beats: new Map() }]] : []),
703
+ seqnos: new Map(), subscribed: new Set(), handled: new Set(), hostTopics: new Map(), ...(key === this.hostId ? { host: true } : {}),
704
+ };
705
+ const listen = [...(config?.listen ?? h.listen), ...h.relays.map((r) => `${r.replace(/\/+$/, "")}/p2p-circuit`)];
552
706
  // An address that does not parse counts as private here; start() reports it when it is dialled or listened on.
553
- const privateDev = [...h.bootstrap, ...listen].every((a) => { try {
707
+ const allPrivate = (xs) => xs.every((a) => { try {
554
708
  return isPrivateAddr(multiaddr(a));
555
709
  }
556
710
  catch {
557
711
  return true;
558
712
  } });
713
+ const privateDev = allPrivate([...h.bootstrap, ...listen]);
559
714
  const services = {
560
715
  identify: identify(),
561
- ping: ping(),
716
+ // #156: the DHT pings a new peer (before it enters its table) beside the other pings; 4 outbound streams as before.
717
+ ping: ping({ maxOutboundStreams: 4 }),
562
718
  pubsub: gossipsub({
563
719
  globalSignaturePolicy: "StrictSign",
564
720
  allowPublishToZeroTopicPeers: true,
@@ -579,36 +735,73 @@ export class P2PHost {
579
735
  },
580
736
  }),
581
737
  };
582
- if (h.dht !== "off")
583
- services.dht = kadDHT({ clientMode: h.dht === "client", peerInfoMapper: privateDev ? passthroughMapper : removePrivateAddressesMapper });
584
- const node = await createLibp2p({
585
- privateKey: libp2pKey(this.o.keyOf(handle)),
586
- addresses: { listen },
738
+ // #156: one DHT, ours (the overlay topics'); 2026-11-02: no Teranode DHT (the public IPFS DHT: even a client fills the connection cap).
739
+ if (h.dht !== "off") {
740
+ services.skeinDht = kadDHT({
741
+ protocol: skeinDhtProtocol(h.network), ...dhtClientMode(h.dht), peerInfoMapper: privateDev ? passthroughMapper : removePrivateAddressesMapper,
742
+ logPrefix: "libp2p:dht-skein", datastorePrefix: "/dht-skein", metricsPrefix: "libp2p_dht_skein",
743
+ });
744
+ }
745
+ // 2026-11-01: AutoNAT confirms the node's public addresses (DHT auto flips to server on one; UPnP's mapped address needs it).
746
+ if (h.upnp || h.dht === "auto" || h.dht === "server")
747
+ services.autoNAT = autoNAT();
748
+ if (h.upnp)
749
+ services.upnpNAT = uPnPNAT();
750
+ const name = handle ?? "router";
751
+ const create = (addrs) => createLibp2p({
752
+ privateKey: libp2pKey(pk),
753
+ addresses: { listen: addrs },
587
754
  transports: [tcp(), webSockets(h.tls ? { https: { cert: h.tls.cert, key: h.tls.key } } : {}), ...(h.relays.length ? [circuitRelayTransport()] : [])],
588
755
  connectionEncrypters: [noise()],
589
756
  streamMuxers: [yamux()],
590
757
  peerDiscovery: h.mdns ? [mdns()] : [],
591
758
  services: services,
759
+ ...(h.upnp ? { logger: upnpTold((line) => this.say(name, line)) } : {}),
760
+ start: false,
592
761
  });
762
+ let node = await create(listen);
763
+ try {
764
+ await node.start();
765
+ }
766
+ catch (e) {
767
+ try {
768
+ await node.stop();
769
+ }
770
+ catch { /* the failed start's listeners closed as far as they go */ }
771
+ // 2026-11-01: the default port taken (another host, another node of this one): an OS-chosen port on the same interfaces.
772
+ // An address SKEIN_LIBP2P_LISTEN (or the instance) names is honoured as named: its failure stops the node, as before.
773
+ const defaulted = !config?.listen && h.listen.join(" ") === DEFAULT_LISTEN.join(" ");
774
+ if (!defaulted || !/EADDRINUSE/.test(e.message))
775
+ throw e;
776
+ const fallback = anyPort(listen);
777
+ this.say(name, `libp2p: port ${DEFAULT_PORT} is taken: listening on an OS-chosen port instead`);
778
+ node = await create(fallback);
779
+ await node.start();
780
+ }
593
781
  n.node = node;
594
782
  if (this.stopped) {
595
783
  await node.stop();
596
784
  return undefined;
597
785
  }
598
786
  this.nodes.set(key, n);
599
- this.byHandle.set(handle, n);
787
+ if (handle)
788
+ this.byHandle.set(handle, n);
600
789
  if (h.mdns)
601
790
  node.addEventListener("peer:discovery", (e) => { void node.dial(e.detail.id).catch((err) => this.say(this.nameOf(n), `libp2p: mDNS peer ${e.detail.id}: ${err.message}`)); });
602
791
  this.resubscribe(n);
603
792
  const host = this.o.hostProtocols ?? {};
604
- for (const protocol of config.protocols.filter((p) => inboundRow(p, host))) {
793
+ for (const protocol of (config?.protocols ?? []).filter((p) => inboundRow(p, host))) {
605
794
  n.handled.add(protocol);
606
795
  await this.handleProtocol(n, protocol);
607
796
  }
608
797
  await this.handleProtocol(n, MESSAGE_PROTOCOL);
609
798
  for (const [protocol, f] of Object.entries(host))
610
799
  await this.handleHostProtocol(n, protocol, f);
611
- this.say(handle, `libp2p: peer ${node.peerId} listening on ${node.getMultiaddrs().map(String).join(" ") || "(nothing)"}; topics ${config.topics.join(", ") || "none"}; protocols ${config.protocols.join(", ") || "none"}`);
800
+ const dhts = (h.dht === "off" ? "DHT off" : `DHT ${h.dht} (${skeinDhtProtocol(h.network)})`) + `; UPnP ${h.upnp ? "on" : "off"}; bootstrap ${h.bootstrap.join(", ") || "none"}`;
801
+ if (handle && config)
802
+ this.say(handle, `libp2p: peer ${node.peerId} listening on ${node.getMultiaddrs().map(String).join(" ") || "(nothing)"}; topics ${config.topics.join(", ") || "none"}; protocols ${config.protocols.join(", ") || "none"}; ${dhts}`);
803
+ else
804
+ this.say("router", `libp2p: the host node, peer ${node.peerId} listening on ${node.getMultiaddrs().map(String).join(" ") || "(nothing)"}; ${dhts}`);
612
805
  const discover = () => void this.discover(n).catch((e) => this.say(this.nameOf(n), `libp2p: discovery: ${e.message}`));
613
806
  setTimeout(discover, 200);
614
807
  n.timer = setInterval(discover, this.o.discoveryMs ?? 30_000);
@@ -620,28 +813,19 @@ export class P2PHost {
620
813
  const node = n.node;
621
814
  const name = this.nameOf(n);
622
815
  const connected = new Set(node.getConnections().map((c) => c.remotePeer.toString()));
623
- for (const a of this.o.host.bootstrap) {
624
- let ma;
625
- try {
626
- ma = multiaddr(a);
627
- }
628
- catch {
629
- this.say(name, `libp2p: bad bootstrap address ${a}`);
630
- continue;
631
- }
632
- const id = ma.getComponents().find((c) => c.name === "p2p")?.value;
633
- if (id === node.peerId.toString() || (id && connected.has(id)))
634
- continue;
635
- await node.dial(ma).catch((e) => this.say(name, `libp2p: bootstrap ${a}: ${e.message}`));
816
+ for (const a of bootstrapDials(this.o.host.bootstrap, node.peerId.toString(), connected, (b) => this.say(name, `libp2p: bad bootstrap address ${b}`))) {
817
+ await node.dial(multiaddr(a)).catch((e) => this.say(name, `libp2p: bootstrap ${a}: ${e.message}`));
636
818
  }
637
- if (this.o.host.dht === "off" || !node.services.dht)
819
+ // #156: the skein DHT (the node's one DHT); the host's own topics are the tracker's.
820
+ const dht = dhtOf(node);
821
+ if (!dht)
638
822
  return;
639
- for (const topic of [...n.subscribed]) {
823
+ for (const topic of [...n.subscribed].filter((t) => !n.hostTopics.has(t))) {
640
824
  const cid = await topicCid(topic);
641
- await node.contentRouting.provide(cid, { signal: AbortSignal.timeout(20_000) }).catch((e) => { if (!timedOut(e))
825
+ await drain(dht.provide(cid, { signal: AbortSignal.timeout(20_000) })).catch((e) => { if (!timedOut(e))
642
826
  this.say(name, `libp2p: provide ${topic}: ${e.message}`); });
643
827
  try {
644
- for await (const p of node.contentRouting.findProviders(cid, { signal: AbortSignal.timeout(20_000) })) {
828
+ for await (const p of providersOf(dht, cid, { signal: AbortSignal.timeout(20_000) })) {
645
829
  if (p.id.equals(node.peerId) || node.getConnections(p.id).length)
646
830
  continue;
647
831
  await node.dial(p.multiaddrs.length ? p.multiaddrs : p.id).catch((e) => this.say(name, `libp2p: ${topic} provider ${p.id}: ${e.message}`));
@@ -666,6 +850,19 @@ export class P2PHost {
666
850
  async validate(n, topic, _source, msg) {
667
851
  if (msg.type !== "signed")
668
852
  return TopicValidatorResult.Reject;
853
+ // #156: a host topic is judged by its handler alone (the tracker's), never the front door.
854
+ const own = n.hostTopics.get(topic);
855
+ if (own) {
856
+ let v;
857
+ try {
858
+ v = own(msg.data, msg.from);
859
+ }
860
+ catch (e) {
861
+ this.say(this.nameOf(n), `libp2p: ${topic}: ${e.message}`);
862
+ v = "ignore";
863
+ } // the host failing is not the message's fault
864
+ return v === "accept" ? TopicValidatorResult.Accept : v === "reject" ? TopicValidatorResult.Reject : TopicValidatorResult.Ignore;
865
+ }
669
866
  const takers = [...n.handles].filter(([handle, h]) => h.config.topics.includes(topic) || this.live.takes(handle, topic));
670
867
  if (!takers.length)
671
868
  return TopicValidatorResult.Ignore;
@@ -948,7 +1145,7 @@ export class P2PHost {
948
1145
  clearInterval(b.timer);
949
1146
  hs?.beats.clear();
950
1147
  this.live.drop(handle);
951
- if (n.handles.size) {
1148
+ if (n.handles.size || n.host) {
952
1149
  this.resubscribe(n);
953
1150
  await this.rehandle(n).catch((e) => this.say(handle, `libp2p: leaving its key's node: ${e.message}`));
954
1151
  this.say(handle, `libp2p: left the node of its key ${n.id} (${n.handles.size} instances on it)`);
@@ -972,7 +1169,7 @@ export class P2PHost {
972
1169
  for (const b of hs.beats.values())
973
1170
  clearInterval(b.timer);
974
1171
  this.byHandle.delete(handle);
975
- this.live.drop(handle);
1172
+ this.live.forget(handle); // the host stopping: its stored beats stay for the next start
976
1173
  }
977
1174
  n.handles.clear();
978
1175
  this.nodes.delete(n.id);
@@ -987,7 +1184,7 @@ export class P2PHost {
987
1184
  }
988
1185
  }
989
1186
  /** A multiaddr on a loopback or private network (dev: the DHT keeps such addresses). */
990
- function isPrivateAddr(ma) {
1187
+ export function isPrivateAddr(ma) {
991
1188
  const c = ma.getComponents().find((x) => x.name.startsWith("dns"));
992
1189
  if (c?.value)
993
1190
  return c.value === "localhost" || c.value.endsWith(".localhost");
@@ -1000,3 +1197,51 @@ function isPrivateAddr(ma) {
1000
1197
  }
1001
1198
  /** An AbortSignal.timeout's abort: the DHT's 20-second rounds end this way, and the next round tries again. */
1002
1199
  const timedOut = (e) => e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError");
1200
+ /**
1201
+ * kad-DHT's clientMode for a mode of the skein DHT (2026-11-01; @libp2p/kad-dht 16.4.5 kad-dht.js): `auto` leaves it
1202
+ * unset — the DHT's own switch (a client, a server while the node has a confirmed public address);
1203
+ * `client` true — only ever a client; `server` false — a server from its start (its start sets the mode),
1204
+ * never switched back. An explicit false is NOT auto in this version: the switch listens only when unset.
1205
+ */
1206
+ export function dhtClientMode(mode) {
1207
+ return mode === "auto" ? {} : { clientMode: mode === "client" };
1208
+ }
1209
+ /**
1210
+ * The libp2p logger, with the UPnP service's outcome told once per distinct line (2026-11-01): the gateway
1211
+ * search's result, the public IP found, each mapping made or refused, an error. The service reports its
1212
+ * outcome nowhere else (its log only); every other component's log is the default, untouched.
1213
+ */
1214
+ export function upnpTold(tell) {
1215
+ const base = defaultLogger();
1216
+ const said = new Set();
1217
+ const fmt = (f, args) => { let i = 0; return String(f).replace(/%[sdioOjaepcbt]/g, () => { const a = args[i++]; return a instanceof Error ? a.message : String(a); }); };
1218
+ const once = (line) => { if (!said.has(line)) {
1219
+ said.add(line);
1220
+ tell(line);
1221
+ } };
1222
+ const outcome = /^(gateway search finished|discovered public IP|created mapping)/;
1223
+ const wrap = (log) => Object.assign((f, ...args) => { log(f, ...args); if (outcome.test(String(f)))
1224
+ once(`libp2p: UPnP ${fmt(f, args)}`); }, {
1225
+ error: (f, ...args) => { log.error(f, ...args); once(`libp2p: UPnP ${fmt(f, args)}`); },
1226
+ trace: log.trace.bind(log),
1227
+ enabled: log.enabled,
1228
+ newScope: (s) => wrap(log.newScope(s)),
1229
+ });
1230
+ return { forComponent: (c) => c.startsWith("libp2p:upnp-nat") ? wrap(base.forComponent(c)) : base.forComponent(c) };
1231
+ }
1232
+ /** A node's DHT service, the skein DHT (#156), or undefined (SKEIN_LIBP2P_DHT=off). */
1233
+ export function dhtOf(node) {
1234
+ return node.services.skeinDht;
1235
+ }
1236
+ /** The providers a DHT finds for `cid` (its PROVIDER events' peers), on that DHT alone. */
1237
+ export async function* providersOf(dht, cid, options = {}) {
1238
+ for await (const e of dht.findProviders(cid, options))
1239
+ if (e.name === "PROVIDER")
1240
+ yield* e.providers;
1241
+ }
1242
+ /** Run a DHT operation's events to the end. */
1243
+ export async function drain(events) { for await (const _ of events) { /* the events: not needed */ } }
1244
+ /** A DHT's routing table size (undefined: no such DHT). */
1245
+ export function tableSize(dht) {
1246
+ return dht?.routingTable?.size;
1247
+ }