@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.
@@ -0,0 +1,339 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import { randomBytes } from 'node:crypto'
3
+
4
+ import { normalizeHex64 } from '../../core/hexIds.mjs'
5
+ import { getBtPeerHint } from '../../discovery/bt/peer_hints.mjs'
6
+ import { loadBleno, loadNoble, resolveBtRole, waitPoweredOn } from '../../discovery/bt/runtime.mjs'
7
+ import { asLinkHandle, coercePipeInbound, createLinkPipe } from '../pipe.mjs'
8
+
9
+ import { LINK_LEVEL_BLE_GATT } from './levels.mjs'
10
+
11
+ /** BLE GATT 数据 service UUID。 */
12
+ export const BLE_DATA_SERVICE_UUID = 'f017f017f017f017f017f017f017f019'
13
+ /** BLE GATT 数据 characteristic UUID。 */
14
+ export const BLE_DATA_CHAR_UUID = 'f017f017f017f017f017f017f017f01a'
15
+ const BT_DEVICE_NAME = 'fount-bt'
16
+
17
+ let cachedAvailable = null
18
+
19
+ /**
20
+ * 探测 BLE GATT 数据链路是否可用(至少 noble central)。
21
+ * @returns {Promise<boolean>} 可用为 true
22
+ */
23
+ export async function canUseBleGattLink() {
24
+ if (cachedAvailable !== null) return cachedAvailable
25
+ try {
26
+ const noble = await loadNoble()
27
+ const wait = noble.waitForPoweredOnAsync ?? noble.waitForPoweredOn
28
+ cachedAvailable = typeof noble.startScanningAsync === 'function' && typeof wait === 'function'
29
+ }
30
+ catch {
31
+ cachedAvailable = false
32
+ }
33
+ return cachedAvailable
34
+ }
35
+
36
+ /**
37
+ * 在 GATT write/notify 上建立 pipe。
38
+ * @param {object} options 配置
39
+ * @returns {Promise<import('./index.mjs').LinkHandle>} 已启动握手的 link
40
+ */
41
+ async function openGattPipe(options) {
42
+ const linkId = normalizeHex64(options.linkId)
43
+ if (!linkId) throw new Error('p2p: ble_gatt linkId required')
44
+
45
+ const pipe = createLinkPipe({
46
+ providerId: 'ble_gatt',
47
+ level: LINK_LEVEL_BLE_GATT,
48
+ initiator: !!options.initiator,
49
+ nodeHash: options.nodeHash,
50
+ localIdentity: options.localIdentity,
51
+ /** @returns {string} 本端 binding(linkId) */
52
+ getLocalBinding: () => linkId,
53
+ /** @returns {string} 对端 binding(linkId) */
54
+ getRemoteBinding: () => linkId,
55
+ /**
56
+ * @param {string} text control JSON
57
+ * @returns {Promise<void>}
58
+ */
59
+ async sendControlText(text) {
60
+ await Promise.resolve(options.write(Buffer.from(text, 'utf8')))
61
+ },
62
+ /**
63
+ * @param {string} _action action
64
+ * @param {Uint8Array} frame 帧
65
+ * @returns {Promise<void>}
66
+ */
67
+ async sendFrame(_action, frame) {
68
+ await Promise.resolve(options.write(Buffer.from(frame)))
69
+ },
70
+ closeTransport: options.closeTransport,
71
+ })
72
+
73
+ const stopNotify = options.onNotify(data => {
74
+ pipe.handleInbound(coercePipeInbound(data))
75
+ })
76
+ pipe.onDown(() => {
77
+ try { stopNotify() } catch { /* ignore */ }
78
+ })
79
+
80
+ if (options.initiator)
81
+ await Promise.resolve(options.write(Buffer.from(JSON.stringify({
82
+ type: 'link-open',
83
+ linkId,
84
+ from: options.localIdentity?.nodeHash || '',
85
+ }), 'utf8')))
86
+
87
+ await pipe.startHandshake()
88
+ return asLinkHandle(pipe)
89
+ }
90
+
91
+ /**
92
+ * Central dial。
93
+ * @param {object} options dial 选项
94
+ * @returns {Promise<import('./index.mjs').LinkHandle>} 已就绪的 link
95
+ */
96
+ async function dialBleGatt(options) {
97
+ const remoteNodeHash = normalizeHex64(options.nodeHash)
98
+ const hint = getBtPeerHint(remoteNodeHash)
99
+ if (!hint) throw new Error('p2p: ble_gatt no peer hint')
100
+ const noble = await loadNoble()
101
+ await waitPoweredOn(noble, 5_000)
102
+
103
+ const peripheral = await new Promise((resolve, reject) => {
104
+ const deadline = setTimeout(() => {
105
+ cleanup()
106
+ reject(new Error('p2p: ble_gatt peripheral scan timeout'))
107
+ }, 8_000)
108
+ /**
109
+ * @returns {void}
110
+ */
111
+ function cleanup() {
112
+ clearTimeout(deadline)
113
+ noble.removeListener('discover', onDiscover)
114
+ void noble.stopScanningAsync?.().catch(() => { })
115
+ }
116
+ /**
117
+ * @param {*} found peripheral
118
+ * @returns {void}
119
+ */
120
+ function onDiscover(found) {
121
+ const id = String(found?.id || found?.address || '')
122
+ if (id !== hint.peripheralId) return
123
+ cleanup()
124
+ resolve(found)
125
+ }
126
+ noble.on('discover', onDiscover)
127
+ void noble.startScanningAsync([BLE_DATA_SERVICE_UUID], true).catch(reject)
128
+ })
129
+
130
+ await peripheral.connectAsync()
131
+ const { characteristics } = await peripheral.discoverSomeServicesAndCharacteristicsAsync(
132
+ [BLE_DATA_SERVICE_UUID],
133
+ [BLE_DATA_CHAR_UUID],
134
+ )
135
+ const characteristic = characteristics?.[0]
136
+ if (!characteristic) {
137
+ try { await peripheral.disconnectAsync() } catch { /* ignore */ }
138
+ throw new Error('p2p: ble_gatt data characteristic missing')
139
+ }
140
+
141
+ const linkId = randomBytes(32).toString('hex')
142
+ /** @type {Set<(data: Buffer) => void>} */
143
+ const notifyHandlers = new Set()
144
+ characteristic.on('data', data => {
145
+ if (data == null) return
146
+ for (const handler of notifyHandlers)
147
+ handler(Buffer.from(data))
148
+ })
149
+ await characteristic.subscribeAsync()
150
+
151
+ const link = await openGattPipe({
152
+ initiator: true,
153
+ linkId,
154
+ nodeHash: remoteNodeHash,
155
+ localIdentity: options.localIdentity,
156
+ /**
157
+ * @param {Buffer} data 出站
158
+ * @returns {Promise<void>}
159
+ */
160
+ async write(data) {
161
+ await characteristic.writeAsync(data, false)
162
+ },
163
+ /**
164
+ * @param {(data: Buffer) => void} handler notify 回调
165
+ * @returns {() => void} 取消订阅
166
+ */
167
+ onNotify(handler) {
168
+ notifyHandlers.add(handler)
169
+ return () => notifyHandlers.delete(handler)
170
+ },
171
+ /**
172
+ * @returns {Promise<void>}
173
+ */
174
+ async closeTransport() {
175
+ try { await characteristic.unsubscribeAsync() } catch { /* ignore */ }
176
+ try { await peripheral.disconnectAsync() } catch { /* ignore */ }
177
+ },
178
+ })
179
+ await link.ready
180
+ return link
181
+ }
182
+
183
+ /**
184
+ * 创建 ble_gatt LinkProvider。
185
+ * 每个实例独立;注册 id 唯一,避免同进程多 registry 互相覆盖 onInbound/localIdentity。
186
+ * 链路上的 `providerId` 仍为 `ble_gatt`。
187
+ * 注意:本机只有一块 BLE 适配器时,多实例仍会争用同一 bleno peripheral(生产应一进程一节点)。
188
+ * @returns {import('./index.mjs').LinkProvider & { ensureListening?: Function }} BLE GATT provider
189
+ */
190
+ export function createBleGattLinkProvider() {
191
+ const instanceId = `ble_gatt:${randomBytes(4).toString('hex')}`
192
+ const role = resolveBtRole()
193
+ /** @type {((link: import('./index.mjs').LinkHandle) => void) | null} */
194
+ let onInbound = null
195
+ /** @type {object | null} */
196
+ let localIdentity = null
197
+ /** @type {any} */
198
+ let blenoRuntime = null
199
+ /** @type {any} */
200
+ let dataCharacteristic = null
201
+ let listening = false
202
+ /** @type {import('./index.mjs').LinkHandle | null} */
203
+ let activeInbound = null
204
+ /** @type {((data: Buffer) => void) | null} */
205
+ let sessionInbound = null
206
+ let acceptInflight = false
207
+
208
+ /**
209
+ * @returns {Promise<void>}
210
+ */
211
+ async function ensurePeripheral() {
212
+ if (role === 'scan' || listening) return
213
+ const bleno = await loadBleno()
214
+ const characteristic = new bleno.Characteristic({
215
+ uuid: BLE_DATA_CHAR_UUID,
216
+ properties: ['write', 'writeWithoutResponse', 'notify'],
217
+ /**
218
+ * stoprocent/bleno onWriteRequest(connection, data, offset, withoutResponse, callback)
219
+ * @param {*} _connection 连接句柄
220
+ * @param {Buffer} data 写入
221
+ * @param {number} _offset 偏移
222
+ * @param {boolean} _withoutResponse 无响应写
223
+ * @param {Function} callback 结果
224
+ * @returns {void}
225
+ */
226
+ onWriteRequest(_connection, data, _offset, _withoutResponse, callback) {
227
+ const buf = Buffer.from(data)
228
+ if (sessionInbound) sessionInbound(buf)
229
+ else void acceptPeripheralWrite(buf).catch(() => { })
230
+ callback(bleno.Characteristic.RESULT_SUCCESS)
231
+ },
232
+ })
233
+
234
+ await waitPoweredOn(bleno, 5_000)
235
+ await bleno.setServicesAsync([
236
+ new bleno.PrimaryService({
237
+ uuid: BLE_DATA_SERVICE_UUID,
238
+ characteristics: [characteristic],
239
+ }),
240
+ ])
241
+ await bleno.startAdvertisingAsync(BT_DEVICE_NAME, [BLE_DATA_SERVICE_UUID])
242
+ blenoRuntime = bleno
243
+ dataCharacteristic = characteristic
244
+ listening = true
245
+ }
246
+
247
+ /**
248
+ * @param {Buffer} buf 入站写(首包应为 link-open)
249
+ * @returns {Promise<void>}
250
+ */
251
+ async function acceptPeripheralWrite(buf) {
252
+ if (activeInbound || acceptInflight || !onInbound || !localIdentity) return
253
+ let parsed
254
+ try {
255
+ parsed = JSON.parse(Buffer.from(buf).toString('utf8'))
256
+ }
257
+ catch {
258
+ return
259
+ }
260
+ if (parsed?.type !== 'link-open' || !parsed.linkId) return
261
+ acceptInflight = true
262
+ try {
263
+ const link = await openGattPipe({
264
+ initiator: false,
265
+ linkId: parsed.linkId,
266
+ nodeHash: normalizeHex64(parsed.from) || null,
267
+ localIdentity,
268
+ /**
269
+ * @param {Buffer} data 出站
270
+ * @returns {void}
271
+ */
272
+ write(data) {
273
+ dataCharacteristic?.notify(data)
274
+ },
275
+ /**
276
+ * @param {(data: Buffer) => void} handler pipe 入站
277
+ * @returns {() => void} 取消订阅
278
+ */
279
+ onNotify(handler) {
280
+ sessionInbound = handler
281
+ return () => { sessionInbound = null }
282
+ },
283
+ /**
284
+ * @returns {void}
285
+ */
286
+ closeTransport() {
287
+ activeInbound = null
288
+ sessionInbound = null
289
+ acceptInflight = false
290
+ },
291
+ })
292
+ activeInbound = link
293
+ onInbound(link)
294
+ }
295
+ catch {
296
+ sessionInbound = null
297
+ acceptInflight = false
298
+ }
299
+ }
300
+
301
+ return {
302
+ id: instanceId,
303
+ level: LINK_LEVEL_BLE_GATT,
304
+ caps: { needsOfferAnswer: false, needsDiscoverySignal: false },
305
+ isAvailable: canUseBleGattLink,
306
+ /**
307
+ * @param {{ nodeHash: string }} remote 远端
308
+ * @returns {boolean} 是否有 BT peer hint
309
+ */
310
+ canReach(remote) {
311
+ return !!getBtPeerHint(remote.nodeHash)
312
+ },
313
+ /**
314
+ * @param {object} options dial 选项
315
+ * @returns {Promise<import('./index.mjs').LinkHandle>} 已就绪的 link
316
+ */
317
+ async dial(options) {
318
+ return dialBleGatt(options)
319
+ },
320
+ /**
321
+ * @param {{ onInbound: (link: import('./index.mjs').LinkHandle) => void, localIdentity: object }} handlers 回调
322
+ * @returns {Promise<() => void>} 停止 listening
323
+ */
324
+ async ensureListening(handlers) {
325
+ onInbound = handlers.onInbound
326
+ localIdentity = handlers.localIdentity
327
+ if (role === 'scan') return () => { onInbound = null }
328
+ await ensurePeripheral()
329
+ return () => {
330
+ onInbound = null
331
+ if (blenoRuntime) {
332
+ void blenoRuntime.stopAdvertisingAsync?.().catch(() => { })
333
+ listening = false
334
+ dataCharacteristic = null
335
+ }
336
+ }
337
+ },
338
+ }
339
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * 上层(rooms / federation / shell)可见的链路句柄。
3
+ * 不暴露 RTC DataChannel、ICE、GATT 等传输细节;providerId/level/initiator 仅供包内择链。
4
+ * @typedef {{
5
+ * ready: Promise<void>,
6
+ * get nodeHash(): string | null,
7
+ * send: (envelope: { scope: string, action: string, payload: unknown }) => Promise<boolean>,
8
+ * onEnvelope: (callback: (envelope: { scope: string, action: string, payload: unknown }, remoteNodeHash: string) => void) => () => void,
9
+ * onDown: (callback: (reason: string) => void) => () => void,
10
+ * close: (reason?: string) => Promise<void>,
11
+ * stats: () => object,
12
+ * }} LinkHandle */
13
+
14
+ /**
15
+ * @typedef {{
16
+ * id: string,
17
+ * level: number,
18
+ * caps?: { needsOfferAnswer?: boolean, needsDiscoverySignal?: boolean },
19
+ * isAvailable: () => boolean | Promise<boolean>,
20
+ * canReach?: (remote: { nodeHash: string, hints?: object }) => boolean | Promise<boolean>,
21
+ * dial: (options: object) => Promise<LinkHandle | null>,
22
+ * accept?: (options: object) => Promise<LinkHandle | null>,
23
+ * ensureListening?: (handlers: { onInbound: (link: LinkHandle) => void, localIdentity: object }) => Promise<(() => void) | void> | (() => void) | void,
24
+ * localEndpoint?: () => { host?: string, port?: number } | null,
25
+ * }} LinkProvider
26
+ */
27
+
28
+ /** @type {Map<string, LinkProvider>} */
29
+ const providers = new Map()
30
+
31
+ /**
32
+ * 注册 link provider。
33
+ * @param {LinkProvider} provider 链路提供者
34
+ * @returns {() => void} 注销函数
35
+ */
36
+ export function registerLinkProvider(provider) {
37
+ if (!provider?.id) throw new Error('p2p: link provider requires id')
38
+ providers.set(String(provider.id), provider)
39
+ return () => unregisterLinkProvider(provider.id)
40
+ }
41
+
42
+ /**
43
+ * 注销 link provider。
44
+ * @param {string} id 提供者 id
45
+ * @returns {void}
46
+ */
47
+ export function unregisterLinkProvider(id) {
48
+ providers.delete(String(id))
49
+ }
50
+
51
+ /**
52
+ * 列出已注册的 link provider(按 level 降序)。
53
+ * @returns {LinkProvider[]} 提供者列表
54
+ */
55
+ export function listLinkProviders() {
56
+ return [...providers.values()].sort((left, right) => Number(right.level || 0) - Number(left.level || 0))
57
+ }
58
+
59
+ /**
60
+ * 清空全部 link provider(测试用)。
61
+ * @returns {void}
62
+ */
63
+ export function clearLinkProviders() {
64
+ providers.clear()
65
+ }
66
+
67
+ /**
68
+ * 列出当前可用的 link provider(isAvailable 失败视为不可用)。
69
+ * @returns {Promise<LinkProvider[]>} 按 level 降序的可用列表
70
+ */
71
+ export async function listAvailableLinkProviders() {
72
+ const available = []
73
+ for (const provider of listLinkProviders())
74
+ try {
75
+ if (await Promise.resolve(provider.isAvailable()))
76
+ available.push(provider)
77
+ }
78
+ catch {
79
+ /* probe failure → skip */
80
+ }
81
+
82
+ return available
83
+ }
84
+
85
+ /** 重导出 level 常量。 */
86
+ export {
87
+ LINK_LEVEL_LAN_TCP,
88
+ LINK_LEVEL_WEBRTC,
89
+ LINK_LEVEL_BLE_GATT,
90
+ } from './levels.mjs'