@steve02081504/fount-p2p 0.0.6 → 0.0.7
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/core/tcp_port.mjs +11 -0
- package/discovery/advert_peer_hints.mjs +21 -0
- package/discovery/{bt.mjs → bt/index.mjs} +151 -55
- package/discovery/bt/peer_hints.mjs +41 -0
- package/discovery/bt/runtime.mjs +46 -0
- package/discovery/lan_peer_hints.mjs +43 -0
- package/discovery/mdns.mjs +6 -2
- package/index.mjs +3 -2
- package/link/handshake.mjs +48 -21
- package/link/pipe.mjs +404 -0
- package/link/providers/ble_gatt.mjs +339 -0
- package/link/providers/index.mjs +90 -0
- package/link/providers/lan_tcp.mjs +342 -0
- package/link/providers/levels.mjs +9 -0
- package/link/providers/webrtc.mjs +385 -0
- package/package.json +3 -2
- package/rooms/scoped_link.mjs +5 -3
- package/transport/group_link_set.mjs +5 -3
- package/transport/link_registry.mjs +241 -54
- package/utils/ttl_map.mjs +41 -0
- package/link/link.mjs +0 -617
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 规范化 TCP 端口(advert / peer hint 共用)。
|
|
3
|
+
* @param {unknown} port 原始端口
|
|
4
|
+
* @returns {number | null} 有效端口或 null(未提供 / 非法)
|
|
5
|
+
*/
|
|
6
|
+
export function normalizeTcpPort(port) {
|
|
7
|
+
if (port == null || port === '') return null
|
|
8
|
+
const value = Number(port)
|
|
9
|
+
if (!Number.isInteger(value) || value < 1 || value > 65535) return null
|
|
10
|
+
return value
|
|
11
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { normalizeTcpPort } from '../core/tcp_port.mjs'
|
|
2
|
+
|
|
3
|
+
import { noteBtPeerHint } from './bt/peer_hints.mjs'
|
|
4
|
+
import { noteLanPeerHint } from './lan_peer_hints.mjs'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 从已验证的 discovery advert + provider meta 写入 LAN/BT peer hints。
|
|
8
|
+
* 任意 discovery 路径(node / group / scoped topic)收到 advert 时都应调用。
|
|
9
|
+
* @param {string} verifiedNodeHash 验签通过的 nodeHash
|
|
10
|
+
* @param {{ tcpPort?: unknown } | null | undefined} body advert body
|
|
11
|
+
* @param {{ address?: unknown, peripheralId?: unknown } | null | undefined} meta discovery provider meta
|
|
12
|
+
* @returns {void}
|
|
13
|
+
*/
|
|
14
|
+
export function noteAdvertPeerHints(verifiedNodeHash, body, meta) {
|
|
15
|
+
if (meta?.peripheralId)
|
|
16
|
+
noteBtPeerHint(verifiedNodeHash, meta.peripheralId)
|
|
17
|
+
const tcpPort = normalizeTcpPort(body?.tcpPort)
|
|
18
|
+
const address = String(meta?.address || '').trim()
|
|
19
|
+
if (tcpPort != null && address)
|
|
20
|
+
noteLanPeerHint(verifiedNodeHash, { host: address, port: tcpPort })
|
|
21
|
+
}
|
|
@@ -1,56 +1,19 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer'
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
import { getBtPeerHint } from './peer_hints.mjs'
|
|
4
|
+
import { loadBleno, loadNoble, resolveBtRole, waitPoweredOn } from './runtime.mjs'
|
|
5
|
+
|
|
6
|
+
/** 重导出 waitPoweredOn,供 discovery 调用方使用。 */
|
|
7
|
+
export { waitPoweredOn } from './runtime.mjs'
|
|
3
8
|
|
|
4
9
|
const BT_SERVICE_UUID = 'f017f017f017f017f017f017f017f017'
|
|
5
10
|
const BT_CHARACTERISTIC_UUID = 'f017f017f017f017f017f017f017f018'
|
|
11
|
+
const BT_SIGNAL_CHAR_UUID = 'f017f017f017f017f017f017f017f01b'
|
|
6
12
|
const BT_DEVICE_NAME = 'fount-bt'
|
|
7
13
|
const MAX_ADVERT_BLOB_BYTES = 12 * 1024
|
|
14
|
+
const MAX_SIGNAL_BLOB_BYTES = 8 * 1024
|
|
8
15
|
const PERIPHERAL_RESCAN_MS = 15_000
|
|
9
16
|
|
|
10
|
-
/**
|
|
11
|
-
* 解析 Bluetooth 发现角色(scan / dual)。
|
|
12
|
-
* @returns {'scan' | 'dual'} 当前平台或环境变量指定的角色
|
|
13
|
-
*/
|
|
14
|
-
function resolveBtRole() {
|
|
15
|
-
const override = String(process.env.FOUNT_BT_DISCOVERY_ROLE || '').trim().toLowerCase()
|
|
16
|
-
if (override === 'dual') return 'dual'
|
|
17
|
-
if (override === 'scan') return 'scan'
|
|
18
|
-
return process.platform === 'win32' ? 'scan' : 'dual'
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* 加载 Noble BLE central 库。
|
|
23
|
-
* @returns {Promise<any>} Noble 运行时实例
|
|
24
|
-
*/
|
|
25
|
-
async function loadNoble() {
|
|
26
|
-
const mod = await import('@stoprocent/noble')
|
|
27
|
-
if (typeof mod.withBindings === 'function') return mod.withBindings('default')
|
|
28
|
-
return mod.default ?? mod
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* 加载 Bleno BLE peripheral 库。
|
|
33
|
-
* @returns {Promise<any>} Bleno 运行时实例
|
|
34
|
-
*/
|
|
35
|
-
async function loadBleno() {
|
|
36
|
-
const mod = await import('@stoprocent/bleno')
|
|
37
|
-
if (typeof mod.withBindings === 'function') return mod.withBindings('default')
|
|
38
|
-
return mod.default ?? mod
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* 等待 BLE 运行时进入 poweredOn(兼容 noble v1/v2 与 bleno)。
|
|
43
|
-
* @param {*} runtime noble 或 bleno 实例
|
|
44
|
-
* @param {number} [timeout] 超时毫秒
|
|
45
|
-
* @returns {Promise<void>}
|
|
46
|
-
*/
|
|
47
|
-
export async function waitPoweredOn(runtime, timeout) {
|
|
48
|
-
const wait = runtime.waitForPoweredOnAsync ?? runtime.waitForPoweredOn
|
|
49
|
-
if (typeof wait !== 'function')
|
|
50
|
-
throw new Error('p2p: bluetooth runtime missing waitForPoweredOn(Async)')
|
|
51
|
-
return wait.call(runtime, timeout)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
17
|
/**
|
|
55
18
|
* 探测本机 noble 运行时是否具备 BT 扫描所需 API。
|
|
56
19
|
* @returns {Promise<boolean>} noble 可加载且具备 startScanningAsync 与 waitForPoweredOn(Async) 时为 true
|
|
@@ -125,6 +88,7 @@ function addListener(bucket, topic, listener) {
|
|
|
125
88
|
* - 默认在 Windows 上只启用 scan 侧发现(单适配器 central+peripheral 常冲突)
|
|
126
89
|
* - 其他平台默认 dual:advertise + scan
|
|
127
90
|
* - 通过固定 BLE service + read characteristic 传输完整 advert 列表,避免 31-byte 广告包限制
|
|
91
|
+
* - dual 下额外暴露 write characteristic 传短信令;central 可按 peer hint 写信令
|
|
128
92
|
*
|
|
129
93
|
* @returns {import('./index.mjs').DiscoveryProvider} Bluetooth 发现提供者
|
|
130
94
|
*/
|
|
@@ -134,6 +98,8 @@ export function createBluetoothDiscoveryProvider() {
|
|
|
134
98
|
const adverts = new Map()
|
|
135
99
|
/** @type {Map<string, Set<Function>>} */
|
|
136
100
|
const advertListeners = new Map()
|
|
101
|
+
/** @type {Map<string, Set<Function>>} */
|
|
102
|
+
const signalListeners = new Map()
|
|
137
103
|
/** @type {Map<string, number>} */
|
|
138
104
|
const inspectedAt = new Map()
|
|
139
105
|
let nobleRuntime = null
|
|
@@ -149,17 +115,17 @@ export function createBluetoothDiscoveryProvider() {
|
|
|
149
115
|
if (role === 'scan') return null
|
|
150
116
|
if (blenoRuntime) return blenoRuntime
|
|
151
117
|
const bleno = await loadBleno()
|
|
152
|
-
const
|
|
118
|
+
const advertCharacteristic = new bleno.Characteristic({
|
|
153
119
|
uuid: BT_CHARACTERISTIC_UUID,
|
|
154
120
|
properties: ['read'],
|
|
155
121
|
/**
|
|
156
|
-
*
|
|
157
|
-
* @param {*}
|
|
158
|
-
* @param {number} offset
|
|
159
|
-
* @param {Function} callback
|
|
122
|
+
* stoprocent/bleno onReadRequest(connection, offset, callback)
|
|
123
|
+
* @param {*} _connection 连接句柄
|
|
124
|
+
* @param {number} offset 偏移
|
|
125
|
+
* @param {Function} callback 结果
|
|
160
126
|
* @returns {void}
|
|
161
127
|
*/
|
|
162
|
-
onReadRequest(
|
|
128
|
+
onReadRequest(_connection, offset, callback) {
|
|
163
129
|
try {
|
|
164
130
|
const blob = serializeAdvertBlob(adverts)
|
|
165
131
|
if (offset > blob.length) {
|
|
@@ -173,11 +139,39 @@ export function createBluetoothDiscoveryProvider() {
|
|
|
173
139
|
}
|
|
174
140
|
},
|
|
175
141
|
})
|
|
142
|
+
const signalCharacteristic = new bleno.Characteristic({
|
|
143
|
+
uuid: BT_SIGNAL_CHAR_UUID,
|
|
144
|
+
properties: ['write', 'writeWithoutResponse'],
|
|
145
|
+
/**
|
|
146
|
+
* stoprocent/bleno onWriteRequest(connection, data, offset, withoutResponse, callback)
|
|
147
|
+
* @param {*} _connection 连接句柄
|
|
148
|
+
* @param {Buffer} data 信令 blob
|
|
149
|
+
* @param {number} _offset 偏移
|
|
150
|
+
* @param {boolean} _withoutResponse 无响应写
|
|
151
|
+
* @param {Function} callback 结果
|
|
152
|
+
* @returns {void}
|
|
153
|
+
*/
|
|
154
|
+
onWriteRequest(_connection, data, _offset, _withoutResponse, callback) {
|
|
155
|
+
try {
|
|
156
|
+
const parsed = JSON.parse(Buffer.from(data).toString('utf8'))
|
|
157
|
+
const topic = String(parsed?.topic || '')
|
|
158
|
+
const bytes = Uint8Array.from(Buffer.from(String(parsed?.data || ''), 'base64'))
|
|
159
|
+
if (topic && bytes.byteLength)
|
|
160
|
+
for (const listener of signalListeners.get(topic) || [])
|
|
161
|
+
listener(bytes, { provider: 'bt' })
|
|
162
|
+
|
|
163
|
+
callback(bleno.Characteristic.RESULT_SUCCESS)
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
callback(bleno.Characteristic.RESULT_UNLIKELY_ERROR)
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
})
|
|
176
170
|
await waitPoweredOn(bleno, 5_000)
|
|
177
171
|
await bleno.setServicesAsync([
|
|
178
172
|
new bleno.PrimaryService({
|
|
179
173
|
uuid: BT_SERVICE_UUID,
|
|
180
|
-
characteristics: [
|
|
174
|
+
characteristics: [advertCharacteristic, signalCharacteristic],
|
|
181
175
|
}),
|
|
182
176
|
])
|
|
183
177
|
blenoRuntime = bleno
|
|
@@ -192,14 +186,14 @@ export function createBluetoothDiscoveryProvider() {
|
|
|
192
186
|
if (role === 'scan') return
|
|
193
187
|
const bleno = await ensurePeripheralRuntime()
|
|
194
188
|
if (!bleno) return
|
|
195
|
-
if (!adverts.size) {
|
|
189
|
+
if (!adverts.size && !signalListeners.size) {
|
|
196
190
|
if (advertisingStarted) {
|
|
197
191
|
await bleno.stopAdvertisingAsync().catch(() => { })
|
|
198
192
|
advertisingStarted = false
|
|
199
193
|
}
|
|
200
194
|
return
|
|
201
195
|
}
|
|
202
|
-
serializeAdvertBlob(adverts)
|
|
196
|
+
if (adverts.size) serializeAdvertBlob(adverts)
|
|
203
197
|
if (!advertisingStarted) {
|
|
204
198
|
await bleno.startAdvertisingAsync(BT_DEVICE_NAME, [BT_SERVICE_UUID])
|
|
205
199
|
advertisingStarted = true
|
|
@@ -256,10 +250,86 @@ export function createBluetoothDiscoveryProvider() {
|
|
|
256
250
|
scanningStarted = true
|
|
257
251
|
}
|
|
258
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Central:按 peer hint 连接并对端 signal characteristic 写短包。
|
|
255
|
+
* @param {string} topic 信令 topic
|
|
256
|
+
* @param {string} to 目标 nodeHash
|
|
257
|
+
* @param {Uint8Array} bytes 载荷
|
|
258
|
+
* @returns {Promise<void>}
|
|
259
|
+
*/
|
|
260
|
+
async function sendSignalViaGatt(topic, to, bytes) {
|
|
261
|
+
const hint = getBtPeerHint(to)
|
|
262
|
+
if (!hint) throw new Error('p2p: bt signal missing peer hint')
|
|
263
|
+
const blob = Buffer.from(JSON.stringify({
|
|
264
|
+
topic: String(topic),
|
|
265
|
+
to: String(to),
|
|
266
|
+
data: Buffer.from(bytes).toString('base64'),
|
|
267
|
+
}), 'utf8')
|
|
268
|
+
if (blob.byteLength > MAX_SIGNAL_BLOB_BYTES)
|
|
269
|
+
throw new Error(`p2p: bt signal blob exceeds ${MAX_SIGNAL_BLOB_BYTES} bytes`)
|
|
270
|
+
await ensureScanRuntime()
|
|
271
|
+
const noble = nobleRuntime
|
|
272
|
+
const wantId = hint.peripheralId
|
|
273
|
+
/**
|
|
274
|
+
* 先查 noble 已缓存的 peripheral,避免只等下一次 advertise 漏报。
|
|
275
|
+
* @returns {*|null} 已缓存的 peripheral,未命中为 null
|
|
276
|
+
*/
|
|
277
|
+
function cachedPeripheral() {
|
|
278
|
+
const table = noble?._peripherals
|
|
279
|
+
if (!table || typeof table !== 'object') return null
|
|
280
|
+
for (const found of Object.values(table)) {
|
|
281
|
+
const id = String(found?.id || found?.address || '')
|
|
282
|
+
if (id === wantId) return found
|
|
283
|
+
}
|
|
284
|
+
return null
|
|
285
|
+
}
|
|
286
|
+
const peripheral = cachedPeripheral() ?? await new Promise((resolve, reject) => {
|
|
287
|
+
const deadline = setTimeout(() => {
|
|
288
|
+
cleanup()
|
|
289
|
+
reject(new Error('p2p: bt signal peripheral timeout'))
|
|
290
|
+
}, 8_000)
|
|
291
|
+
/**
|
|
292
|
+
* @returns {void}
|
|
293
|
+
*/
|
|
294
|
+
function cleanup() {
|
|
295
|
+
clearTimeout(deadline)
|
|
296
|
+
noble.removeListener('discover', onDiscover)
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* @param {*} found peripheral
|
|
300
|
+
* @returns {void}
|
|
301
|
+
*/
|
|
302
|
+
function onDiscover(found) {
|
|
303
|
+
const id = String(found?.id || found?.address || '')
|
|
304
|
+
if (id !== wantId) return
|
|
305
|
+
cleanup()
|
|
306
|
+
resolve(found)
|
|
307
|
+
}
|
|
308
|
+
noble.on('discover', onDiscover)
|
|
309
|
+
const again = cachedPeripheral()
|
|
310
|
+
if (again) {
|
|
311
|
+
cleanup()
|
|
312
|
+
resolve(again)
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
try {
|
|
316
|
+
await peripheral.connectAsync()
|
|
317
|
+
const { characteristics } = await peripheral.discoverSomeServicesAndCharacteristicsAsync(
|
|
318
|
+
[BT_SERVICE_UUID],
|
|
319
|
+
[BT_SIGNAL_CHAR_UUID],
|
|
320
|
+
)
|
|
321
|
+
if (!characteristics?.length) throw new Error('p2p: bt signal characteristic missing')
|
|
322
|
+
await characteristics[0].writeAsync(blob, false)
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
try { await peripheral.disconnectAsync() } catch { /* ignore */ }
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
259
329
|
return {
|
|
260
330
|
id: 'bt',
|
|
261
331
|
priority: 20,
|
|
262
|
-
caps: { canDiscover: true, canSignal:
|
|
332
|
+
caps: { canDiscover: true, canSignal: true, canRelay: false },
|
|
263
333
|
/**
|
|
264
334
|
* 广播指定 topic 的 advert。
|
|
265
335
|
* @param {string} topic advert 主题
|
|
@@ -285,5 +355,31 @@ export function createBluetoothDiscoveryProvider() {
|
|
|
285
355
|
await ensureScanRuntime()
|
|
286
356
|
return addListener(advertListeners, String(topic), onAdvert)
|
|
287
357
|
},
|
|
358
|
+
/**
|
|
359
|
+
* 经 GATT 向近场 peer 发送信令。
|
|
360
|
+
* @param {string} topic 信令 topic
|
|
361
|
+
* @param {string} to 目标 nodeHash
|
|
362
|
+
* @param {Uint8Array} bytes 载荷
|
|
363
|
+
* @returns {Promise<void>}
|
|
364
|
+
*/
|
|
365
|
+
async sendSignal(topic, to, bytes) {
|
|
366
|
+
await sendSignalViaGatt(topic, to, bytes)
|
|
367
|
+
},
|
|
368
|
+
/**
|
|
369
|
+
* 监听经本机 peripheral signal characteristic 写入的信令。
|
|
370
|
+
* @param {string} topic 信令 topic
|
|
371
|
+
* @param {Function} onSignal 回调
|
|
372
|
+
* @returns {Promise<() => void>} 取消订阅
|
|
373
|
+
*/
|
|
374
|
+
async onSignal(topic, onSignal) {
|
|
375
|
+
if (role === 'scan') return () => { }
|
|
376
|
+
await ensurePeripheralRuntime()
|
|
377
|
+
await refreshAdvertising()
|
|
378
|
+
const stop = addListener(signalListeners, String(topic), onSignal)
|
|
379
|
+
return () => {
|
|
380
|
+
stop()
|
|
381
|
+
void refreshAdvertising().catch(() => { })
|
|
382
|
+
}
|
|
383
|
+
},
|
|
288
384
|
}
|
|
289
385
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { normalizeHex64 } from '../../core/hexIds.mjs'
|
|
2
|
+
import { createTtlMap } from '../../utils/ttl_map.mjs'
|
|
3
|
+
|
|
4
|
+
/** BT peer hint 存活时间。 */
|
|
5
|
+
export const BT_PEER_HINT_TTL_MS = 5 * 60_000
|
|
6
|
+
|
|
7
|
+
/** @type {ReturnType<typeof createTtlMap<{ peripheralId: string }>>} */
|
|
8
|
+
const hints = createTtlMap(BT_PEER_HINT_TTL_MS)
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 记录近场 BT 扫描到的 nodeHash → peripheral 映射。
|
|
12
|
+
* @param {string} nodeHash 节点 64 hex
|
|
13
|
+
* @param {string} peripheralId noble peripheral id / address
|
|
14
|
+
* @returns {void}
|
|
15
|
+
*/
|
|
16
|
+
export function noteBtPeerHint(nodeHash, peripheralId) {
|
|
17
|
+
const hash = normalizeHex64(nodeHash)
|
|
18
|
+
const id = String(peripheralId || '').trim()
|
|
19
|
+
if (!hash || !id) return
|
|
20
|
+
hints.set(hash, { peripheralId: id })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 查询未过期的 BT peer hint。
|
|
25
|
+
* @param {string} nodeHash 节点 64 hex
|
|
26
|
+
* @param {number} [now=Date.now()] 当前时间(测试可注入)
|
|
27
|
+
* @returns {{ peripheralId: string } | null} hint 或 null
|
|
28
|
+
*/
|
|
29
|
+
export function getBtPeerHint(nodeHash, now = Date.now()) {
|
|
30
|
+
const hash = normalizeHex64(nodeHash)
|
|
31
|
+
if (!hash) return null
|
|
32
|
+
return hints.get(hash, now)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 清空全部 BT peer hints(测试用)。
|
|
37
|
+
* @returns {void}
|
|
38
|
+
*/
|
|
39
|
+
export function clearBtPeerHints() {
|
|
40
|
+
hints.clear()
|
|
41
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import process from 'node:process'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 解析 Bluetooth 角色(scan / dual)。
|
|
5
|
+
* Win32 默认 scan(单适配器 central+peripheral 常冲突);可用 FOUNT_BT_DISCOVERY_ROLE 覆盖。
|
|
6
|
+
* @returns {'scan' | 'dual'} 生效角色
|
|
7
|
+
*/
|
|
8
|
+
export function resolveBtRole() {
|
|
9
|
+
const override = String(process.env.FOUNT_BT_DISCOVERY_ROLE || '').trim().toLowerCase()
|
|
10
|
+
if (override === 'dual') return 'dual'
|
|
11
|
+
if (override === 'scan') return 'scan'
|
|
12
|
+
return process.platform === 'win32' ? 'scan' : 'dual'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 加载 Noble BLE central。
|
|
17
|
+
* @returns {Promise<any>} noble 运行时
|
|
18
|
+
*/
|
|
19
|
+
export async function loadNoble() {
|
|
20
|
+
const mod = await import('@stoprocent/noble')
|
|
21
|
+
if (typeof mod.withBindings === 'function') return mod.withBindings('default')
|
|
22
|
+
return mod.default ?? mod
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 加载 Bleno BLE peripheral。
|
|
27
|
+
* @returns {Promise<any>} bleno 运行时
|
|
28
|
+
*/
|
|
29
|
+
export async function loadBleno() {
|
|
30
|
+
const mod = await import('@stoprocent/bleno')
|
|
31
|
+
if (typeof mod.withBindings === 'function') return mod.withBindings('default')
|
|
32
|
+
return mod.default ?? mod
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 等待 BLE 运行时 poweredOn(兼容 noble/bleno v1/v2)。
|
|
37
|
+
* @param {*} runtime noble 或 bleno
|
|
38
|
+
* @param {number} [timeout] 超时毫秒
|
|
39
|
+
* @returns {Promise<void>}
|
|
40
|
+
*/
|
|
41
|
+
export async function waitPoweredOn(runtime, timeout) {
|
|
42
|
+
const wait = runtime.waitForPoweredOnAsync ?? runtime.waitForPoweredOn
|
|
43
|
+
if (typeof wait !== 'function')
|
|
44
|
+
throw new Error('p2p: bluetooth runtime missing waitForPoweredOn(Async)')
|
|
45
|
+
return wait.call(runtime, timeout)
|
|
46
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { normalizeHex64 } from '../core/hexIds.mjs'
|
|
2
|
+
import { normalizeTcpPort } from '../core/tcp_port.mjs'
|
|
3
|
+
import { createTtlMap } from '../utils/ttl_map.mjs'
|
|
4
|
+
|
|
5
|
+
/** LAN peer hint 存活时间。 */
|
|
6
|
+
export const LAN_PEER_HINT_TTL_MS = 5 * 60_000
|
|
7
|
+
|
|
8
|
+
/** @type {ReturnType<typeof createTtlMap<{ host: string, port: number }>>} */
|
|
9
|
+
const hints = createTtlMap(LAN_PEER_HINT_TTL_MS)
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 记录 LAN 上观察到的 nodeHash → host:port。
|
|
13
|
+
* @param {string} nodeHash 节点 64 hex
|
|
14
|
+
* @param {{ host: string, port: number }} endpoint 端点
|
|
15
|
+
* @returns {void}
|
|
16
|
+
*/
|
|
17
|
+
export function noteLanPeerHint(nodeHash, endpoint) {
|
|
18
|
+
const hash = normalizeHex64(nodeHash)
|
|
19
|
+
const host = String(endpoint?.host || '').trim()
|
|
20
|
+
const port = normalizeTcpPort(endpoint?.port)
|
|
21
|
+
if (!hash || !host || !port) return
|
|
22
|
+
hints.set(hash, { host, port })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 查询未过期的 LAN peer hint。
|
|
27
|
+
* @param {string} nodeHash 节点 64 hex
|
|
28
|
+
* @param {number} [now=Date.now()] 当前时间(测试可注入)
|
|
29
|
+
* @returns {{ host: string, port: number } | null} hint 或 null
|
|
30
|
+
*/
|
|
31
|
+
export function getLanPeerHint(nodeHash, now = Date.now()) {
|
|
32
|
+
const hash = normalizeHex64(nodeHash)
|
|
33
|
+
if (!hash) return null
|
|
34
|
+
return hints.get(hash, now)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 清空全部 LAN peer hints(测试用)。
|
|
39
|
+
* @returns {void}
|
|
40
|
+
*/
|
|
41
|
+
export function clearLanPeerHints() {
|
|
42
|
+
hints.clear()
|
|
43
|
+
}
|
package/discovery/mdns.mjs
CHANGED
|
@@ -38,7 +38,7 @@ export function createMdnsDiscoveryProvider(options = {}) {
|
|
|
38
38
|
resolve()
|
|
39
39
|
})
|
|
40
40
|
})
|
|
41
|
-
socket.on('message', raw => {
|
|
41
|
+
socket.on('message', (raw, rinfo) => {
|
|
42
42
|
let packet
|
|
43
43
|
try { packet = JSON.parse(String(raw)) } catch { return }
|
|
44
44
|
const listeners = packet.type === 'advert'
|
|
@@ -46,8 +46,12 @@ export function createMdnsDiscoveryProvider(options = {}) {
|
|
|
46
46
|
: signalListeners.get(String(packet.topic || ''))
|
|
47
47
|
if (!listeners?.size) return
|
|
48
48
|
const bytes = Uint8Array.from(Buffer.from(String(packet.data || ''), 'base64'))
|
|
49
|
+
const meta = {
|
|
50
|
+
provider: 'mdns',
|
|
51
|
+
address: String(rinfo?.address || ''),
|
|
52
|
+
}
|
|
49
53
|
for (const listener of listeners)
|
|
50
|
-
listener(bytes,
|
|
54
|
+
listener(bytes, meta)
|
|
51
55
|
})
|
|
52
56
|
bound = true
|
|
53
57
|
})()
|
package/index.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Federation P2P
|
|
3
|
-
*
|
|
2
|
+
* Federation P2P 门面:fount 网络引导与房间/发现入口。
|
|
3
|
+
* 上层只面对 nodeHash + envelope,不选择 WebRTC/BLE 等传输。
|
|
4
|
+
* 重型子系统请从子路径导入(如 `./dag`);勿导入未导出的 `link/`。
|
|
4
5
|
*/
|
|
5
6
|
import { registerDiscoveryProvider } from './discovery/index.mjs'
|
|
6
7
|
import { ensureNodeDefaults, getNodeHash } from './node/identity.mjs'
|
package/link/handshake.mjs
CHANGED
|
@@ -2,34 +2,50 @@ import { Buffer } from 'node:buffer'
|
|
|
2
2
|
import { randomBytes } from 'node:crypto'
|
|
3
3
|
|
|
4
4
|
import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
|
|
5
|
+
import { normalizeTcpPort } from '../core/tcp_port.mjs'
|
|
5
6
|
import { keyPairFromSeed, pubKeyHash, sign, verify } from '../crypto/crypto.mjs'
|
|
6
7
|
import { ensureNodeSeed, getNodeHash } from '../node/identity.mjs'
|
|
7
8
|
|
|
8
9
|
import { normalizeDtlsFingerprint } from './sdp_fingerprint.mjs'
|
|
9
10
|
|
|
11
|
+
/** advert / peer-hint 端口规范化(re-export,调用方可直接从 core/tcp_port 导入) */
|
|
12
|
+
export const normalizeAdvertTcpPort = normalizeTcpPort
|
|
13
|
+
|
|
10
14
|
/**
|
|
11
15
|
* Link 握手签名域标识符。
|
|
12
16
|
*/
|
|
13
17
|
export const LINK_HANDSHAKE_DOMAIN = 'fount-link'
|
|
14
18
|
|
|
19
|
+
/**
|
|
20
|
+
* 规范化链路绑定材料:DTLS fingerprint 或 64-hex linkId。
|
|
21
|
+
* @param {unknown} value 原始 binding
|
|
22
|
+
* @returns {string | null} 规范化 binding,无效时 null
|
|
23
|
+
*/
|
|
24
|
+
export function normalizeLinkBinding(value) {
|
|
25
|
+
const dtls = normalizeDtlsFingerprint(value)
|
|
26
|
+
if (dtls) return dtls
|
|
27
|
+
const hex = normalizeHex64(value)
|
|
28
|
+
return isHex64(hex) ? hex : null
|
|
29
|
+
}
|
|
30
|
+
|
|
15
31
|
/**
|
|
16
32
|
* 构造 link auth 待签名字节串。
|
|
17
33
|
* @param {string} peerNonce 对端 hello 中的 nonce(64 位 hex)
|
|
18
|
-
* @param {string}
|
|
34
|
+
* @param {string} localBinding 本地绑定材料(DTLS fingerprint 或 linkId)
|
|
19
35
|
* @param {string} localNodeHash 本地节点 nodeHash(64 位 hex)
|
|
20
36
|
* @returns {Uint8Array} 待签名消息字节
|
|
21
37
|
*/
|
|
22
|
-
export function buildAuthMessage(peerNonce,
|
|
38
|
+
export function buildAuthMessage(peerNonce, localBinding, localNodeHash) {
|
|
23
39
|
const nonce = normalizeHex64(peerNonce)
|
|
24
|
-
const
|
|
40
|
+
const binding = normalizeLinkBinding(localBinding)
|
|
25
41
|
const nodeHash = normalizeHex64(localNodeHash)
|
|
26
42
|
if (!/^[\da-f]{64}$/u.test(nonce))
|
|
27
43
|
throw new Error('p2p: auth nonce must be 64 hex characters')
|
|
28
|
-
if (!
|
|
29
|
-
throw new Error('p2p:
|
|
44
|
+
if (!binding)
|
|
45
|
+
throw new Error('p2p: link binding missing or invalid')
|
|
30
46
|
if (!isHex64(nodeHash))
|
|
31
47
|
throw new Error('p2p: nodeHash must be 64 hex characters')
|
|
32
|
-
return Buffer.from(`${LINK_HANDSHAKE_DOMAIN}\0${nonce}\0${
|
|
48
|
+
return Buffer.from(`${LINK_HANDSHAKE_DOMAIN}\0${nonce}\0${binding}\0${nodeHash}`, 'utf8')
|
|
33
49
|
}
|
|
34
50
|
|
|
35
51
|
/**
|
|
@@ -56,11 +72,11 @@ export function buildHello(options = {}) {
|
|
|
56
72
|
/**
|
|
57
73
|
* 对 link auth 消息签名。
|
|
58
74
|
* @param {string} peerNonce 对端 hello 中的 nonce
|
|
59
|
-
* @param {string}
|
|
75
|
+
* @param {string} localBinding 本地绑定材料(DTLS fingerprint 或 linkId)
|
|
60
76
|
* @param {{ secretKey?: Uint8Array, nodeHash?: string }} [options] 签名密钥与 nodeHash 覆盖
|
|
61
77
|
* @returns {Promise<{ sig: string }>} hex 签名
|
|
62
78
|
*/
|
|
63
|
-
export async function buildAuth(peerNonce,
|
|
79
|
+
export async function buildAuth(peerNonce, localBinding, options = {}) {
|
|
64
80
|
const seed = options.secretKey
|
|
65
81
|
? Buffer.from(options.secretKey)
|
|
66
82
|
: Buffer.from(ensureNodeSeed(), 'hex')
|
|
@@ -68,7 +84,7 @@ export async function buildAuth(peerNonce, localFingerprint, options = {}) {
|
|
|
68
84
|
const nodeHash = normalizeHex64(options.nodeHash || pubKeyHash(publicKey))
|
|
69
85
|
if (nodeHash !== pubKeyHash(publicKey))
|
|
70
86
|
throw new Error('p2p: auth nodeHash does not match secretKey')
|
|
71
|
-
const message = buildAuthMessage(peerNonce,
|
|
87
|
+
const message = buildAuthMessage(peerNonce, localBinding, nodeHash)
|
|
72
88
|
const signature = await sign(message, secretKey)
|
|
73
89
|
return { sig: Buffer.from(signature).toString('hex') }
|
|
74
90
|
}
|
|
@@ -98,18 +114,18 @@ export function parseHello(hello) {
|
|
|
98
114
|
* @param {unknown} hello 对端 hello
|
|
99
115
|
* @param {unknown} auth 对端 auth(含 sig)
|
|
100
116
|
* @param {string} expectedNonce 本地 hello 发出的 nonce
|
|
101
|
-
* @param {string}
|
|
117
|
+
* @param {string} remoteBinding 对端绑定材料(DTLS fingerprint 或 linkId)
|
|
102
118
|
* @returns {Promise<string | null>} 验证通过的 nodeHash,失败返回 null
|
|
103
119
|
*/
|
|
104
|
-
export async function verifyAuth(hello, auth, expectedNonce,
|
|
120
|
+
export async function verifyAuth(hello, auth, expectedNonce, remoteBinding) {
|
|
105
121
|
const parsedHello = parseHello(hello)
|
|
106
122
|
if (!parsedHello) return null
|
|
107
123
|
const signatureHex = String(auth?.sig ?? '').trim().toLowerCase()
|
|
108
|
-
const
|
|
109
|
-
if (!/^[\da-f]{128}$/u.test(signatureHex) || !
|
|
124
|
+
const binding = normalizeLinkBinding(remoteBinding)
|
|
125
|
+
if (!/^[\da-f]{128}$/u.test(signatureHex) || !binding) return null
|
|
110
126
|
const normalizedNonce = normalizeHex64(expectedNonce)
|
|
111
127
|
if (!isHex64(normalizedNonce)) return null
|
|
112
|
-
const message = buildAuthMessage(normalizedNonce,
|
|
128
|
+
const message = buildAuthMessage(normalizedNonce, binding, parsedHello.nodeHash)
|
|
113
129
|
const ok = await verify(
|
|
114
130
|
Buffer.from(signatureHex, 'hex'),
|
|
115
131
|
message,
|
|
@@ -123,18 +139,21 @@ export async function verifyAuth(hello, auth, expectedNonce, remoteFingerprintFr
|
|
|
123
139
|
* @param {string} topic 广播主题
|
|
124
140
|
* @param {number} ts 时间戳(毫秒)
|
|
125
141
|
* @param {string} nodeHash 节点 nodeHash
|
|
142
|
+
* @param {number | null} [tcpPort=null] 可选 LAN TCP 监听端口(签入消息)
|
|
126
143
|
* @returns {Uint8Array} 待签名消息字节
|
|
127
144
|
*/
|
|
128
|
-
export function buildAdvertMessage(topic, ts, nodeHash) {
|
|
129
|
-
|
|
145
|
+
export function buildAdvertMessage(topic, ts, nodeHash, tcpPort = null) {
|
|
146
|
+
const base = `fount-advert\0${String(topic)}\0${String(ts)}\0${normalizeHex64(nodeHash)}`
|
|
147
|
+
const port = normalizeTcpPort(tcpPort)
|
|
148
|
+
return Buffer.from(port == null ? base : `${base}\0${port}`, 'utf8')
|
|
130
149
|
}
|
|
131
150
|
|
|
132
151
|
/**
|
|
133
152
|
* 构造带签名的 discovery advert。
|
|
134
153
|
* @param {string} topic 广播主题
|
|
135
154
|
* @param {number} [ts=Date.now()] 时间戳(毫秒)
|
|
136
|
-
* @param {{ secretKey?: Uint8Array, nodeHash?: string, nodePubKey?: string } | null} [options]
|
|
137
|
-
* @returns {Promise<{ nodeHash: string, nodePubKey: string, ts: number, sig: string }>} 签名 advert
|
|
155
|
+
* @param {{ secretKey?: Uint8Array, nodeHash?: string, nodePubKey?: string, tcpPort?: number } | null} [options] 签名身份与可选 tcpPort
|
|
156
|
+
* @returns {Promise<{ nodeHash: string, nodePubKey: string, ts: number, sig: string, tcpPort?: number }>} 签名 advert
|
|
138
157
|
*/
|
|
139
158
|
export async function buildSignedAdvert(topic, ts = Date.now(), options = null) {
|
|
140
159
|
const seed = options?.secretKey
|
|
@@ -145,14 +164,19 @@ export async function buildSignedAdvert(topic, ts = Date.now(), options = null)
|
|
|
145
164
|
const nodePubKey = normalizeHex64(options?.nodePubKey || Buffer.from(publicKey).toString('hex'))
|
|
146
165
|
if (pubKeyHash(Buffer.from(nodePubKey, 'hex')) !== nodeHash)
|
|
147
166
|
throw new Error('p2p: advert nodePubKey does not match nodeHash')
|
|
148
|
-
const
|
|
167
|
+
const tcpPort = normalizeTcpPort(options?.tcpPort)
|
|
168
|
+
if (options?.tcpPort != null && options.tcpPort !== '' && tcpPort == null)
|
|
169
|
+
throw new Error('p2p: advert tcpPort invalid')
|
|
170
|
+
const message = buildAdvertMessage(topic, ts, nodeHash, tcpPort)
|
|
149
171
|
const sig = await sign(message, secretKey)
|
|
150
|
-
|
|
172
|
+
const advert = {
|
|
151
173
|
nodeHash,
|
|
152
174
|
nodePubKey,
|
|
153
175
|
ts,
|
|
154
176
|
sig: Buffer.from(sig).toString('hex'),
|
|
155
177
|
}
|
|
178
|
+
if (tcpPort != null) advert.tcpPort = tcpPort
|
|
179
|
+
return advert
|
|
156
180
|
}
|
|
157
181
|
|
|
158
182
|
/**
|
|
@@ -169,7 +193,10 @@ export async function verifySignedAdvert(topic, advert, now = Date.now(), maxSke
|
|
|
169
193
|
const ts = Number(advert?.ts)
|
|
170
194
|
const sig = String(advert?.sig ?? '').trim().toLowerCase()
|
|
171
195
|
if (!Number.isFinite(ts) || Math.abs(now - ts) > maxSkewMs || !/^[\da-f]{128}$/u.test(sig)) return null
|
|
172
|
-
const
|
|
196
|
+
const hasTcpPortField = advert?.tcpPort != null && advert.tcpPort !== ''
|
|
197
|
+
const tcpPort = normalizeTcpPort(advert?.tcpPort)
|
|
198
|
+
if (hasTcpPortField && tcpPort == null) return null
|
|
199
|
+
const message = buildAdvertMessage(topic, ts, parsedHello.nodeHash, tcpPort)
|
|
173
200
|
const ok = await verify(Buffer.from(sig, 'hex'), message, Buffer.from(parsedHello.nodePubKey, 'hex'))
|
|
174
201
|
return ok ? parsedHello.nodeHash : null
|
|
175
202
|
}
|