@steve02081504/fount-p2p 0.0.13 → 0.0.15

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.
@@ -0,0 +1,23 @@
1
+ import { normalizeHex64 } from '../core/hexIds.mjs'
2
+
3
+ /** @type {((nodeHash: string) => void) | null} */
4
+ let peerClueListener = null
5
+
6
+ /**
7
+ * 由 link registry 注入:peer 首次可见时清 dial 冷却。
8
+ * @param {((nodeHash: string) => void) | null} listener 回调
9
+ * @returns {void}
10
+ */
11
+ export function setDiscoveryPeerClueListener(listener) {
12
+ peerClueListener = listener
13
+ }
14
+
15
+ /**
16
+ * discovery accept 路径:peer 首次进入可见池时通知 registry。
17
+ * @param {string} nodeHash 对端
18
+ * @returns {void}
19
+ */
20
+ export function noteDiscoveryPeerClue(nodeHash) {
21
+ const hash = normalizeHex64(nodeHash)
22
+ if (hash) peerClueListener?.(hash)
23
+ }
@@ -4,6 +4,7 @@ import { randomBytes } from 'node:crypto'
4
4
  import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
5
5
  import { normalizeTcpPort } from '../core/tcp_port.mjs'
6
6
  import { keyPairFromSeed, pubKeyHash, sign, verify } from '../crypto/crypto.mjs'
7
+ import { normalizeLanHosts } from '../discovery/lan_interfaces.mjs'
7
8
  import { ensureNodeSeed, getNodeHash } from '../node/identity.mjs'
8
9
 
9
10
  import { normalizeDtlsFingerprint } from './sdp_fingerprint.mjs'
