@steve02081504/fount-p2p 0.0.10 → 0.0.11

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
@@ -24,7 +24,9 @@ await startNode({ nodeDir: '/path/to/p2p/node' })
24
24
  await ensureUserRoom()
25
25
  ```
26
26
 
27
- Subpath exports mirror source directories, e.g. `@steve02081504/fount-p2p/dag`, `@steve02081504/fount-p2p/transport/link_registry`, `@steve02081504/fount-p2p/registries/event_type`.
27
+ Shells talk to the **fount network** (`ensureLinkToNode` / `sendToNodeLink` / rooms). Do not import `link/` or choose WebRTC / BLE / LAN yourself.
28
+
29
+ Subpath exports mirror source directories, e.g. `@steve02081504/fount-p2p/dag`, `@steve02081504/fount-p2p/transport/link_registry`, `@steve02081504/fount-p2p/transport/signal_crypto`, `@steve02081504/fount-p2p/registries/event_type`.
28
30
 
29
31
  ## Layout
30
32
 
@@ -33,31 +35,47 @@ Subpath exports mirror source directories, e.g. `@steve02081504/fount-p2p/dag`,
33
35
  | L0 | `core/` | Pure primitives: `hexIds`, `entity_id*`, `canonical_json` |
34
36
  | L1 | `crypto/`, `wire/`, `schemas/` | Cryptography, wire protocol, canonical validation |
35
37
  | L2 | `node/` | Node runtime: `identity`, `entity_store`, `denylist`, `reputation_store` |
36
- | L3 | `discovery/`, `link/`, `transport/`, `rooms/` | Discovery, RTC links, rooms |
37
- | L4 | `trust_graph/`, `mailbox/`, `dag/`, `federation/`, `files/` | Federation, store-and-forward, DAG, EVFS |
38
+ | L3 | `discovery/`, `link/`, `transport/`, `rooms/` | Discovery + fount-network registry/rooms (`link/providers` are package-private) |
39
+ | L4 | `trust_graph/`, `mailbox/`, `dag/`, `federation/`, `files/`, `governance/`, `reputation/` | Federation, store-and-forward, DAG, EVFS, tunables |
38
40
  | — | `registries/` | Pluggable registries (event type, part path, room provider, …) |
39
41
 
40
42
  Facade entry: `index.mjs` (`startNode`, `createGroupLinkSet`, `registerDiscoveryProvider`, …).
41
43
 
42
- Root contains only the facade and package metadata; all modules live in layered subdirectories. After a layout migration, run `node scripts/cleanup-root-duplicates.mjs` to drop stale root stubs.
44
+ **Transport modules** (exported under `./transport/*`):
45
+
46
+ | Module | Role |
47
+ |---|---|
48
+ | `link_registry.mjs` | Fount-network facade: dial fallback, scope/overlay |
49
+ | `runtime_bootstrap.mjs` | Progressive `ensureRuntime` (register + background listen / discovery / BT) |
50
+ | `offer_answer.mjs` | Discovery-signal glare for offer/answer providers |
51
+ | `signal_crypto.mjs` | Rendezvous topics + AES-GCM signal packets |
52
+
53
+ `ensureRuntime` returns after registration and scheduling warm-up; it does not await lan_tcp listen, public relays, or Bluetooth. See [docs/runtime.md](./docs/runtime.md) and [docs/transports.md](./docs/transports.md).
54
+
55
+ Root contains only the facade and package metadata; all modules live in layered subdirectories.
43
56
 
44
57
  ## Tests
45
58
 
46
59
  ```bash
47
60
  npm test # package pure + integration (Node)
48
61
  npm run test:fount # cross-repo bridge (Deno; requires fount on PATH)
49
- npm run test:live # RTC / link smoke (requires node-datachannel)
62
+ npm run test:live # link / LAN / glare smoke
50
63
  npm run test:sim # tunables co-evolution sim (dev only, not published; --social-tunables to write back)
51
64
  ```
52
65
 
53
66
  During development: `node scripts/check-imports.mjs` validates relative imports. After a layout migration, `node scripts/cleanup-root-duplicates.mjs` removes stale root-level stubs.
54
67
 
68
+ Maintainer notes for agents / contributors: [AGENTS.md](./AGENTS.md). Sim harness fidelity: [sim/AGENTS.md](./sim/AGENTS.md).
69
+
55
70
  ## Optional dependencies
56
71
 
57
- - `@stoprocent/noble` / `@stoprocent/bleno` — Bluetooth (optionalDependencies). Unavailable when there is no adapter, load fails, or the radio never reaches poweredOn — other discovery/link paths continue.
72
+ - `@stoprocent/noble` / `@stoprocent/bleno` — Bluetooth (optionalDependencies). Hardware probe is subprocess-only; see [docs/runtime.md](./docs/runtime.md).
73
+ - `node-datachannel` — WebRTC DataChannels (dependency).
74
+ - `ws` — Nostr discovery/signaling WebSockets (dependency).
58
75
 
59
76
  Group chunk remote storage (S3, etc.) is implemented by the shell as `GroupStoragePlugin` and injected; see `node/storage_plugins.mjs` for the local reference implementation.
60
77
 
61
78
  ## Docs
62
79
 
63
- - Signaling and link setup: [`docs/signaling.md`](./docs/signaling.md)
80
+ - Transports and provider fallback: [`docs/transports.md`](./docs/transports.md)
81
+ - Signaling and WebRTC glare: [`docs/signaling.md`](./docs/signaling.md)
@@ -0,0 +1,20 @@
1
+ /**
2
+ * 子进程 BT 可用性探测:load → poweredOn → stop → exit。
3
+ * 由 `canUseBluetoothRuntime` spawn;勿在父进程直接跑 waitPoweredOn(会拖住事件循环)。
4
+ */
5
+ import process from 'node:process'
6
+
7
+ import { loadNoble, waitPoweredOn } from './runtime.mjs'
8
+
9
+ const timeoutMs = Number(process.env.FOUNT_BT_PROBE_MS || 3000)
10
+
11
+ try {
12
+ const noble = await loadNoble()
13
+ if (!noble?.startScanningAsync) process.exit(2)
14
+ await waitPoweredOn(noble, timeoutMs)
15
+ try { noble.stop() } catch { /* Windows 上 stop 偶发崩;子进程反正要退出 */ }
16
+ process.exit(0)
17
+ }
18
+ catch {
19
+ process.exit(1)
20
+ }
@@ -1,5 +1,8 @@
1
1
  import { existsSync, readdirSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
2
3
  import process from 'node:process'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { spawn } from 'node:child_process'
3
6
 
4
7
  /**
5
8
  * 解析 Bluetooth 角色(scan / dual)。
@@ -31,10 +34,55 @@ export function probeBluetoothHardware() {
31
34
 
32
35
  /** @type {boolean | null} canUseBluetoothRuntime 缓存 */
33
36
  let cachedRuntimeOk = null
37
+ /** @type {Promise<boolean> | null} 并发 canUse 合并为一次子进程探测 */
38
+ let probeInflight = null
39
+
40
+ const PROBE_CHILD = join(dirname(fileURLToPath(import.meta.url)), 'probe_child.mjs')
41
+
42
+ /**
43
+ * 在子进程中探测 BT(父进程 waitPoweredOn 会拖住事件循环;stop() 还可能 AV)。
44
+ * @param {number} timeoutMs waitPoweredOn 超时
45
+ * @returns {Promise<boolean>} 子进程 exit 0 为可用
46
+ */
47
+ function probeBluetoothInSubprocess(timeoutMs) {
48
+ return new Promise(resolve => {
49
+ const child = spawn(process.execPath, [PROBE_CHILD], {
50
+ stdio: 'ignore',
51
+ env: {
52
+ ...process.env,
53
+ FOUNT_BT_PROBE_MS: String(timeoutMs),
54
+ },
55
+ windowsHide: true,
56
+ })
57
+ // 探测是旁路缓存填充;勿拖住父进程事件循环 / shutdown 退出。
58
+ child.unref()
59
+ let settled = false
60
+ /**
61
+ * @param {boolean} ok 探测结果
62
+ * @returns {void}
63
+ */
64
+ const finish = ok => {
65
+ if (settled) return
66
+ settled = true
67
+ clearTimeout(timer)
68
+ resolve(ok)
69
+ }
70
+ const timer = setTimeout(() => {
71
+ child.kill('SIGKILL')
72
+ finish(false)
73
+ }, timeoutMs + 5_000)
74
+ timer.unref()
75
+ child.once('error', () => finish(false))
76
+ child.once('exit', (code, signal) => {
77
+ if (signal) finish(false)
78
+ else finish(code === 0)
79
+ })
80
+ })
81
+ }
34
82
 
35
83
  /**
36
- * 探测 BT 栈是否真正可用(有适配器迹象 load → poweredOn)。
37
- * 任一步失败返回 false,调用方回落其它 discovery/link;不抛错。
84
+ * 探测 BT 栈是否真正可用(硬件迹象子进程 load → poweredOn)。
85
+ * 任一步失败返回 false;不抛错。
38
86
  * @param {number} [timeoutMs=3000] waitPoweredOn 超时
39
87
  * @returns {Promise<boolean>} 可用为 true
40
88
  */
@@ -44,19 +92,15 @@ export async function canUseBluetoothRuntime(timeoutMs = 3000) {
44
92
  cachedRuntimeOk = false
45
93
  return false
46
94
  }
47
- try {
48
- const noble = await loadNoble()
49
- if (!noble.startScanningAsync) {
50
- cachedRuntimeOk = false
51
- return false
52
- }
53
- await waitPoweredOn(noble, timeoutMs)
54
- cachedRuntimeOk = true
55
- }
56
- catch {
57
- cachedRuntimeOk = false
95
+ if (!probeInflight) {
96
+ probeInflight = probeBluetoothInSubprocess(timeoutMs)
97
+ .then(ok => {
98
+ cachedRuntimeOk = ok
99
+ return ok
100
+ })
101
+ .finally(() => { probeInflight = null })
58
102
  }
59
- return cachedRuntimeOk
103
+ return probeInflight
60
104
  }
61
105
 
62
106
  /**
@@ -23,6 +23,14 @@ export function unregisterDiscoveryProvider(id) {
23
23
  providers.delete(String(id))
24
24
  }
25
25
 
26
+ /**
27
+ * 清空全部 discovery provider。
28
+ * @returns {void}
29
+ */
30
+ export function clearDiscoveryProviders() {
31
+ providers.clear()
32
+ }
33
+
26
34
  /**
27
35
  * 列出已注册的 discovery provider(按 priority 排序)。
28
36
  * @returns {DiscoveryProvider[]} 提供者列表
@@ -32,29 +40,40 @@ export function listDiscoveryProviders() {
32
40
  }
33
41
 
34
42
  /**
35
- * 通过所有可用 provider 广播 topic advert。
36
- * 单路失败静默(正常降级);其它 provider 仍可成功。
37
- * @param {string} topic advert 主题
38
- * @param {Uint8Array} bytes advert 载荷
43
+ * 并行启动各 provider async 钩子;单路失败静默。
44
+ * @param {DiscoveryProvider[]} list 提供者列表
45
+ * @param {(provider: DiscoveryProvider) => Promise<(() => void) | null | undefined>} run 单路启动
39
46
  * @returns {Promise<() => void>} 统一取消函数
40
47
  */
41
- export async function advertiseTopic(topic, bytes) {
42
- const cleanups = []
43
- for (const provider of listDiscoveryProviders()) {
44
- if (!provider.caps?.canDiscover || typeof provider.advertise !== 'function') continue
45
- let cleanup = null
48
+ async function startProvidersParallel(list, run) {
49
+ const settled = await Promise.all(list.map(async provider => {
46
50
  try {
47
- cleanup = await provider.advertise(topic, bytes)
51
+ const cleanup = await run(provider)
52
+ return typeof cleanup === 'function' ? cleanup : null
48
53
  }
49
- catch { continue }
50
- if (typeof cleanup === 'function') cleanups.push(cleanup)
51
- }
54
+ catch {
55
+ return null
56
+ }
57
+ }))
58
+ const cleanups = settled.filter(Boolean)
52
59
  return () => {
53
60
  for (const cleanup of cleanups)
54
61
  try { cleanup() } catch { /* ignore */ }
55
62
  }
56
63
  }
57
64
 
65
+ /**
66
+ * 通过所有可用 provider 广播 topic advert。
67
+ * 单路失败静默(正常降级);其它 provider 仍可成功。
68
+ * @param {string} topic advert 主题
69
+ * @param {Uint8Array} bytes advert 载荷
70
+ * @returns {Promise<() => void>} 统一取消函数
71
+ */
72
+ export async function advertiseTopic(topic, bytes) {
73
+ const list = listDiscoveryProviders().filter(provider => provider.caps?.canDiscover && typeof provider.advertise === 'function')
74
+ return startProvidersParallel(list, provider => provider.advertise(topic, bytes))
75
+ }
76
+
58
77
  /**
59
78
  * 通过所有可用 provider 订阅 topic advert。
60
79
  * 单路失败静默(正常降级)。
@@ -63,20 +82,8 @@ export async function advertiseTopic(topic, bytes) {
63
82
  * @returns {Promise<() => void>} 统一取消函数
64
83
  */
65
84
  export async function subscribeTopic(topic, onAdvert) {
66
- const cleanups = []
67
- for (const provider of listDiscoveryProviders()) {
68
- if (!provider.caps?.canDiscover || typeof provider.subscribe !== 'function') continue
69
- let cleanup = null
70
- try {
71
- cleanup = await provider.subscribe(topic, onAdvert)
72
- }
73
- catch { continue }
74
- if (typeof cleanup === 'function') cleanups.push(cleanup)
75
- }
76
- return () => {
77
- for (const cleanup of cleanups)
78
- try { cleanup() } catch { /* ignore */ }
79
- }
85
+ const list = listDiscoveryProviders().filter(provider => provider.caps?.canDiscover && typeof provider.subscribe === 'function')
86
+ return startProvidersParallel(list, provider => provider.subscribe(topic, onAdvert))
80
87
  }
81
88
 
82
89
  /**
@@ -93,13 +100,15 @@ export async function sendSignal(topic, to, bytes) {
93
100
  if (!capable.length) throw new Error('p2p: no discovery provider can signal')
94
101
  let sent = false
95
102
  let lastError = null
96
- for (const provider of capable) try {
97
- if (await Promise.resolve(provider.sendSignal(topic, to, bytes)) !== false)
98
- sent = true
99
- }
100
- catch (error) {
101
- lastError = error
102
- }
103
+ await Promise.all(capable.map(async provider => {
104
+ try {
105
+ if (await Promise.resolve(provider.sendSignal(topic, to, bytes)) !== false)
106
+ sent = true
107
+ }
108
+ catch (error) {
109
+ lastError = error
110
+ }
111
+ }))
103
112
  if (!sent) throw lastError || new Error('p2p: no discovery provider delivered signal')
104
113
  }
105
114
 
@@ -111,18 +120,6 @@ export async function sendSignal(topic, to, bytes) {
111
120
  * @returns {Promise<() => void>} 统一取消函数
112
121
  */
113
122
  export async function listenSignals(topic, onSignal) {
114
- const cleanups = []
115
- for (const provider of listDiscoveryProviders()) {
116
- if (!provider.caps?.canSignal || typeof provider.onSignal !== 'function') continue
117
- let cleanup = null
118
- try {
119
- cleanup = await provider.onSignal(topic, onSignal)
120
- }
121
- catch { continue }
122
- if (typeof cleanup === 'function') cleanups.push(cleanup)
123
- }
124
- return () => {
125
- for (const cleanup of cleanups)
126
- try { cleanup() } catch { /* ignore */ }
127
- }
123
+ const list = listDiscoveryProviders().filter(provider => provider.caps?.canSignal && typeof provider.onSignal === 'function')
124
+ return startProvidersParallel(list, provider => provider.onSignal(topic, onSignal))
128
125
  }
@@ -6,6 +6,7 @@ const DEFAULT_GROUP = '239.255.42.99'
6
6
 
7
7
  /**
8
8
  * 轻量 multicast 发现插件:不做完整 DNS-SD,只复用 mDNS 的 LAN multicast 发现思路。
9
+ * 引用计数为 0 时关闭 UDP socket,避免 shutdown 后句柄拖住进程。
9
10
  *
10
11
  * @param {{ port?: number, group?: string }} [options] 组播端口与组地址
11
12
  * @returns {import('./index.mjs').DiscoveryProvider} mDNS 发现提供者
@@ -13,14 +14,24 @@ const DEFAULT_GROUP = '239.255.42.99'
13
14
  export function createMdnsDiscoveryProvider(options = {}) {
14
15
  const port = Number(options.port) || DEFAULT_PORT
15
16
  const group = String(options.group || DEFAULT_GROUP)
16
- const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true })
17
+ /** @type {import('node:dgram').Socket | null} */
18
+ let socket = null
17
19
  let bound = false
18
20
  let bindPromise = null
21
+ let refs = 0
19
22
  /** @type {Map<string, Set<Function>>} */
20
23
  const advertListeners = new Map()
21
24
  /** @type {Map<string, Set<Function>>} */
22
25
  const signalListeners = new Map()
23
26
 
27
+ /**
28
+ * 懒创建或返回当前 UDP socket。
29
+ * @returns {import('node:dgram').Socket} 用于组播 bind/send 的 socket
30
+ */
31
+ function getSocket() {
32
+ return socket ||= dgram.createSocket({ type: 'udp4', reuseAddr: true })
33
+ }
34
+
24
35
  /**
25
36
  * 绑定 UDP socket 并注册组播消息处理器。
26
37
  * @returns {Promise<void>}
@@ -29,16 +40,24 @@ export function createMdnsDiscoveryProvider(options = {}) {
29
40
  if (bound) return
30
41
  if (!bindPromise)
31
42
  bindPromise = (async () => {
43
+ const sock = getSocket()
32
44
  await new Promise((resolve, reject) => {
33
- socket.once('error', reject)
34
- socket.bind(port, '0.0.0.0', () => {
35
- socket.off('error', reject)
36
- socket.addMembership(group)
37
- socket.setMulticastTTL(1)
45
+ sock.once('error', reject)
46
+ sock.bind(port, '0.0.0.0', () => {
47
+ sock.off('error', reject)
48
+ sock.addMembership(group)
49
+ sock.setMulticastTTL(1)
38
50
  resolve()
39
51
  })
40
52
  })
41
- socket.on('message', (raw, rinfo) => {
53
+ // acquire 已全部释放:关掉刚 bind 的 socket,避免泄漏句柄。
54
+ if (refs <= 0) {
55
+ try { sock.close() } catch { /* ignore */ }
56
+ if (socket === sock) socket = null
57
+ bound = false
58
+ return
59
+ }
60
+ sock.on('message', (raw, rinfo) => {
42
61
  let packet
43
62
  try { packet = JSON.parse(String(raw)) } catch { return }
44
63
  const listeners = packet.type === 'advert'
@@ -70,16 +89,34 @@ export function createMdnsDiscoveryProvider(options = {}) {
70
89
  */
71
90
  async function multicast(type, topic, bytes) {
72
91
  await ensureBound()
92
+ const sock = getSocket()
73
93
  const packet = Buffer.from(JSON.stringify({
74
94
  type,
75
95
  topic,
76
96
  data: Buffer.from(bytes).toString('base64'),
77
97
  }))
78
98
  await new Promise((resolve, reject) => {
79
- socket.send(packet, port, group, error => error ? reject(error) : resolve())
99
+ sock.send(packet, port, group, error => error ? reject(error) : resolve())
80
100
  })
81
101
  }
82
102
 
103
+ /**
104
+ * 占用 socket;归还后若无引用则关闭。
105
+ * @returns {() => void} 释放函数
106
+ */
107
+ function acquire() {
108
+ refs++
109
+ void ensureBound().catch(() => { })
110
+ return () => {
111
+ refs = Math.max(0, refs - 1)
112
+ if (refs > 0 || !socket) return
113
+ try { socket.close() } catch { /* ignore */ }
114
+ socket = null
115
+ bound = false
116
+ bindPromise = null
117
+ }
118
+ }
119
+
83
120
  /**
84
121
  * 向 topic bucket 注册监听器。
85
122
  * @param {Map<string, Set<Function>>} bucket topic → 监听器集合
@@ -90,7 +127,11 @@ export function createMdnsDiscoveryProvider(options = {}) {
90
127
  function addListener(bucket, topic, listener) {
91
128
  if (!bucket.has(topic)) bucket.set(topic, new Set())
92
129
  bucket.get(topic).add(listener)
93
- return () => bucket.get(topic)?.delete(listener)
130
+ const release = acquire()
131
+ return () => {
132
+ bucket.get(topic)?.delete(listener)
133
+ release()
134
+ }
94
135
  }
95
136
 
96
137
  return {
@@ -98,24 +139,27 @@ export function createMdnsDiscoveryProvider(options = {}) {
98
139
  priority: 10,
99
140
  caps: { canDiscover: true, canSignal: true, canRelay: false },
100
141
  /**
101
- * 周期性组播广播 advert
142
+ * 周期性组播广播 advert(首发后台,不阻塞调用方)。
102
143
  * @param {string} topic advert 主题
103
144
  * @param {Uint8Array} bytes advert 载荷
104
145
  * @returns {Promise<() => void>} 取消广播函数
105
146
  */
106
147
  async advertise(topic, bytes) {
107
- await multicast('advert', topic, bytes)
148
+ const release = acquire()
149
+ void multicast('advert', topic, bytes).catch(() => { })
108
150
  const timer = setInterval(() => { void multicast('advert', topic, bytes).catch(() => { }) }, 30_000)
109
- return () => clearInterval(timer)
151
+ return () => {
152
+ clearInterval(timer)
153
+ release()
154
+ }
110
155
  },
111
156
  /**
112
- * 订阅组播 advert
157
+ * 订阅组播 advert(立即返回;UDP bind 后台完成)。
113
158
  * @param {string} topic advert 主题
114
159
  * @param {Function} onAdvert advert 回调
115
160
  * @returns {Promise<() => void>} 取消订阅函数
116
161
  */
117
162
  async subscribe(topic, onAdvert) {
118
- await ensureBound()
119
163
  return addListener(advertListeners, topic, onAdvert)
120
164
  },
121
165
  /**
@@ -126,16 +170,21 @@ export function createMdnsDiscoveryProvider(options = {}) {
126
170
  * @returns {Promise<void>}
127
171
  */
128
172
  async sendSignal(topic, _to, bytes) {
129
- await multicast('signal', topic, bytes)
173
+ const release = acquire()
174
+ try {
175
+ await multicast('signal', topic, bytes)
176
+ }
177
+ finally {
178
+ release()
179
+ }
130
180
  },
131
181
  /**
132
- * 订阅组播信令。
182
+ * 订阅组播信令(立即返回;UDP bind 后台完成)。
133
183
  * @param {string} topic 信令 topic
134
184
  * @param {Function} onSignal 信令回调
135
185
  * @returns {Promise<() => void>} 取消订阅函数
136
186
  */
137
187
  async onSignal(topic, onSignal) {
138
- await ensureBound()
139
188
  return addListener(signalListeners, topic, onSignal)
140
189
  },
141
190
  }