@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.
package/README.md CHANGED
@@ -44,6 +44,14 @@ npx @steve02081504/fount-p2p
44
44
 
45
45
  Default `nodeDir`: Windows `%LOCALAPPDATA%/fount-p2p/node`; elsewhere `~/.local/share/fount-p2p/node`.
46
46
 
47
+ Deno (optional; WebRTC needs explicit native scripts — **not** blanket `--allow-scripts`):
48
+
49
+ ```bash
50
+ deno run -A --minimum-dependency-age=0 --node-modules-dir=auto --allow-scripts=npm:node-datachannel npm:@steve02081504/fount-p2p
51
+ ```
52
+
53
+ See [docs/runtime.md](./docs/runtime.md).
54
+
47
55
  ```javascript
48
56
  import { initNode, startNode, startInfra, stopInfra, setInfraPriority } from '@steve02081504/fount-p2p'
49
57
 
package/deno.json CHANGED
@@ -1,4 +1,7 @@
1
1
  {
2
2
  "nodeModulesDir": "none",
3
- "lock": false
3
+ "lock": false,
4
+ "allowScripts": [
5
+ "npm:node-datachannel"
6
+ ]
4
7
  }
@@ -1,13 +1,15 @@
1
1
  import { normalizeTcpPort } from '../core/tcp_port.mjs'
2
2
 
3
3
  import { noteBtPeerHint } from './bt/peer_hints.mjs'
4
+ import { normalizeLanHosts } from './lan_interfaces.mjs'
4
5
  import { noteLanPeerHint } from './lan_peer_hints.mjs'
5
6
 
6
7
  /**
7
8
  * 从已验证的 discovery advert + provider meta 写入 LAN/BT peer hints。
8
9
  * 任意 discovery 路径(node / group / scoped)收到 advert 时都应调用。
10
+ * meta.address(观测地址)优先于 body.lanHosts(自报地址)。
9
11
  * @param {string} verifiedNodeHash 验签通过的 nodeHash
10
- * @param {{ tcpPort?: unknown } | null | undefined} body advert body
12
+ * @param {{ tcpPort?: unknown, lanHosts?: unknown } | null | undefined} body advert body
11
13
  * @param {{ address?: unknown, peripheralId?: unknown } | null | undefined} meta discovery provider meta
12
14
  * @returns {void}
13
15
  */
@@ -15,7 +17,11 @@ export function noteAdvertPeerHints(verifiedNodeHash, body, meta) {
15
17
  if (meta?.peripheralId)
16
18
  noteBtPeerHint(verifiedNodeHash, meta.peripheralId)
17
19
  const tcpPort = normalizeTcpPort(body?.tcpPort)
20
+ if (tcpPort == null) return
21
+ // lanHosts 先写(差→优,unshift 后优在前),再写观测 address,使 address 最优先
22
+ for (const host of normalizeLanHosts(body?.lanHosts).toReversed())
23
+ noteLanPeerHint(verifiedNodeHash, { host, port: tcpPort })
18
24
  const address = String(meta?.address || '').trim()
19
- if (tcpPort != null && address)
25
+ if (address)
20
26
  noteLanPeerHint(verifiedNodeHash, { host: address, port: tcpPort })
21
27
  }
@@ -8,6 +8,7 @@ import {
8
8
  networkRendezvousKey,
9
9
  nodeRendezvousKey,
10
10
  } from './internal/signal_crypto.mjs'
11
+ import { listMulticastIpv4Addresses } from './lan_interfaces.mjs'
11
12
 
12
13
  /** @typedef {'node' | 'network' | { roomSecret: string }} AdvertScope */
13
14
 
@@ -33,9 +34,13 @@ export function rendezvousKeyForScope(scope, selfNodeHash) {
33
34
  */
34
35
  export async function buildSignedAdvertForScope(scope, localIdentity, tcpPort) {
35
36
  const key = rendezvousKeyForScope(scope, localIdentity.nodeHash)
37
+ const lanHosts = scope === 'network' && tcpPort != null
38
+ ? listMulticastIpv4Addresses()
39
+ : []
36
40
  return await buildSignedAdvert(key, Date.now(), {
37
41
  ...localIdentity,
38
42
  ...tcpPort != null ? { tcpPort } : {},
43
+ ...lanHosts.length ? { lanHosts } : {},
39
44
  })
40
45
  }
41
46
 
@@ -4,6 +4,7 @@ import { isHex64, normalizeHex64 } from '../../core/hexIds.mjs'
4
4
  import { nodeDebug, shortHash } from '../../node/log.mjs'
5
5
  import { noteAdvertPeerHints } from '../advert_peer_hints.mjs'
6
6
  import { ingestNetworkAdvert } from '../adverts.mjs'
7
+ import { noteDiscoveryPeerClue } from '../peer_clue.mjs'
7
8
 
8
9
  import { getBtPeerHint } from './peer_hints.mjs'
9
10
  import { canUseBluetoothRuntime, loadBleno, loadNoble, resolveBtRole, waitPoweredOn } from './runtime.mjs'
@@ -106,11 +107,13 @@ export async function acceptBtScannedPresence(bytes, meta) {
106
107
  const firstSeen = !visibleByHash.has(ingested.verifiedNodeHash)
107
108
  noteBtVisibleNode(ingested.verifiedNodeHash)
108
109
  noteAdvertPeerHints(ingested.verifiedNodeHash, ingested.body, meta)
109
- if (firstSeen)
110
+ if (firstSeen) {
111
+ noteDiscoveryPeerClue(ingested.verifiedNodeHash)
110
112
  nodeDebug('p2p:bt peer visible', {
111
113
  peer: shortHash(ingested.verifiedNodeHash),
112
114
  peripheralId,
113
115
  })
116
+ }
114
117
  return ingested
115
118
  }
116
119
 
package/discovery/lan.mjs CHANGED
@@ -5,8 +5,11 @@ import { base64ToBytes, bytesToBase64 } from '../core/bytes_codec.mjs'
5
5
  import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
6
6
  import { nodeDebug, shortHash } from '../node/log.mjs'
7
7
 
8
+ import { noteAdvertPeerHints } from './advert_peer_hints.mjs'
8
9
  import { ingestNetworkAdvert } from './adverts.mjs'
9
- import { getLanPeerHint, noteLanPeerHint } from './lan_peer_hints.mjs'
10
+ import { listMulticastIpv4Addresses } from './lan_interfaces.mjs'
11
+ import { getLanPeerHint } from './lan_peer_hints.mjs'
12
+ import { noteDiscoveryPeerClue } from './peer_clue.mjs'
10
13
 
11
14
  const DEFAULT_PORT = 53531
12
15
  const DEFAULT_GROUP = '239.255.42.99'
@@ -51,31 +54,33 @@ export function clearLanVisibleNodes() {
51
54
  /**
52
55
  * Untrusted ingress:验签 network advert 后写入可见池 / peer hint。
53
56
  * @param {Uint8Array} advertBytes 加密 network advert
54
- * @param {{ address?: string }} [meta] 发送方地址
57
+ * @param {{ address?: string, skipNodeHash?: string }} [meta] 发送方地址 / 本机 nodeHash(过滤自回环)
55
58
  * @returns {Promise<{ verifiedNodeHash: string, body: object } | null>} 验签结果
56
59
  */
57
60
  export async function acceptLanPresenceAdvert(advertBytes, meta = {}) {
58
61
  if (!advertBytes?.byteLength) return null
59
62
  const ingested = await ingestNetworkAdvert(advertBytes, meta)
60
63
  if (!ingested) return null
64
+ const skipHash = meta.skipNodeHash ? normalizeHex64(meta.skipNodeHash) : null
65
+ if (skipHash && ingested.verifiedNodeHash === skipHash) return ingested
61
66
  const firstSeen = !visibleByHash.has(ingested.verifiedNodeHash)
62
67
  noteLanVisibleNode(ingested.verifiedNodeHash)
63
- const host = String(meta.address || '').trim()
64
- const tcpPort = Number(ingested.body?.tcpPort)
65
- if (host && Number.isFinite(tcpPort) && tcpPort > 0)
66
- noteLanPeerHint(ingested.verifiedNodeHash, { host, port: tcpPort })
67
- if (firstSeen)
68
+ noteAdvertPeerHints(ingested.verifiedNodeHash, ingested.body, meta)
69
+ if (firstSeen) {
70
+ noteDiscoveryPeerClue(ingested.verifiedNodeHash)
71
+ const host = String(meta.address || '').trim()
68
72
  nodeDebug('p2p:lan peer visible', {
69
73
  peer: shortHash(ingested.verifiedNodeHash),
70
74
  host: host || undefined,
71
- tcpPort: tcpPort > 0 ? tcpPort : undefined,
75
+ tcpPort: ingested.body?.tcpPort,
72
76
  })
77
+ }
73
78
  return ingested
74
79
  }
75
80
 
76
81
  /**
77
82
  * LAN UDP presence:段内 beacon,非 topic 订阅模型。
78
- * @param {{ port?: number, group?: string }} [options] 配置
83
+ * @param {{ port?: number, group?: string, localNodeHash?: string }} [options] 配置
79
84
  * @returns {import('./index.mjs').DiscoveryProvider} LAN 发现提供者
80
85
  */
81
86
  export function createLanDiscoveryProvider(options = {}) {
@@ -88,9 +93,33 @@ export function createLanDiscoveryProvider(options = {}) {
88
93
  let refs = 0
89
94
  /** @type {ReturnType<typeof setInterval> | null} */
90
95
  let beaconTimer = null
96
+ const seededSelf = normalizeHex64(options.localNodeHash)
97
+ /** @type {string | null} */
98
+ let selfNodeHash = isHex64(seededSelf) ? seededSelf : null
99
+ /** @type {Set<string>} */
100
+ const joinedAddresses = new Set()
101
+
102
+ /**
103
+ * 在每个本机 IPv4 接口上加入组播组;全部失败则回退到默认接口。
104
+ * @param {import('node:dgram').Socket} sock UDP socket
105
+ * @returns {void}
106
+ */
107
+ function ensureMemberships(sock) {
108
+ for (const addr of listMulticastIpv4Addresses()) {
109
+ if (joinedAddresses.has(addr)) continue
110
+ try {
111
+ sock.addMembership(group, addr)
112
+ joinedAddresses.add(addr)
113
+ }
114
+ catch { /* ignore per-interface join failure */ }
115
+ }
116
+ if (!joinedAddresses.size)
117
+ try { sock.addMembership(group) } catch { /* ignore */ }
118
+ }
91
119
 
92
120
  /**
93
- * @returns {import('node:dgram').Socket} 复用的组播 UDP socket
121
+ * 懒创建复用的 udp4 socket(reuseAddr,便于同机多实例绑同一 port)。
122
+ * @returns {import('node:dgram').Socket} 组播 presence socket
94
123
  */
95
124
  function getSocket() {
96
125
  return socket ||= dgram.createSocket({ type: 'udp4', reuseAddr: true })
@@ -108,7 +137,7 @@ export function createLanDiscoveryProvider(options = {}) {
108
137
  sock.once('error', reject)
109
138
  sock.bind(port, '0.0.0.0', () => {
110
139
  sock.off('error', reject)
111
- sock.addMembership(group)
140
+ ensureMemberships(sock)
112
141
  sock.setMulticastTTL(1)
113
142
  resolve()
114
143
  })
@@ -117,6 +146,7 @@ export function createLanDiscoveryProvider(options = {}) {
117
146
  try { sock.close() } catch { /* ignore */ }
118
147
  if (socket === sock) socket = null
119
148
  bound = false
149
+ joinedAddresses.clear()
120
150
  return
121
151
  }
122
152
  sock.on('message', (raw, rinfo) => {
@@ -131,8 +161,11 @@ export function createLanDiscoveryProvider(options = {}) {
131
161
  }
132
162
  catch { return }
133
163
  if (!advertBytes?.byteLength) return
134
- void acceptLanPresenceAdvert(advertBytes, { address: rinfo?.address, provider: 'lan' })
135
- .catch(() => { })
164
+ void acceptLanPresenceAdvert(advertBytes, {
165
+ address: rinfo?.address,
166
+ provider: 'lan',
167
+ skipNodeHash: selfNodeHash || undefined,
168
+ }).catch(() => { })
136
169
  })
137
170
  bound = true
138
171
  })().finally(() => {
@@ -148,10 +181,33 @@ export function createLanDiscoveryProvider(options = {}) {
148
181
  async function multicastBeacon(beacon) {
149
182
  await ensureBound()
150
183
  const sock = getSocket()
184
+ ensureMemberships(sock)
151
185
  const packet = Buffer.from(JSON.stringify({ type: 'presence', ...beacon }))
152
- await new Promise((resolve, reject) => {
186
+ const addrs = listMulticastIpv4Addresses()
187
+ /**
188
+ * @param {string | undefined} ifaceAddr 组播出口地址
189
+ * @returns {Promise<void>}
190
+ */
191
+ const sendOnce = ifaceAddr => new Promise((resolve, reject) => {
192
+ if (ifaceAddr) sock.setMulticastInterface(ifaceAddr)
153
193
  sock.send(packet, port, group, error => error ? reject(error) : resolve())
154
194
  })
195
+ if (!addrs.length) {
196
+ await sendOnce()
197
+ return
198
+ }
199
+ let sent = 0
200
+ /** @type {unknown} */
201
+ let lastError = null
202
+ for (const addr of addrs)
203
+ try {
204
+ await sendOnce(addr)
205
+ sent++
206
+ }
207
+ catch (error) {
208
+ lastError = error
209
+ }
210
+ if (!sent) throw lastError || new Error('p2p: lan multicast send failed')
155
211
  }
156
212
 
157
213
  /**
@@ -164,6 +220,8 @@ export function createLanDiscoveryProvider(options = {}) {
164
220
  refs = Math.max(0, refs - 1)
165
221
  if (refs > 0 || !socket) return
166
222
  if (beaconTimer) { clearInterval(beaconTimer); beaconTimer = null }
223
+ selfNodeHash = null
224
+ joinedAddresses.clear()
167
225
  try { socket.close() } catch { /* ignore */ }
168
226
  socket = null
169
227
  bound = false
@@ -206,6 +264,7 @@ export function createLanDiscoveryProvider(options = {}) {
206
264
  const send = async () => {
207
265
  const body = await getBeacon?.()
208
266
  if (!body) return
267
+ if (body.nodeHash) selfNodeHash = normalizeHex64(body.nodeHash)
209
268
  const advertBytes = body.advertBytes?.byteLength
210
269
  ? body.advertBytes
211
270
  : null
@@ -0,0 +1,78 @@
1
+ import os from 'node:os'
2
+
3
+ /** advert / 组播 beacon 携带的 LAN IPv4 上限 */
4
+ export const MAX_LAN_HOSTS = 4
5
+
6
+ const IPV4_RE = /^(?:\d{1,3}\.){3}\d{1,3}$/
7
+
8
+ /**
9
+ * untrusted ingress:清洗 advert body 中的 LAN IPv4 列表。
10
+ * @param {unknown} input 原始 lanHosts
11
+ * @returns {string[]} 去重后的 IPv4 列表
12
+ */
13
+ export function normalizeLanHosts(input) {
14
+ if (!input) return []
15
+ const arr = Array.isArray(input) ? input : [input]
16
+ const seen = new Set()
17
+ /** @type {string[]} */
18
+ const out = []
19
+ for (const item of arr) {
20
+ const host = String(item || '').trim()
21
+ if (!host || !IPV4_RE.test(host) || seen.has(host)) continue
22
+ seen.add(host)
23
+ out.push(host)
24
+ if (out.length >= MAX_LAN_HOSTS) break
25
+ }
26
+ return out
27
+ }
28
+
29
+ /**
30
+ * @param {string} addr IPv4
31
+ * @returns {number} 排序权重(越小越优先)
32
+ */
33
+ function lanAddressRank(addr) {
34
+ if (addr.startsWith('169.254.')) return 100
35
+ if (addr.startsWith('192.168.56.')) return 90
36
+ if (addr.startsWith('192.168.')) return 10
37
+ if (addr.startsWith('10.')) return 20
38
+ const match = /^172\.(\d+)\./u.exec(addr)
39
+ if (match && Number(match[1]) >= 16 && Number(match[1]) <= 31) return 30
40
+ return 50
41
+ }
42
+
43
+ /**
44
+ * @param {string[]} addrs IPv4 列表
45
+ * @returns {string[]} 按 LAN 可达性优先排序
46
+ */
47
+ function prioritizeLanAddresses(addrs) {
48
+ return [...addrs].sort((left, right) => lanAddressRank(left) - lanAddressRank(right))
49
+ }
50
+
51
+ /**
52
+ * @param {string | number} family os.networkInterfaces family
53
+ * @returns {boolean} 是否 IPv4
54
+ */
55
+ function isIpv4Family(family) {
56
+ return family === 'IPv4' || family === 4
57
+ }
58
+
59
+ /**
60
+ * 本机可用于 LAN 组播 / advert 的非 internal IPv4 地址。
61
+ * @returns {string[]} 去重、排序后的地址列表
62
+ */
63
+ export function listMulticastIpv4Addresses() {
64
+ const seen = new Set()
65
+ /** @type {string[]} */
66
+ const addrs = []
67
+ for (const ifaces of Object.values(os.networkInterfaces())) {
68
+ if (!ifaces) continue
69
+ for (const iface of ifaces) {
70
+ if (iface.internal || !isIpv4Family(iface.family)) continue
71
+ const addr = String(iface.address || '').trim()
72
+ if (!addr || seen.has(addr)) continue
73
+ seen.add(addr)
74
+ addrs.push(addr)
75
+ }
76
+ }
77
+ return prioritizeLanAddresses(addrs).slice(0, MAX_LAN_HOSTS)
78
+ }
@@ -5,11 +5,15 @@ import { createTtlMap } from '../utils/ttl_map.mjs'
5
5
  /** LAN peer hint 存活时间。 */
6
6
  export const LAN_PEER_HINT_TTL_MS = 5 * 60_000
7
7
 
8
- /** @type {ReturnType<typeof createTtlMap<{ host: string, port: number }>>} */
8
+ /** peer 保留的 endpoint 上限。 */
9
+ const MAX_ENDPOINTS = 8
10
+
11
+ /** @type {ReturnType<typeof createTtlMap<{ endpoints: { host: string, port: number }[] }>>} */
9
12
  const hints = createTtlMap(LAN_PEER_HINT_TTL_MS)
10
13
 
11
14
  /**
12
15
  * 记录 LAN 上观察到的 nodeHash → host:port。
16
+ * 最新写入排在最前(dial / getLanPeerHint 优先用新观测)。
13
17
  * @param {string} nodeHash 节点 64 hex
14
18
  * @param {{ host: string, port: number }} endpoint 端点
15
19
  * @returns {void}
@@ -19,19 +23,32 @@ export function noteLanPeerHint(nodeHash, endpoint) {
19
23
  const host = String(endpoint?.host || '').trim()
20
24
  const port = normalizeTcpPort(endpoint?.port)
21
25
  if (!hash || !host || !port) return
22
- hints.set(hash, { host, port })
26
+ const existing = hints.get(hash)?.endpoints ?? []
27
+ const next = existing.filter(item => !(item.host === host && item.port === port))
28
+ next.unshift({ host, port })
29
+ hints.set(hash, { endpoints: next.slice(0, MAX_ENDPOINTS) })
23
30
  }
24
31
 
25
32
  /**
26
- * 查询未过期的 LAN peer hint
33
+ * 查询未过期的首个 LAN peer hint(最新观测)。
27
34
  * @param {string} nodeHash 节点 64 hex
28
35
  * @param {number} [now=Date.now()] 当前时间(测试可注入)
29
36
  * @returns {{ host: string, port: number } | null} hint 或 null
30
37
  */
31
38
  export function getLanPeerHint(nodeHash, now = Date.now()) {
39
+ return listLanPeerHints(nodeHash, now)[0] ?? null
40
+ }
41
+
42
+ /**
43
+ * 查询未过期的全部 LAN peer hint(最新在前)。
44
+ * @param {string} nodeHash 节点 64 hex
45
+ * @param {number} [now=Date.now()] 当前时间(测试可注入)
46
+ * @returns {{ host: string, port: number }[]} hint 列表
47
+ */
48
+ export function listLanPeerHints(nodeHash, now = Date.now()) {
32
49
  const hash = normalizeHex64(nodeHash)
33
- if (!hash) return null
34
- return hints.get(hash, now)
50
+ if (!hash) return []
51
+ return hints.get(hash, now)?.endpoints ?? []
35
52
  }
36
53
 
37
54
  /**