@@ -139,20 +140,24 @@ export async function verifyAuth(hello, auth, expectedNonce, remoteBinding) {
139
140
  * @param {number} ts 时间戳(毫秒)
140
141
  * @param {string} nodeHash 节点 nodeHash
141
142
  * @param {number | null} [tcpPort=null] 可选 LAN TCP 监听端口(签入消息)
143
+ * @param {unknown} [lanHosts=null] 可选 LAN IPv4 列表(签入消息)
142
144
  * @returns {Uint8Array} 待签名消息字节
143
145
  */
144
- export function buildAdvertMessage(rendezvousKey, ts, nodeHash, tcpPort = null) {
146
+ export function buildAdvertMessage(rendezvousKey, ts, nodeHash, tcpPort = null, lanHosts = null) {
145
147
  const base = `fount-advert\0${String(rendezvousKey)}\0${String(ts)}\0${normalizeHex64(nodeHash)}`
146
148
  const port = normalizeTcpPort(tcpPort)
147
- return Buffer.from(port ? `${base}\0${port}` : base, 'utf8')
149
+ let message = port ? `${base}\0${port}` : base
150
+ const hosts = normalizeLanHosts(lanHosts)
151
+ if (hosts.length) message += `\0${hosts.join(',')}`
152
+ return Buffer.from(message, 'utf8')
148
153
  }
149
154
 
150
155
  /**
151
- * 构造带签名的 discovery advert
156
+ * 构造带签名的 discovery advert(tcpPort / lanHosts 一并签入消息)。
152
157
  * @param {string} rendezvousKey discovery 内部汇合键
153
158
  * @param {number} [ts=Date.now()] 时间戳(毫秒)
154
- * @param {{ secretKey?: Uint8Array, nodeHash?: string, nodePubKey?: string, tcpPort?: number } | null} [options] 签名身份与可选 tcpPort
155
- * @returns {Promise<{ nodeHash: string, nodePubKey: string, ts: number, sig: string, tcpPort?: number }>} 签名 advert
159
+ * @param {{ secretKey?: Uint8Array, nodeHash?: string, nodePubKey?: string, tcpPort?: number, lanHosts?: unknown } | null} [options] 签名身份;可选 LAN 监听端口与本机 IPv4 列表
160
+ * @returns {Promise<{ nodeHash: string, nodePubKey: string, ts: number, sig: string, tcpPort?: number, lanHosts?: string[] }>} 签名 advert
156
161
  */
157
162
  export async function buildSignedAdvert(rendezvousKey, ts = Date.now(), options = null) {
158
163
  const seed = options?.secretKey
@@ -166,7 +171,8 @@ export async function buildSignedAdvert(rendezvousKey, ts = Date.now(), options
166
171
  const tcpPort = normalizeTcpPort(options?.tcpPort)
167
172
  if (options?.tcpPort && !tcpPort)
168
173
  throw new Error('p2p: advert tcpPort invalid')
169
- const message = buildAdvertMessage(rendezvousKey, ts, nodeHash, tcpPort)
174
+ const lanHosts = normalizeLanHosts(options?.lanHosts)
175
+ const message = buildAdvertMessage(rendezvousKey, ts, nodeHash, tcpPort, lanHosts)
170
176
  const sig = await sign(message, secretKey)
171
177
  const advert = {
172
178
  nodeHash,
@@ -175,6 +181,7 @@ export async function buildSignedAdvert(rendezvousKey, ts = Date.now(), options
175
181
  sig: Buffer.from(sig).toString('hex'),
176
182
  }
177
183
  if (tcpPort) advert.tcpPort = tcpPort
184
+ if (lanHosts.length) advert.lanHosts = lanHosts
178
185
  return advert
179
186
  }
180
187
 
@@ -195,7 +202,8 @@ export async function verifySignedAdvert(rendezvousKey, advert, now = Date.now()
195
202
  const hasTcpPortField = !!advert?.tcpPort
196
203
  const tcpPort = normalizeTcpPort(advert?.tcpPort)
197
204
  if (hasTcpPortField && !tcpPort) return null
198
- const message = buildAdvertMessage(rendezvousKey, ts, parsedHello.nodeHash, tcpPort)
205
+ const lanHosts = normalizeLanHosts(advert?.lanHosts)
206
+ const message = buildAdvertMessage(rendezvousKey, ts, parsedHello.nodeHash, tcpPort, lanHosts)
199
207
  const ok = await verify(Buffer.from(sig, 'hex'), message, Buffer.from(parsedHello.nodePubKey, 'hex'))
200
208
  return ok ? parsedHello.nodeHash : null
201
209
  }
@@ -3,13 +3,50 @@ import { randomBytes } from 'node:crypto'
3
3
  import net from 'node:net'
4
4
 
5
5
  import { normalizeHex64 } from '../../core/hexIds.mjs'
6
- import { getLanPeerHint } from '../../discovery/lan_peer_hints.mjs'
6
+ import { getLanPeerHint, listLanPeerHints } from '../../discovery/lan_peer_hints.mjs'
7
7
  import { asLinkHandle } from '../pipe.mjs'
8
8
 
9
9
  import { LINK_LEVEL_LAN_TCP } from './levels.mjs'
10
10
  import { buildLinkOpen, createLinkIdBoundPipe, parseLinkOpen } from './link_id_pipe.mjs'
11
11
 
12
12
  const MAX_FRAME_BYTES = 1 << 20
13
+ /** LAN TCP 单 endpoint 连接超时。 */
14
+ const LAN_TCP_CONNECT_TIMEOUT_MS = 3_000
15
+
16
+ /**
17
+ * @param {string} host 目标 host
18
+ * @param {number} port 目标 port
19
+ * @param {number} [timeoutMs=LAN_TCP_CONNECT_TIMEOUT_MS] 超时
20
+ * @returns {Promise<import('node:net').Socket>} 已连接 socket
21
+ */
22
+ function connectTcp(host, port, timeoutMs = LAN_TCP_CONNECT_TIMEOUT_MS) {
23
+ return new Promise((resolve, reject) => {
24
+ const conn = net.createConnection({ host, port })
25
+ const timer = setTimeout(() => {
26
+ conn.destroy()
27
+ reject(new Error(`p2p: lan_tcp connect timeout ${host}:${port}`))
28
+ }, timeoutMs)
29
+ /**
30
+ * @param {Error} error 连接错误
31
+ * @returns {void}
32
+ */
33
+ const onError = error => {
34
+ clearTimeout(timer)
35
+ conn.off('connect', onConnect)
36
+ reject(error)
37
+ }
38
+ /**
39
+ * @returns {void}
40
+ */
41
+ function onConnect() {
42
+ clearTimeout(timer)
43
+ conn.off('error', onError)
44
+ resolve(conn)
45
+ }
46
+ conn.once('error', onError)
47
+ conn.once('connect', onConnect)
48
+ })
49
+ }
13
50
 
14
51
  /**
15
52
  * 在 socket 上挂 length-prefix 编解码(u32be + payload)。
@@ -168,41 +205,33 @@ async function openTcpPipe(options) {
168
205
  */
169
206
  async function dialLanTcp(options) {
170
207
  const remoteNodeHash = normalizeHex64(options.nodeHash)
171
- const hint = getLanPeerHint(remoteNodeHash)
172
- if (!hint) throw new Error('p2p: lan_tcp no peer hint')
208
+ const hints = listLanPeerHints(remoteNodeHash)
209
+ if (!hints.length) throw new Error('p2p: lan_tcp no peer hint')
173
210
 
174
- const socket = await new Promise((resolve, reject) => {
175
- const conn = net.createConnection({ host: hint.host, port: hint.port })
176
- /**
177
- * @param {Error} error 连接错误
178
- * @returns {void}
179
- */
180
- const onError = error => {
181
- conn.off('connect', onConnect)
182
- reject(error)
211
+ let lastError = null
212
+ for (const hint of hints) {
213
+ /** @type {import('node:net').Socket | null} */
214
+ let socket = null
215
+ try {
216
+ socket = await connectTcp(hint.host, hint.port)
217
+ const link = await openTcpPipe({
218
+ initiator: true,
219
+ linkId: randomBytes(32).toString('hex'),
220
+ nodeHash: remoteNodeHash,
221
+ localIdentity: options.localIdentity,
222
+ socket,
223
+ host: hint.host,
224
+ port: hint.port,
225
+ })
226
+ await link.ready
227
+ return link
183
228
  }
184
- /**
185
- * @returns {void}
186
- */
187
- function onConnect() {
188
- conn.off('error', onError)
189
- resolve(conn)
229
+ catch (error) {
230
+ lastError = error
231
+ try { socket?.destroy() } catch { /* ignore */ }
190
232
  }
191
- conn.once('error', onError)
192
- conn.once('connect', onConnect)
193
- })
194
-
195
- const link = await openTcpPipe({
196
- initiator: true,
197
- linkId: randomBytes(32).toString('hex'),
198
- nodeHash: remoteNodeHash,
199
- localIdentity: options.localIdentity,
200
- socket,
201
- host: hint.host,
202
- port: hint.port,
203
- })
204
- await link.ready
205
- return link
233
+ }
234
+ throw lastError || new Error('p2p: lan_tcp dial failed')
206
235
  }
207
236
 
208
237
  /**
@@ -1,4 +1,5 @@
1
1
  import { getSignalingRuntimeConfig } from '../../node/instance.mjs'
2
+ import { nodeDebug } from '../../node/log.mjs'
2
3
  import { ms } from '../../utils/duration.mjs'
3
4
  import { createLruMap } from '../../utils/lru.mjs'
4
5
  import {
@@ -33,6 +34,13 @@ function formatErrorReason(error) {
33
34
 
34
35
  let cachedAvailable = null
35
36
 
37
+ /**
38
+ * @returns {boolean} 是否跑在 Deno
39
+ */
40
+ function isDenoRuntime() {
41
+ return typeof globalThis.Deno !== 'undefined'
42
+ }
43
+
36
44
  /**
37
45
  * 探测 WebRTC(node-datachannel)是否可用。
38
46
  * @returns {Promise<boolean>} 可用为 true
@@ -43,8 +51,14 @@ export async function canUseWebRtcLink() {
43
51
  await loadNodeRtcPolyfill()
44
52
  cachedAvailable = true
45
53
  }
46
- catch {
54
+ catch (error) {
47
55
  cachedAvailable = false
56
+ const reason = formatErrorReason(error)
57
+ nodeDebug('p2p:webrtc unavailable', {
58
+ err: isDenoRuntime()
59
+ ? `${reason} | deno: see docs/runtime.md (node-datachannel scripts only)`
60
+ : reason,
61
+ })
48
62
  }
49
63
  return cachedAvailable
50
64
  }
package/link/rtc.mjs CHANGED
@@ -4,15 +4,23 @@ import { toBytes } from '../core/bytes_codec.mjs'
4
4
  import { getSignalingRuntimeConfig } from '../node/instance.mjs'
5
5
  import { wrapRtcPeerConnectionForIceLocalHostname } from '../transport/rtc_ice_local_hostname.mjs'
6
6
 
7
+ /** @type {boolean} */
8
+ let exitCleanupHooked = false
9
+
7
10
  /**
8
- * 注册进程退出时销毁 libdatachannel 全部原生资源(仅一次)。
11
+ * 注册进程退出时销毁 libdatachannel 原生资源(首次成功加载后挂一次)。
9
12
  * libdatachannel 的原生线程在 pc.close() 后仍需时间回收;进程退出时若原生资源未同步销毁,
10
13
  * Windows 上会触发堆损坏(退出码 0xC0000374)。
14
+ * @returns {Promise<void>}
11
15
  */
12
- const { cleanup = undefined } = await import('node-datachannel').catch(() => ({}))
13
- process.on('exit', () => {
14
- try { cleanup?.() } catch { /* already torn down */ }
15
- })
16
+ async function ensureNodeDatachannelExitCleanup() {
17
+ if (exitCleanupHooked) return
18
+ exitCleanupHooked = true
19
+ const { cleanup = undefined } = await import('node-datachannel').catch(() => ({}))
20
+ process.on('exit', () => {
21
+ try { cleanup?.() } catch { /* already torn down */ }
22
+ })
23
+ }
16
24
 
17
25
  /**
18
26
  * 加载 node-datachannel polyfill,并按配置包装 RTCPeerConnection。
@@ -20,6 +28,7 @@ process.on('exit', () => {
20
28
  */
21
29
  export async function loadNodeRtcPolyfill() {
22
30
  const mod = await import('node-datachannel/polyfill')
31
+ await ensureNodeDatachannelExitCleanup()
23
32
  const { iceLocalHostnamePolicy } = getSignalingRuntimeConfig()
24
33
  return {
25
34
  RTCPeerConnection: wrapRtcPeerConnectionForIceLocalHostname(mod.RTCPeerConnection, mod.RTCIceCandidate, iceLocalHostnamePolicy),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve02081504/fount-p2p",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
4
4
  "description": "fount federation P2P layer — link, trust graph, mailbox, DAG, EVFS.",
5
5
  "keywords": [
6
6
  "network",
@@ -3,11 +3,13 @@ import { Buffer } from 'node:buffer'
3
3
  import { compareHex64Asc, normalizeHex64 } from '../core/hexIds.mjs'
4
4
  import { keyPairFromSeed } from '../crypto/crypto.mjs'
5
5
  import { watchVerifiedNodeAdvert, setDiscoveryLinkDialer, prepareConnectToNode } from '../discovery/index.mjs'
6
+ import { setDiscoveryPeerClueListener } from '../discovery/peer_clue.mjs'
6
7
  import { listLinkProviders } from '../link/providers/index.mjs'
7
8
  import { ensureNodeSeed, getNodeHash } from '../node/identity.mjs'
8
9
  import { nodeDebug, shortHash } from '../node/log.mjs'
9
10
  import { loadPeerPoolView } from '../node/network.mjs'
10
11
  import { createOverlayRouter } from '../overlay/index.mjs'
12
+ import { ms } from '../utils/duration.mjs'
11
13
  import { emitSafe } from '../utils/emit_safe.mjs'
12
14
  import { createLruMap } from '../utils/lru.mjs'
13
15
 
@@ -18,6 +20,11 @@ import { createOfferAnswerDial } from './offer_answer.mjs'
18
20
  import { pickMeshEvictionVictim } from './peer_pool.mjs'
19
21
  import { createRuntimeBootstrap } from './runtime_bootstrap.mjs'
20
22
 
23
+ /** dial exhausted 后的初始冷却。 */
24
+ const DIAL_COOLDOWN_BASE_MS = ms('30s')
25
+ /** dial 冷却上限。 */
26
+ const DIAL_COOLDOWN_MAX_MS = ms('10m')
27
+
21
28
  /**
22
29
  * 解析或从节点种子推导本地身份。
23
30
  * @param {{ nodeHash?: string, nodePubKey?: string, secretKey?: Uint8Array } | undefined} localIdentity 可选的预置身份
@@ -84,6 +91,8 @@ export function createLinkRegistry(options = {}) {
84
91
  const links = new Map()
85
92
  /** @type {Map<string, Promise<object | null>>} */
86
93
  const inflights = new Map()
94
+ /** @type {Map<string, { until: number, failures: number }> & { touch: (key: string, value: { until: number, failures: number }) => void }} */
95
+ const dialCooldown = createLruMap(1024)
87
96
  /** @type {Map<string, ReturnType<typeof import('./offer_answer.mjs').createBufferedSignalSession>>} */
88
97
  const signalSessions = new Map()
89
98
  /** @type {Map<string, Set<string>>} */
@@ -265,6 +274,19 @@ export function createLinkRegistry(options = {}) {
265
274
  if (!normalized || normalized === localIdentity.nodeHash) return null
266
275
  if (links.has(normalized)) return links.get(normalized)
267
276
  if (inflights.has(normalized)) return await inflights.get(normalized)
277
+ const cool = dialCooldown.get(normalized)
278
+ const now = Date.now()
279
+ if (cool) {
280
+ if (now < cool.until) {
281
+ nodeDebug('p2p:dial cooldown', {
282
+ peer: shortHash(normalized),
283
+ failures: cool.failures,
284
+ retryInMs: cool.until - now,
285
+ })
286
+ return null
287
+ }
288
+ dialCooldown.delete(normalized)
289
+ }
268
290
  const task = (async () => {
269
291
  nodeDebug('p2p:dial start', { peer: shortHash(normalized) })
270
292
  await prepareConnectToNode(normalized)
@@ -289,6 +311,7 @@ export function createLinkRegistry(options = {}) {
289
311
  if (provider.caps?.needsOfferAnswer) {
290
312
  const link = await dialOfferAnswer(provider, normalized)
291
313
  if (link) {
314
+ dialCooldown.delete(normalized)
292
315
  nodeDebug('p2p:dial ok', { peer: shortHash(normalized), provider: provider.id })
293
316
  return link
294
317
  }
@@ -297,6 +320,7 @@ export function createLinkRegistry(options = {}) {
297
320
  }
298
321
  const link = await dialProvider(provider, normalized)
299
322
  if (link) {
323
+ dialCooldown.delete(normalized)
300
324
  nodeDebug('p2p:dial ok', { peer: shortHash(normalized), provider: provider.id })
301
325
  return link
302
326
  }
@@ -310,7 +334,10 @@ export function createLinkRegistry(options = {}) {
310
334
  })
311
335
  }
312
336
 
313
- nodeDebug('p2p:dial exhausted', { peer: shortHash(normalized) })
337
+ const failures = (cool?.failures || 0) + 1
338
+ const delay = Math.min(DIAL_COOLDOWN_MAX_MS, DIAL_COOLDOWN_BASE_MS * (2 ** Math.min(failures - 1, 5)))
339
+ dialCooldown.touch(normalized, { until: Date.now() + delay, failures })
340
+ nodeDebug('p2p:dial exhausted', { peer: shortHash(normalized), cooldownMs: delay, failures })
314
341
  return null
315
342
  })().finally(() => inflights.delete(normalized))
316
343
  inflights.set(normalized, task)
@@ -351,6 +378,12 @@ export function createLinkRegistry(options = {}) {
351
378
  })
352
379
  exploreLinkHashes = meshKeepalive.exploreLinkHashes
353
380
  setDiscoveryLinkDialer(ensureDirectLinkToNode)
381
+ setDiscoveryPeerClueListener(nodeHash => {
382
+ const hash = normalizeHex64(nodeHash)
383
+ if (!hash) return
384
+ dialCooldown.delete(hash)
385
+ recentAdverts.touch(hash, Date.now())
386
+ })
354
387
 
355
388
  /**
356
389
  *
@@ -619,6 +652,7 @@ export function createLinkRegistry(options = {}) {
619
652
  return await watchVerifiedNodeAdvert(hash, async (verifiedNodeHash, body, meta) => {
620
653
  applyAdvertPeerHints(verifiedNodeHash, body, meta)
621
654
  recentAdverts.touch(verifiedNodeHash, Date.now())
655
+ dialCooldown.delete(verifiedNodeHash)
622
656
  await Promise.resolve(onAdvert(verifiedNodeHash, body))
623
657
  })
624
658
  },
@@ -631,6 +665,7 @@ export function createLinkRegistry(options = {}) {
631
665
  async shutdown() {
632
666
  await meshKeepalive?.stop()
633
667
  setDiscoveryLinkDialer(null)
668
+ setDiscoveryPeerClueListener(null)
634
669
  await bootstrap.shutdown()
635
670
  overlayRouter?.close()
636
671
  overlayRouter = null
@@ -638,6 +673,7 @@ export function createLinkRegistry(options = {}) {
638
673
  await link.close('registry-shutdown')
639
674
  links.clear()
640
675
  inflights.clear()
676
+ dialCooldown.clear()
641
677
  for (const session of signalSessions.values()) session.clear()
642
678
  signalSessions.clear()
643
679
  },
@@ -116,7 +116,9 @@ export function createRuntimeBootstrap(deps) {
116
116
  function registerDiscoveryDefaults() {
117
117
  const providerIds = new Set(listDiscoveryProviders().map(provider => provider.id))
118
118
  if (!providerIds.has('lan'))
119
- registerDiscoveryProvider(createLanDiscoveryProvider())
119
+ registerDiscoveryProvider(createLanDiscoveryProvider({
120
+ localNodeHash: localIdentity.nodeHash,
121
+ }))
120
122
  if (!providerIds.has('nostr'))
121
123
  registerNostrProvider()
122
124
  }
@@ -128,6 +130,7 @@ export function createRuntimeBootstrap(deps) {
128
130
  unregisterDiscoveryProvider('nostr')
129
131
  registerDiscoveryProvider(createNostrDiscoveryProvider({
130
132
  getRelayUrls: resolveNostrRelayUrls,
133
+ localNodeHash: localIdentity.nodeHash,
131
134
  }))
132
135
  }
133
136