@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
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
import { normalizeHex64 } from '../core/hexIds.mjs'
|
|
4
|
+
import { sendSignal } from '../discovery/index.mjs'
|
|
5
|
+
import { listLinkProviders } from '../link/providers/index.mjs'
|
|
6
|
+
|
|
7
|
+
import { decryptSignalPacket, encryptSignalPacket, nodeRendezvousTopic } from './signal_crypto.mjs'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 创建带 backlog 的缓冲信令会话。
|
|
11
|
+
* @param {(message: unknown) => Promise<void>} sendRemote 远端发送回调
|
|
12
|
+
* @returns {{ send: (message: unknown) => Promise<void>, onRemote: (handler: (message: unknown) => void) => () => void, deliver: (message: unknown) => void, clear: () => void }} 信令会话
|
|
13
|
+
*/
|
|
14
|
+
export function createBufferedSignalSession(sendRemote) {
|
|
15
|
+
/** @type {Set<(message: unknown) => void>} */
|
|
16
|
+
const handlers = new Set()
|
|
17
|
+
/** @type {unknown[]} */
|
|
18
|
+
const backlog = []
|
|
19
|
+
return {
|
|
20
|
+
/**
|
|
21
|
+
* 发送信令消息到远端。
|
|
22
|
+
* @param {unknown} message 信令消息
|
|
23
|
+
* @returns {Promise<void>}
|
|
24
|
+
*/
|
|
25
|
+
async send(message) {
|
|
26
|
+
await sendRemote(message)
|
|
27
|
+
},
|
|
28
|
+
/**
|
|
29
|
+
* 注册远端信令 handler(含 backlog 回放)。
|
|
30
|
+
* @param {(message: unknown) => void} handler 入站回调
|
|
31
|
+
* @returns {() => void} 取消订阅函数
|
|
32
|
+
*/
|
|
33
|
+
onRemote(handler) {
|
|
34
|
+
handlers.add(handler)
|
|
35
|
+
for (const pending of backlog.splice(0))
|
|
36
|
+
handler(pending)
|
|
37
|
+
return () => handlers.delete(handler)
|
|
38
|
+
},
|
|
39
|
+
/**
|
|
40
|
+
* 投递信令消息;无 handler 时入 backlog。
|
|
41
|
+
* @param {unknown} message 信令消息
|
|
42
|
+
* @returns {void}
|
|
43
|
+
*/
|
|
44
|
+
deliver(message) {
|
|
45
|
+
if (!handlers.size) {
|
|
46
|
+
backlog.push(message)
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
for (const handler of handlers)
|
|
50
|
+
handler(message)
|
|
51
|
+
},
|
|
52
|
+
/**
|
|
53
|
+
* 清空 backlog 与 handler。
|
|
54
|
+
* @returns {void}
|
|
55
|
+
*/
|
|
56
|
+
clear() {
|
|
57
|
+
backlog.length = 0
|
|
58
|
+
handlers.clear()
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 判断信令 body 是否为发起方 offer(据此决定入站是否新建应答 PC)。
|
|
65
|
+
* @param {unknown} body 信令 body
|
|
66
|
+
* @returns {boolean} 是 offer 则 true
|
|
67
|
+
*/
|
|
68
|
+
function isOfferSignalBody(body) {
|
|
69
|
+
return !!body && typeof body === 'object'
|
|
70
|
+
&& body.type === 'description'
|
|
71
|
+
&& body.description?.type === 'offer'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 找已注册的 offer/answer provider(按 level 降序的第一个)。
|
|
76
|
+
* @returns {import('../link/providers/index.mjs').LinkProvider | null} 命中的 provider;无则 null
|
|
77
|
+
*/
|
|
78
|
+
export function findOfferAnswerProvider() {
|
|
79
|
+
return listLinkProviders().find(provider => provider.caps?.needsOfferAnswer) ?? null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Offer/answer glare 拨号控制器(WebRTC 等 needsOfferAnswer provider)。
|
|
84
|
+
* @param {object} deps 依赖注入
|
|
85
|
+
* @param {{ nodeHash: string, nodePubKey: string, secretKey: Uint8Array }} deps.localIdentity 本地身份
|
|
86
|
+
* @param {RTCConfiguration['iceServers']} deps.iceServers ICE 服务器列表
|
|
87
|
+
* @param {string} deps.selfTopic 本机 rendezvous topic
|
|
88
|
+
* @param {Map<string, ReturnType<typeof createBufferedSignalSession>>} deps.signalSessions 按 connId 索引的信令会话
|
|
89
|
+
* @param {(remoteNodeHash: string, link: object) => Promise<void>} deps.registerResolvedLink 注册规范链
|
|
90
|
+
* @param {() => Promise<void>} deps.trimToBudget 超预算驱逐
|
|
91
|
+
* @param {(remoteNodeHash: string) => object | null | undefined} deps.getCanonicalLink 读取当前规范链
|
|
92
|
+
* @returns {{ handleIncomingSignal: (bytes: Uint8Array) => Promise<void>, dialOfferAnswer: (provider: import('../link/providers/index.mjs').LinkProvider, remoteNodeHash: string) => Promise<object | null> }} 入站信令与主动拨号
|
|
93
|
+
*/
|
|
94
|
+
export function createOfferAnswerDial(deps) {
|
|
95
|
+
const {
|
|
96
|
+
localIdentity,
|
|
97
|
+
iceServers,
|
|
98
|
+
selfTopic,
|
|
99
|
+
signalSessions,
|
|
100
|
+
registerResolvedLink,
|
|
101
|
+
trimToBudget,
|
|
102
|
+
getCanonicalLink,
|
|
103
|
+
} = deps
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 为单条 PC 创建按 connId 标记的信令会话。
|
|
107
|
+
* @param {string} remoteNodeHash 远端节点 64 hex
|
|
108
|
+
* @param {string} connId 连接标识
|
|
109
|
+
* @returns {ReturnType<typeof createBufferedSignalSession>} 信令会话
|
|
110
|
+
*/
|
|
111
|
+
function createConnSession(remoteNodeHash, connId) {
|
|
112
|
+
const normalized = normalizeHex64(remoteNodeHash)
|
|
113
|
+
const topic = nodeRendezvousTopic(normalized)
|
|
114
|
+
return createBufferedSignalSession(async message => {
|
|
115
|
+
await sendSignal(topic, normalized, encryptSignalPacket(topic, {
|
|
116
|
+
type: 'signal',
|
|
117
|
+
from: localIdentity.nodeHash,
|
|
118
|
+
connId,
|
|
119
|
+
body: message,
|
|
120
|
+
}))
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Offer/answer provider:为一条 connId 会话建链并走 glare 择一。
|
|
126
|
+
* @param {{ provider: import('../link/providers/index.mjs').LinkProvider, remoteNodeHash: string, connId: string, session: ReturnType<typeof createBufferedSignalSession>, initiator: boolean }} options 建链参数
|
|
127
|
+
* @returns {Promise<object | null>} 当前规范链;失败 null
|
|
128
|
+
*/
|
|
129
|
+
async function buildConnLink({ provider, remoteNodeHash, connId, session, initiator }) {
|
|
130
|
+
try {
|
|
131
|
+
if (initiator) await trimToBudget()
|
|
132
|
+
const link = await (initiator ? provider.dial : provider.accept)({
|
|
133
|
+
nodeHash: remoteNodeHash,
|
|
134
|
+
signal: session,
|
|
135
|
+
iceServers,
|
|
136
|
+
localIdentity,
|
|
137
|
+
})
|
|
138
|
+
if (!link) {
|
|
139
|
+
signalSessions.delete(connId)
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
link.onDown(() => signalSessions.delete(connId))
|
|
143
|
+
await link.ready
|
|
144
|
+
await registerResolvedLink(remoteNodeHash, link)
|
|
145
|
+
return getCanonicalLink(remoteNodeHash) ?? null
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
signalSessions.delete(connId)
|
|
149
|
+
return null
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 处理入站加密信令并可能发起被动建链。
|
|
155
|
+
* @param {Uint8Array} bytes 加密信令字节
|
|
156
|
+
* @returns {Promise<void>}
|
|
157
|
+
*/
|
|
158
|
+
async function handleIncomingSignal(bytes) {
|
|
159
|
+
const packet = decryptSignalPacket(selfTopic, bytes)
|
|
160
|
+
if (!packet || packet.type !== 'signal') return
|
|
161
|
+
const remoteNodeHash = normalizeHex64(packet.from)
|
|
162
|
+
const connId = String(packet.connId || '')
|
|
163
|
+
if (!remoteNodeHash || remoteNodeHash === localIdentity.nodeHash || !connId) return
|
|
164
|
+
let session = signalSessions.get(connId)
|
|
165
|
+
if (!session) {
|
|
166
|
+
if (!isOfferSignalBody(packet.body)) return
|
|
167
|
+
const provider = findOfferAnswerProvider()
|
|
168
|
+
if (!provider) return
|
|
169
|
+
session = createConnSession(remoteNodeHash, connId)
|
|
170
|
+
signalSessions.set(connId, session)
|
|
171
|
+
void buildConnLink({ provider, remoteNodeHash, connId, session, initiator: false })
|
|
172
|
+
}
|
|
173
|
+
session.deliver(packet.body)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 经 offer/answer provider 拨号(discovery signal + glare)。
|
|
178
|
+
* @param {import('../link/providers/index.mjs').LinkProvider} provider 链路提供者
|
|
179
|
+
* @param {string} remoteNodeHash 远端 64 hex
|
|
180
|
+
* @returns {Promise<object | null>} 当前规范链;失败 null
|
|
181
|
+
*/
|
|
182
|
+
async function dialOfferAnswer(provider, remoteNodeHash) {
|
|
183
|
+
const connId = randomBytes(16).toString('hex')
|
|
184
|
+
const session = createConnSession(remoteNodeHash, connId)
|
|
185
|
+
signalSessions.set(connId, session)
|
|
186
|
+
return await buildConnLink({ provider, remoteNodeHash, connId, session, initiator: true })
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { handleIncomingSignal, dialOfferAnswer }
|
|
190
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { normalizeTcpPort } from '../core/tcp_port.mjs'
|
|
2
|
+
import { advertiseTopic, listenSignals, listDiscoveryProviders, registerDiscoveryProvider } from '../discovery/index.mjs'
|
|
3
|
+
import { createMdnsDiscoveryProvider } from '../discovery/mdns.mjs'
|
|
4
|
+
import { mergeSignalingRelayUrls, createNostrDiscoveryProvider } from '../discovery/nostr.mjs'
|
|
5
|
+
import { buildSignedAdvert } from '../link/handshake.mjs'
|
|
6
|
+
import { createBleGattLinkProvider } from '../link/providers/ble_gatt.mjs'
|
|
7
|
+
import {
|
|
8
|
+
listLinkProviders,
|
|
9
|
+
registerLinkProvider,
|
|
10
|
+
unregisterLinkProvider,
|
|
11
|
+
} from '../link/providers/index.mjs'
|
|
12
|
+
import { createLanTcpLinkProvider } from '../link/providers/lan_tcp.mjs'
|
|
13
|
+
import { createWebRtcLinkProvider } from '../link/providers/webrtc.mjs'
|
|
14
|
+
import { getNodeTransportSettings } from '../node/identity.mjs'
|
|
15
|
+
import { getSignalingRuntimeConfig } from '../node/instance.mjs'
|
|
16
|
+
|
|
17
|
+
import { encryptSignalPacket } from './signal_crypto.mjs'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Provider 是否会在 isAvailable() 调用时触发 native/异步探测(不得在 ensureRuntime 快路径调用)。
|
|
21
|
+
* @param {import('../link/providers/index.mjs').LinkProvider} provider 链路提供者
|
|
22
|
+
* @returns {boolean} `caps.probe === 'native'` 时 true
|
|
23
|
+
*/
|
|
24
|
+
export function providerHasNativeProbe(provider) {
|
|
25
|
+
return provider.caps?.probe === 'native'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 收集快路径应 listen 的 provider(仅 owned lan_tcp + 同步可用者)。
|
|
30
|
+
* 切勿调用 caps.probe==='native' 的 isAvailable(会触发 ble/webrtc 探测)。
|
|
31
|
+
* @param {import('../link/providers/index.mjs').LinkProvider | null} ownedLanTcp 本 registry 持有的 lan_tcp
|
|
32
|
+
* @returns {import('../link/providers/index.mjs').LinkProvider[]} 可在 ensureRuntime 快路径 listen 的 provider
|
|
33
|
+
*/
|
|
34
|
+
export function collectFastListenProviders(ownedLanTcp) {
|
|
35
|
+
/** @type {import('../link/providers/index.mjs').LinkProvider[]} */
|
|
36
|
+
const listenProviders = []
|
|
37
|
+
if (ownedLanTcp) listenProviders.push(ownedLanTcp)
|
|
38
|
+
for (const provider of listLinkProviders()) {
|
|
39
|
+
const id = String(provider.id)
|
|
40
|
+
if (id.startsWith('lan_tcp') || id.startsWith('ble_gatt')) continue
|
|
41
|
+
if (typeof provider.ensureListening !== 'function') continue
|
|
42
|
+
if (providerHasNativeProbe(provider)) continue
|
|
43
|
+
if (typeof provider.isAvailable === 'function') {
|
|
44
|
+
try {
|
|
45
|
+
const available = provider.isAvailable()
|
|
46
|
+
// 未标 probe:native 却返回 thenable:跳过,勿 await。
|
|
47
|
+
if (available && typeof available.then === 'function') continue
|
|
48
|
+
if (!available) continue
|
|
49
|
+
}
|
|
50
|
+
catch { continue }
|
|
51
|
+
}
|
|
52
|
+
listenProviders.push(provider)
|
|
53
|
+
}
|
|
54
|
+
return listenProviders
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Discovery + link listen 暖机(ensureRuntime 立即返回;listen/公网/BT 后台渐进)。
|
|
59
|
+
* @param {object} deps 依赖注入
|
|
60
|
+
* @param {{ nodeHash: string, nodePubKey: string, secretKey: Uint8Array }} deps.localIdentity 本地身份
|
|
61
|
+
* @param {string} deps.selfTopic 本机 rendezvous topic
|
|
62
|
+
* @param {boolean} deps.autoRegisterDiscoveryProviders 是否自动注册 discovery provider
|
|
63
|
+
* @param {boolean} deps.autoRegisterLinkProviders 是否自动注册内置 link provider
|
|
64
|
+
* @param {(link: object) => void} deps.onInboundLink 入站链路回调
|
|
65
|
+
* @param {(bytes: Uint8Array) => Promise<void>} deps.handleIncomingSignal 入站加密信令处理
|
|
66
|
+
* @returns {{
|
|
67
|
+
* ensureRuntime: () => Promise<void>,
|
|
68
|
+
* whenListening: () => Promise<void>,
|
|
69
|
+
* whenSignalListening: () => Promise<void>,
|
|
70
|
+
* buildLocalAdvert: (topic: string) => Promise<object>,
|
|
71
|
+
* lanTcpPort: () => number | null,
|
|
72
|
+
* ownedLanTcp: () => object | null,
|
|
73
|
+
* ownedBleGatt: () => object | null,
|
|
74
|
+
* shutdown: () => Promise<void>,
|
|
75
|
+
* }} 运行时暖机句柄
|
|
76
|
+
*/
|
|
77
|
+
export function createRuntimeBootstrap(deps) {
|
|
78
|
+
const {
|
|
79
|
+
localIdentity,
|
|
80
|
+
selfTopic,
|
|
81
|
+
autoRegisterDiscoveryProviders,
|
|
82
|
+
autoRegisterLinkProviders,
|
|
83
|
+
onInboundLink,
|
|
84
|
+
handleIncomingSignal,
|
|
85
|
+
} = deps
|
|
86
|
+
|
|
87
|
+
let runtimeStarted = false
|
|
88
|
+
/** @type {Promise<void> | null} */
|
|
89
|
+
let runtimeStart = null
|
|
90
|
+
/** @type {Promise<void> | null} */
|
|
91
|
+
let lanListenReady = null
|
|
92
|
+
/** @type {Promise<void> | null} selfTopic listenSignals 已挂上(offer/answer dial 前必须等) */
|
|
93
|
+
let signalListenReady = null
|
|
94
|
+
/** @type {Promise<void> | null} */
|
|
95
|
+
let runtimeWarm = null
|
|
96
|
+
/** @type {Promise<void> | null} */
|
|
97
|
+
let bluetoothWarm = null
|
|
98
|
+
/** @type {(() => void) | null} */
|
|
99
|
+
let stopAdvert = null
|
|
100
|
+
/** @type {(() => void) | null} */
|
|
101
|
+
let stopSignalListener = null
|
|
102
|
+
/** @type {Array<() => void>} */
|
|
103
|
+
const stopLinkListeners = []
|
|
104
|
+
/** @type {ReturnType<typeof createLanTcpLinkProvider> | null} */
|
|
105
|
+
let ownedLanTcp = null
|
|
106
|
+
/** @type {ReturnType<typeof createBleGattLinkProvider> | null} */
|
|
107
|
+
let ownedBleGatt = null
|
|
108
|
+
/** @type {number} shutdown 时递增,后台任务检查后退出 */
|
|
109
|
+
let generation = 0
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 暖机是否仍在运行(shutdown 后为 false)。
|
|
113
|
+
* @returns {boolean} runtime 已启动且未 shutdown 时 true
|
|
114
|
+
*/
|
|
115
|
+
function isLive() {
|
|
116
|
+
return runtimeStarted
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 自动注册默认 LinkProvider(lan_tcp / webrtc / ble_gatt)。
|
|
121
|
+
* @returns {Promise<void>}
|
|
122
|
+
*/
|
|
123
|
+
async function ensureLinkProviders() {
|
|
124
|
+
if (!autoRegisterLinkProviders) return
|
|
125
|
+
if (!ownedLanTcp) {
|
|
126
|
+
ownedLanTcp = createLanTcpLinkProvider()
|
|
127
|
+
registerLinkProvider(ownedLanTcp)
|
|
128
|
+
}
|
|
129
|
+
if (!ownedBleGatt) {
|
|
130
|
+
ownedBleGatt = createBleGattLinkProvider()
|
|
131
|
+
registerLinkProvider(ownedBleGatt)
|
|
132
|
+
}
|
|
133
|
+
const ids = new Set(listLinkProviders().map(provider => provider.id))
|
|
134
|
+
if (!ids.has('webrtc'))
|
|
135
|
+
registerLinkProvider(createWebRtcLinkProvider())
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 本 registry 的 LAN TCP 监听端口。
|
|
140
|
+
* @returns {number | null} listen 端口;未 listen 为 null
|
|
141
|
+
*/
|
|
142
|
+
function lanTcpPort() {
|
|
143
|
+
const endpoint = typeof ownedLanTcp?.localEndpoint === 'function' ? ownedLanTcp.localEndpoint() : null
|
|
144
|
+
return normalizeTcpPort(endpoint?.port)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 等待本地 lan_tcp listen 落定。shell / startNode 不应调用。
|
|
149
|
+
* @returns {Promise<void>}
|
|
150
|
+
*/
|
|
151
|
+
async function whenListening() {
|
|
152
|
+
if (lanListenReady) await lanListenReady.catch(() => { })
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 等待 selfTopic 信令监听挂上(needsOfferAnswer dial 前调用)。
|
|
157
|
+
* @returns {Promise<void>}
|
|
158
|
+
*/
|
|
159
|
+
async function whenSignalListening() {
|
|
160
|
+
if (signalListenReady) await signalListenReady.catch(() => { })
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 构造带本机身份与(若已 listen)tcpPort 的签名 advert。
|
|
165
|
+
* @param {string} topic 广播主题
|
|
166
|
+
* @returns {Promise<object>} 签名 advert
|
|
167
|
+
*/
|
|
168
|
+
async function buildLocalAdvert(topic) {
|
|
169
|
+
await whenListening()
|
|
170
|
+
const tcpPort = lanTcpPort()
|
|
171
|
+
return await buildSignedAdvert(topic, Date.now(), {
|
|
172
|
+
...localIdentity,
|
|
173
|
+
...tcpPort != null ? { tcpPort } : {},
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 对单个 link provider 调用 ensureListening,收集 stop。
|
|
179
|
+
* @param {import('../link/providers/index.mjs').LinkProvider} provider 链路提供者
|
|
180
|
+
* @returns {Promise<void>}
|
|
181
|
+
*/
|
|
182
|
+
async function startProviderListening(provider) {
|
|
183
|
+
if (typeof provider.ensureListening !== 'function') return
|
|
184
|
+
try {
|
|
185
|
+
const stop = await provider.ensureListening({
|
|
186
|
+
localIdentity,
|
|
187
|
+
onInbound: onInboundLink,
|
|
188
|
+
})
|
|
189
|
+
if (typeof stop === 'function') stopLinkListeners.push(stop)
|
|
190
|
+
}
|
|
191
|
+
catch { /* provider listen unavailable — normal degrade */ }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 把晚注册 provider 的 stop 链到现有 cleanup。
|
|
196
|
+
* @param {'signal' | 'advert'} kind 停止钩子类型
|
|
197
|
+
* @param {() => void} stop 新 provider 的取消函数
|
|
198
|
+
* @returns {void}
|
|
199
|
+
*/
|
|
200
|
+
function chainStop(kind, stop) {
|
|
201
|
+
if (kind === 'signal') {
|
|
202
|
+
const prev = stopSignalListener
|
|
203
|
+
stopSignalListener = () => {
|
|
204
|
+
try { stop() } catch { /* ignore */ }
|
|
205
|
+
prev?.()
|
|
206
|
+
}
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
const prev = stopAdvert
|
|
210
|
+
stopAdvert = () => {
|
|
211
|
+
try { stop() } catch { /* ignore */ }
|
|
212
|
+
prev?.()
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 后台探测 BT discovery / ble_gatt listen。
|
|
218
|
+
* @param {number} gen 启动世代
|
|
219
|
+
* @returns {Promise<void>}
|
|
220
|
+
*/
|
|
221
|
+
async function warmBluetoothTask(gen) {
|
|
222
|
+
if (autoRegisterDiscoveryProviders) {
|
|
223
|
+
const providerIds = new Set(listDiscoveryProviders().map(provider => provider.id))
|
|
224
|
+
if (!providerIds.has('bt')) {
|
|
225
|
+
const bt = await import('../discovery/bt/index.mjs').catch(() => null)
|
|
226
|
+
if (generation !== gen || !isLive()) return
|
|
227
|
+
if (await bt?.canUseBluetoothDiscovery?.()) {
|
|
228
|
+
if (generation !== gen || !isLive()) return
|
|
229
|
+
const provider = bt.createBluetoothDiscoveryProvider()
|
|
230
|
+
registerDiscoveryProvider(provider)
|
|
231
|
+
if (generation !== gen || !isLive()) return
|
|
232
|
+
if (provider.caps?.canSignal && typeof provider.onSignal === 'function')
|
|
233
|
+
try {
|
|
234
|
+
const stop = await provider.onSignal(selfTopic, bytes => {
|
|
235
|
+
void handleIncomingSignal(bytes).catch(() => { })
|
|
236
|
+
})
|
|
237
|
+
if (typeof stop === 'function' && generation === gen && isLive())
|
|
238
|
+
chainStop('signal', stop)
|
|
239
|
+
}
|
|
240
|
+
catch { /* ignore */ }
|
|
241
|
+
if (provider.caps?.canDiscover && typeof provider.advertise === 'function')
|
|
242
|
+
try {
|
|
243
|
+
const stop = await provider.advertise(selfTopic, encryptSignalPacket(selfTopic, {
|
|
244
|
+
type: 'advert',
|
|
245
|
+
body: await buildLocalAdvert(selfTopic),
|
|
246
|
+
}))
|
|
247
|
+
if (typeof stop === 'function' && generation === gen && isLive())
|
|
248
|
+
chainStop('advert', stop)
|
|
249
|
+
}
|
|
250
|
+
catch { /* ignore */ }
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (generation !== gen || !isLive()) return
|
|
255
|
+
if (ownedBleGatt && await Promise.resolve(ownedBleGatt.isAvailable())) {
|
|
256
|
+
if (generation !== gen || !isLive()) return
|
|
257
|
+
await startProviderListening(ownedBleGatt)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* 后台:lan_tcp listen → selfTopic listenSignals/advertise。
|
|
263
|
+
* @param {number} gen 启动世代(shutdown 时递增以作废后台任务)
|
|
264
|
+
* @returns {Promise<void>}
|
|
265
|
+
*/
|
|
266
|
+
function warmListenAndDiscovery(gen) {
|
|
267
|
+
const listenProviders = collectFastListenProviders(ownedLanTcp)
|
|
268
|
+
// 同步挂上 Promise,避免 ensureRuntime 返回后 buildLocalAdvert 看不到 lanListenReady。
|
|
269
|
+
lanListenReady = Promise.all(listenProviders.map(provider => startProviderListening(provider))).then(() => { })
|
|
270
|
+
signalListenReady = (async () => {
|
|
271
|
+
await lanListenReady.catch(() => { })
|
|
272
|
+
if (generation !== gen || !isLive()) return
|
|
273
|
+
if (!listDiscoveryProviders().length) return
|
|
274
|
+
stopSignalListener = await listenSignals(selfTopic, bytes => {
|
|
275
|
+
void handleIncomingSignal(bytes).catch(() => { })
|
|
276
|
+
})
|
|
277
|
+
})()
|
|
278
|
+
return (async () => {
|
|
279
|
+
await signalListenReady.catch(() => { })
|
|
280
|
+
if (generation !== gen || !isLive()) return
|
|
281
|
+
if (!listDiscoveryProviders().length) return
|
|
282
|
+
stopAdvert = await advertiseTopic(selfTopic, encryptSignalPacket(selfTopic, {
|
|
283
|
+
type: 'advert',
|
|
284
|
+
body: await buildLocalAdvert(selfTopic),
|
|
285
|
+
}))
|
|
286
|
+
})()
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* 仅注册 provider 并调度后台暖机后立即返回。
|
|
291
|
+
* @returns {Promise<void>}
|
|
292
|
+
*/
|
|
293
|
+
async function ensureRuntime() {
|
|
294
|
+
if (runtimeStarted) return
|
|
295
|
+
if (runtimeStart) return await runtimeStart
|
|
296
|
+
runtimeStart = (async () => {
|
|
297
|
+
runtimeStarted = true
|
|
298
|
+
const gen = generation
|
|
299
|
+
await ensureLinkProviders()
|
|
300
|
+
if (autoRegisterDiscoveryProviders) {
|
|
301
|
+
const providerIds = new Set(listDiscoveryProviders().map(provider => provider.id))
|
|
302
|
+
if (!providerIds.has('mdns'))
|
|
303
|
+
registerDiscoveryProvider(createMdnsDiscoveryProvider())
|
|
304
|
+
if (!providerIds.has('nostr'))
|
|
305
|
+
registerDiscoveryProvider(createNostrDiscoveryProvider({
|
|
306
|
+
relayUrls: getSignalingRuntimeConfig().relayOverride
|
|
307
|
+
?? mergeSignalingRelayUrls(getNodeTransportSettings().relayUrls),
|
|
308
|
+
}))
|
|
309
|
+
}
|
|
310
|
+
bluetoothWarm = warmBluetoothTask(gen).catch(() => { })
|
|
311
|
+
runtimeWarm = warmListenAndDiscovery(gen)
|
|
312
|
+
void runtimeWarm.catch(() => { })
|
|
313
|
+
})()
|
|
314
|
+
try {
|
|
315
|
+
await runtimeStart
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
runtimeStart = null
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* 停止暖机与 owned providers(链路表由 registry 自行清理)。
|
|
324
|
+
* 不 await bluetoothWarm:noble/poweredOn 可能挂死;generation 已作废其后副作用。
|
|
325
|
+
* @returns {Promise<void>}
|
|
326
|
+
*/
|
|
327
|
+
async function shutdown() {
|
|
328
|
+
runtimeStarted = false
|
|
329
|
+
generation++
|
|
330
|
+
await runtimeWarm?.catch(() => { })
|
|
331
|
+
stopAdvert?.()
|
|
332
|
+
stopSignalListener?.()
|
|
333
|
+
stopAdvert = null
|
|
334
|
+
stopSignalListener = null
|
|
335
|
+
for (const stop of stopLinkListeners.splice(0))
|
|
336
|
+
try { stop() } catch { /* ignore */ }
|
|
337
|
+
if (ownedLanTcp) {
|
|
338
|
+
unregisterLinkProvider(ownedLanTcp.id)
|
|
339
|
+
ownedLanTcp = null
|
|
340
|
+
}
|
|
341
|
+
if (ownedBleGatt) {
|
|
342
|
+
unregisterLinkProvider(ownedBleGatt.id)
|
|
343
|
+
ownedBleGatt = null
|
|
344
|
+
}
|
|
345
|
+
lanListenReady = null
|
|
346
|
+
signalListenReady = null
|
|
347
|
+
runtimeWarm = null
|
|
348
|
+
bluetoothWarm = null
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
ensureRuntime,
|
|
353
|
+
whenListening,
|
|
354
|
+
whenSignalListening,
|
|
355
|
+
buildLocalAdvert,
|
|
356
|
+
lanTcpPort,
|
|
357
|
+
/** @returns {ReturnType<typeof createLanTcpLinkProvider> | null} 本 registry 持有的 lan_tcp */
|
|
358
|
+
ownedLanTcp: () => ownedLanTcp,
|
|
359
|
+
/** @returns {ReturnType<typeof createBleGattLinkProvider> | null} 本 registry 持有的 ble_gatt */
|
|
360
|
+
ownedBleGatt: () => ownedBleGatt,
|
|
361
|
+
shutdown,
|
|
362
|
+
}
|
|
363
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
import { normalizeHex64 } from '../core/hexIds.mjs'
|
|
5
|
+
import { sha256Hex } from '../crypto/crypto.mjs'
|
|
6
|
+
|
|
7
|
+
const SIGNAL_DOMAIN = 'fount-signal'
|
|
8
|
+
const NODE_TOPIC_DOMAIN = 'fount-rdv-node:'
|
|
9
|
+
const GROUP_TOPIC_DOMAIN = 'fount-rdv-group:'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 由 nodeHash 派生节点 rendezvous topic。
|
|
13
|
+
* @param {string} nodeHash 节点 64 hex
|
|
14
|
+
* @returns {string} rendezvous topic 哈希
|
|
15
|
+
*/
|
|
16
|
+
export function nodeRendezvousTopic(nodeHash) {
|
|
17
|
+
return sha256Hex(`${NODE_TOPIC_DOMAIN}${normalizeHex64(nodeHash)}`)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 由房间密钥派生群组 rendezvous topic。
|
|
22
|
+
* @param {string} roomSecret 房间密钥
|
|
23
|
+
* @returns {string} rendezvous topic 哈希
|
|
24
|
+
*/
|
|
25
|
+
export function groupRendezvousTopic(roomSecret) {
|
|
26
|
+
return sha256Hex(`${GROUP_TOPIC_DOMAIN}${String(roomSecret || '')}`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 由 topic 派生信令 AES 密钥。
|
|
31
|
+
* @param {string} topic rendezvous 主题
|
|
32
|
+
* @returns {Buffer} AES-256 密钥
|
|
33
|
+
*/
|
|
34
|
+
function signalKeyForTopic(topic) {
|
|
35
|
+
return createHash('sha256').update(`${SIGNAL_DOMAIN}:${String(topic)}`).digest()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 加密信令包为 AES-GCM 字节序列。
|
|
40
|
+
* @param {string} topic rendezvous 主题
|
|
41
|
+
* @param {unknown} packet 待加密 JSON 对象
|
|
42
|
+
* @returns {Uint8Array} 加密后的字节
|
|
43
|
+
*/
|
|
44
|
+
export function encryptSignalPacket(topic, packet) {
|
|
45
|
+
const iv = randomBytes(12)
|
|
46
|
+
const cipher = createCipheriv('aes-256-gcm', signalKeyForTopic(topic), iv)
|
|
47
|
+
const ciphertext = Buffer.concat([
|
|
48
|
+
cipher.update(Buffer.from(JSON.stringify(packet), 'utf8')),
|
|
49
|
+
cipher.final(),
|
|
50
|
+
])
|
|
51
|
+
return Buffer.from(JSON.stringify({
|
|
52
|
+
iv: iv.toString('base64'),
|
|
53
|
+
authTag: cipher.getAuthTag().toString('base64'),
|
|
54
|
+
ciphertext: ciphertext.toString('base64'),
|
|
55
|
+
}))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 解密信令包;失败时返回 null。
|
|
60
|
+
* @param {string} topic rendezvous 主题
|
|
61
|
+
* @param {Uint8Array} bytes 加密字节
|
|
62
|
+
* @returns {object | null} 解密后的 JSON 对象
|
|
63
|
+
*/
|
|
64
|
+
export function decryptSignalPacket(topic, bytes) {
|
|
65
|
+
try {
|
|
66
|
+
const payload = JSON.parse(Buffer.from(bytes).toString('utf8'))
|
|
67
|
+
const decipher = createDecipheriv(
|
|
68
|
+
'aes-256-gcm',
|
|
69
|
+
signalKeyForTopic(topic),
|
|
70
|
+
Buffer.from(payload.iv, 'base64'),
|
|
71
|
+
)
|
|
72
|
+
decipher.setAuthTag(Buffer.from(payload.authTag, 'base64'))
|
|
73
|
+
const plain = Buffer.concat([
|
|
74
|
+
decipher.update(Buffer.from(payload.ciphertext, 'base64')),
|
|
75
|
+
decipher.final(),
|
|
76
|
+
])
|
|
77
|
+
return JSON.parse(plain.toString('utf8'))
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
}
|