@steve02081504/fount-p2p 0.0.6 → 0.0.8
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} +159 -55
- package/discovery/bt/peer_hints.mjs +41 -0
- package/discovery/bt/runtime.mjs +46 -0
- package/discovery/index.mjs +9 -10
- 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,94 @@ 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 },
|
|
333
|
+
/**
|
|
334
|
+
* 是否具备对该 peer 的 GATT 信令能力(需未过期 BT peer hint)。
|
|
335
|
+
* @param {string} to 目标 nodeHash
|
|
336
|
+
* @returns {boolean} 有 hint 则可尝试
|
|
337
|
+
*/
|
|
338
|
+
canSignalTo(to) {
|
|
339
|
+
return !!getBtPeerHint(to)
|
|
340
|
+
},
|
|
263
341
|
/**
|
|
264
342
|
* 广播指定 topic 的 advert。
|
|
265
343
|
* @param {string} topic advert 主题
|
|
@@ -285,5 +363,31 @@ export function createBluetoothDiscoveryProvider() {
|
|
|
285
363
|
await ensureScanRuntime()
|
|
286
364
|
return addListener(advertListeners, String(topic), onAdvert)
|
|
287
365
|
},
|
|
366
|
+
/**
|
|
367
|
+
* 经 GATT 向近场 peer 发送信令。
|
|
368
|
+
* @param {string} topic 信令 topic
|
|
369
|
+
* @param {string} to 目标 nodeHash
|
|
370
|
+
* @param {Uint8Array} bytes 载荷
|
|
371
|
+
* @returns {Promise<void>}
|
|
372
|
+
*/
|
|
373
|
+
async sendSignal(topic, to, bytes) {
|
|
374
|
+
await sendSignalViaGatt(topic, to, bytes)
|
|
375
|
+
},
|
|
376
|
+
/**
|
|
377
|
+
* 监听经本机 peripheral signal characteristic 写入的信令。
|
|
378
|
+
* @param {string} topic 信令 topic
|
|
379
|
+
* @param {Function} onSignal 回调
|
|
380
|
+
* @returns {Promise<() => void>} 取消订阅
|
|
381
|
+
*/
|
|
382
|
+
async onSignal(topic, onSignal) {
|
|
383
|
+
if (role === 'scan') return () => { }
|
|
384
|
+
await ensurePeripheralRuntime()
|
|
385
|
+
await refreshAdvertising()
|
|
386
|
+
const stop = addListener(signalListeners, String(topic), onSignal)
|
|
387
|
+
return () => {
|
|
388
|
+
stop()
|
|
389
|
+
void refreshAdvertising().catch(() => { })
|
|
390
|
+
}
|
|
391
|
+
},
|
|
288
392
|
}
|
|
289
393
|
}
|
|
@@ -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
|
+
}
|
package/discovery/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** @typedef {{ id: string, priority: number, caps: { canDiscover?: boolean, canSignal?: boolean, canRelay?: boolean }, advertise?: (topic: string, bytes: Uint8Array) => (() => void) | Promise<() => void>, subscribe?: (topic: string, onAdvert: (bytes: Uint8Array, meta?: object) => void) => (() => void) | Promise<() => void>, sendSignal?: (topic: string, to: string, bytes: Uint8Array) => void | Promise<void>, onSignal?: (topic: string, onSignal: (bytes: Uint8Array, meta?: object) => void) => (() => void) | Promise<() => void> }} DiscoveryProvider */
|
|
1
|
+
/** @typedef {{ id: string, priority: number, caps: { canDiscover?: boolean, canSignal?: boolean, canRelay?: boolean }, advertise?: (topic: string, bytes: Uint8Array) => (() => void) | Promise<() => void>, subscribe?: (topic: string, onAdvert: (bytes: Uint8Array, meta?: object) => void) => (() => void) | Promise<() => void>, canSignalTo?: (to: string) => boolean, sendSignal?: (topic: string, to: string, bytes: Uint8Array) => void | Promise<void>, onSignal?: (topic: string, onSignal: (bytes: Uint8Array, meta?: object) => void) => (() => void) | Promise<() => void> }} DiscoveryProvider */
|
|
2
2
|
|
|
3
3
|
/** @type {Map<string, DiscoveryProvider>} */
|
|
4
4
|
const providers = new Map()
|
|
@@ -95,15 +95,14 @@ export async function sendSignal(topic, to, bytes) {
|
|
|
95
95
|
if (!capable.length) throw new Error('p2p: no discovery provider can signal')
|
|
96
96
|
let sent = false
|
|
97
97
|
let lastError = null
|
|
98
|
-
for (const provider of capable)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
98
|
+
for (const provider of capable) if (provider?.canSignalTo?.(to)) try {
|
|
99
|
+
await Promise.resolve(provider.sendSignal(topic, to, bytes))
|
|
100
|
+
sent = true
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
lastError = error
|
|
104
|
+
console.warn(`p2p: discovery signal failed for ${provider.id}`, error)
|
|
105
|
+
}
|
|
107
106
|
if (!sent) throw lastError || new Error('p2p: no discovery provider delivered signal')
|
|
108
107
|
}
|
|
109
108
|
|
|
@@ -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'
|