@steve02081504/fount-p2p 0.0.9 → 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 +25 -7
- package/discovery/bt/index.mjs +4 -12
- package/discovery/bt/probe_child.mjs +20 -0
- package/discovery/bt/runtime.mjs +99 -5
- package/discovery/index.mjs +45 -48
- package/discovery/mdns.mjs +66 -17
- package/discovery/nostr.mjs +149 -67
- package/link/providers/ble_gatt.mjs +4 -15
- package/link/providers/index.mjs +7 -9
- package/link/providers/lan_tcp.mjs +1 -1
- package/link/providers/webrtc.mjs +1 -1
- package/package.json +3 -2
- package/rooms/scoped_link.mjs +3 -3
- package/transport/group_link_set.mjs +4 -4
- package/transport/ice_servers.mjs +10 -1
- package/transport/link_registry.mjs +76 -424
- package/transport/offer_answer.mjs +190 -0
- package/transport/runtime_bootstrap.mjs +363 -0
- package/transport/signal_crypto.mjs +82 -0
package/README.md
CHANGED
|
@@ -24,7 +24,9 @@ await startNode({ nodeDir: '/path/to/p2p/node' })
|
|
|
24
24
|
await ensureUserRoom()
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
|
|
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
|
|
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
|
-
|
|
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 #
|
|
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
|
|
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
|
-
-
|
|
80
|
+
- Transports and provider fallback: [`docs/transports.md`](./docs/transports.md)
|
|
81
|
+
- Signaling and WebRTC glare: [`docs/signaling.md`](./docs/signaling.md)
|
package/discovery/bt/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer'
|
|
2
2
|
|
|
3
3
|
import { getBtPeerHint } from './peer_hints.mjs'
|
|
4
|
-
import { loadBleno, loadNoble, resolveBtRole, waitPoweredOn } from './runtime.mjs'
|
|
4
|
+
import { canUseBluetoothRuntime, loadBleno, loadNoble, resolveBtRole, waitPoweredOn } from './runtime.mjs'
|
|
5
5
|
|
|
6
6
|
/** 重导出 waitPoweredOn,供 discovery 调用方使用。 */
|
|
7
7
|
export { waitPoweredOn } from './runtime.mjs'
|
|
@@ -15,19 +15,11 @@ const MAX_SIGNAL_BLOB_BYTES = 8 * 1024
|
|
|
15
15
|
const PERIPHERAL_RESCAN_MS = 15_000
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* 探测本机
|
|
19
|
-
* @returns {Promise<boolean>}
|
|
18
|
+
* 探测本机 BT discovery 是否可用;失败则回落其它 discovery,不抛错。
|
|
19
|
+
* @returns {Promise<boolean>} 可用为 true
|
|
20
20
|
*/
|
|
21
21
|
export async function canUseBluetoothDiscovery() {
|
|
22
|
-
|
|
23
|
-
const noble = await loadNoble()
|
|
24
|
-
if (typeof noble.startScanningAsync !== 'function') return false
|
|
25
|
-
const wait = noble.waitForPoweredOnAsync ?? noble.waitForPoweredOn
|
|
26
|
-
return typeof wait === 'function'
|
|
27
|
-
}
|
|
28
|
-
catch {
|
|
29
|
-
return false
|
|
30
|
-
}
|
|
22
|
+
return canUseBluetoothRuntime()
|
|
31
23
|
}
|
|
32
24
|
|
|
33
25
|
/**
|
|
@@ -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
|
+
}
|
package/discovery/bt/runtime.mjs
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
1
3
|
import process from 'node:process'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { spawn } from 'node:child_process'
|
|
2
6
|
|
|
3
7
|
/**
|
|
4
8
|
* 解析 Bluetooth 角色(scan / dual)。
|
|
@@ -13,20 +17,110 @@ export function resolveBtRole() {
|
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
/**
|
|
16
|
-
*
|
|
20
|
+
* 廉价硬件探测:明确无适配器时跳过 noble/bleno import(加载本身会拉起 native,无适配器时常在 teardown SIGSEGV)。
|
|
21
|
+
* @returns {boolean | null} true=有迹象,false=明确无,null=未知(继续尝试加载,失败则回落)
|
|
22
|
+
*/
|
|
23
|
+
export function probeBluetoothHardware() {
|
|
24
|
+
if (process.platform === 'linux') try {
|
|
25
|
+
const dir = '/sys/class/bluetooth'
|
|
26
|
+
if (!existsSync(dir)) return false
|
|
27
|
+
return readdirSync(dir).some(name => name && !name.startsWith('.'))
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @type {boolean | null} canUseBluetoothRuntime 缓存 */
|
|
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
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 探测 BT 栈是否真正可用(硬件迹象 → 子进程 load → poweredOn)。
|
|
85
|
+
* 任一步失败返回 false;不抛错。
|
|
86
|
+
* @param {number} [timeoutMs=3000] waitPoweredOn 超时
|
|
87
|
+
* @returns {Promise<boolean>} 可用为 true
|
|
88
|
+
*/
|
|
89
|
+
export async function canUseBluetoothRuntime(timeoutMs = 3000) {
|
|
90
|
+
if (cachedRuntimeOk !== null) return cachedRuntimeOk
|
|
91
|
+
if (probeBluetoothHardware() === false) {
|
|
92
|
+
cachedRuntimeOk = false
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
if (!probeInflight) {
|
|
96
|
+
probeInflight = probeBluetoothInSubprocess(timeoutMs)
|
|
97
|
+
.then(ok => {
|
|
98
|
+
cachedRuntimeOk = ok
|
|
99
|
+
return ok
|
|
100
|
+
})
|
|
101
|
+
.finally(() => { probeInflight = null })
|
|
102
|
+
}
|
|
103
|
+
return probeInflight
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 加载 Noble BLE central。明确无适配器时直接抛错,避免无意义的 native import。
|
|
17
108
|
* @returns {Promise<any>} noble 运行时
|
|
18
109
|
*/
|
|
19
110
|
export async function loadNoble() {
|
|
111
|
+
if (probeBluetoothHardware() === false)
|
|
112
|
+
throw new Error('p2p: no bluetooth adapter')
|
|
20
113
|
const mod = await import('@stoprocent/noble')
|
|
21
|
-
|
|
22
|
-
return mod.default ?? mod
|
|
114
|
+
return mod?.withBindings?.('default') || mod?.default || mod
|
|
23
115
|
}
|
|
24
116
|
|
|
25
117
|
/**
|
|
26
|
-
* 加载 Bleno BLE peripheral。
|
|
118
|
+
* 加载 Bleno BLE peripheral。明确无适配器时直接抛错,避免无意义的 native import。
|
|
27
119
|
* @returns {Promise<any>} bleno 运行时
|
|
28
120
|
*/
|
|
29
121
|
export async function loadBleno() {
|
|
122
|
+
if (probeBluetoothHardware() === false)
|
|
123
|
+
throw new Error('p2p: no bluetooth adapter')
|
|
30
124
|
const mod = await import('@stoprocent/bleno')
|
|
31
125
|
if (typeof mod.withBindings === 'function') return mod.withBindings('default')
|
|
32
126
|
return mod.default ?? mod
|
|
@@ -40,7 +134,7 @@ export async function loadBleno() {
|
|
|
40
134
|
*/
|
|
41
135
|
export async function waitPoweredOn(runtime, timeout) {
|
|
42
136
|
const wait = runtime.waitForPoweredOnAsync ?? runtime.waitForPoweredOn
|
|
43
|
-
if (
|
|
137
|
+
if (!wait)
|
|
44
138
|
throw new Error('p2p: bluetooth runtime missing waitForPoweredOn(Async)')
|
|
45
139
|
return wait.call(runtime, timeout)
|
|
46
140
|
}
|
package/discovery/index.mjs
CHANGED
|
@@ -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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* @param {
|
|
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
|
-
|
|
42
|
-
const
|
|
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
|
|
51
|
+
const cleanup = await run(provider)
|
|
52
|
+
return typeof cleanup === 'function' ? cleanup : null
|
|
48
53
|
}
|
|
49
|
-
catch {
|
|
50
|
-
|
|
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
|
|
67
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
115
|
-
|
|
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
|
}
|
package/discovery/mdns.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 () =>
|
|
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
|
-
|
|
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
|
}
|