@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.
@@ -0,0 +1,385 @@
1
+ import { getSignalingRuntimeConfig } from '../../node/instance.mjs'
2
+ import { ms } from '../../utils/duration.mjs'
3
+ import { createLruMap } from '../../utils/lru.mjs'
4
+ import {
5
+ CHANNEL_BULK,
6
+ CHANNEL_CONTROL,
7
+ CHANNEL_LOW_THRESHOLD_BYTES,
8
+ configureBufferedAmountLowThreshold,
9
+ createChannelSendQueues,
10
+ onBufferedAmountLow,
11
+ } from '../channel_mux.mjs'
12
+ import { asLinkHandle, createLinkPipe } from '../pipe.mjs'
13
+ import {
14
+ attachDataChannelListener,
15
+ attachIceCandidateListener,
16
+ loadNodeRtcPolyfill,
17
+ signalDataToBytes,
18
+ waitForChannelState,
19
+ } from '../rtc.mjs'
20
+ import { extractDtlsFingerprint } from '../sdp_fingerprint.mjs'
21
+
22
+ import { LINK_LEVEL_WEBRTC } from './levels.mjs'
23
+
24
+ /**
25
+ * 将错误对象格式化为短字符串。
26
+ * @param {unknown} error 原始错误
27
+ * @returns {string} 短 reason
28
+ */
29
+ function formatErrorReason(error) {
30
+ return String(error?.message ?? error ?? 'unknown-error').replace(/\s+/g, ' ').slice(0, 240)
31
+ }
32
+
33
+ /**
34
+ * 绑定 data channel message 回调。
35
+ * @param {RTCDataChannel} channel RTC 数据通道
36
+ * @param {(data: unknown) => void} handler 消息处理器
37
+ * @returns {void}
38
+ */
39
+ function attachChannelMessageListener(channel, handler) {
40
+ channel.addEventListener?.('message', event => handler(event?.data))
41
+ /**
42
+ * @param {MessageEvent} event 消息事件
43
+ * @returns {void}
44
+ */
45
+ channel.onmessage = event => handler(event?.data)
46
+ channel.onMessage?.subscribe(message => handler(message))
47
+ }
48
+
49
+ let cachedAvailable = null
50
+
51
+ /**
52
+ * 探测 WebRTC(node-datachannel)是否可用。
53
+ * @returns {Promise<boolean>} 可用为 true
54
+ */
55
+ export async function canUseWebRtcLink() {
56
+ if (cachedAvailable !== null) return cachedAvailable
57
+ try {
58
+ await loadNodeRtcPolyfill()
59
+ cachedAvailable = true
60
+ }
61
+ catch {
62
+ cachedAvailable = false
63
+ }
64
+ return cachedAvailable
65
+ }
66
+
67
+ /**
68
+ * 建立 WebRTC link(双 DataChannel + discovery 信令)。
69
+ * @param {object} options link 配置
70
+ * @param {string | null} [options.nodeHash] 期望的对端 nodeHash
71
+ * @param {boolean} options.initiator 是否为连接发起方
72
+ * @param {{ send: (message: unknown) => void | Promise<void>, onRemote: (handler: (message: unknown) => void) => (() => void) | void }} options.signal 信令收发接口
73
+ * @param {RTCConfiguration['iceServers']} [options.iceServers] ICE 服务器列表
74
+ * @param {number} [options.heartbeatMs] 心跳间隔
75
+ * @param {number} [options.idleTimeoutMs] 无入站流量超时
76
+ * @param {number} [options.handshakeTimeoutMs] 握手超时
77
+ * @param {{ RTCPeerConnection: typeof RTCPeerConnection } | null} [options.rtc] RTC 构造器
78
+ * @param {{ nodeHash?: string, nodePubKey?: string, secretKey?: Uint8Array, nonce?: string } | null} [options.localIdentity] 本地握手身份
79
+ * @returns {Promise<import('./index.mjs').LinkHandle>} link 句柄
80
+ */
81
+ export async function createWebRtcLink(options) {
82
+ const handshakeTimeoutMs = Number(options.handshakeTimeoutMs) || ms('10s')
83
+ const channelOpenTimeoutMs = Math.max(handshakeTimeoutMs, ms('30s'))
84
+ const trickleIceOff = getSignalingRuntimeConfig().trickleIceOff === true
85
+ const rtc = options.rtc ?? await loadNodeRtcPolyfill()
86
+ const pc = new rtc.RTCPeerConnection(options.iceServers?.length ? { iceServers: options.iceServers } : undefined)
87
+ const remoteSignalQueue = []
88
+ const seenRemoteSignals = createLruMap(1024)
89
+ let remoteDescriptionSet = false
90
+ let controlChannel = null
91
+ let bulkChannel = null
92
+ let unlistenRemote = null
93
+ let sendQueues = null
94
+ let controlLowEvents = 0
95
+ let bulkLowEvents = 0
96
+ let reconnectCount = 0
97
+
98
+ /**
99
+ * @param {unknown} message 信令载荷
100
+ * @returns {Promise<void>}
101
+ */
102
+ async function sendSignal(message) {
103
+ await Promise.resolve(options.signal.send(message))
104
+ }
105
+
106
+ const pipe = createLinkPipe({
107
+ providerId: 'webrtc',
108
+ level: LINK_LEVEL_WEBRTC,
109
+ initiator: !!options.initiator,
110
+ nodeHash: options.nodeHash,
111
+ localIdentity: options.localIdentity,
112
+ heartbeatMs: options.heartbeatMs,
113
+ idleTimeoutMs: options.idleTimeoutMs,
114
+ handshakeTimeoutMs,
115
+ /** @returns {string} 本端 DTLS fingerprint */
116
+ getLocalBinding: () => extractDtlsFingerprint(pc.localDescription?.sdp || ''),
117
+ /** @returns {string} 对端 DTLS fingerprint */
118
+ getRemoteBinding: () => extractDtlsFingerprint(pc.remoteDescription?.sdp || ''),
119
+ /**
120
+ * @param {string} text control JSON
121
+ * @returns {void}
122
+ */
123
+ sendControlText(text) {
124
+ if (!controlChannel) throw new Error('p2p: control channel unavailable')
125
+ controlChannel.send(text)
126
+ },
127
+ /**
128
+ * @param {string} action envelope action
129
+ * @param {Uint8Array} frame 帧字节
130
+ * @returns {void}
131
+ */
132
+ sendFrame(action, frame) {
133
+ if (!sendQueues) throw new Error('p2p: send queues unavailable')
134
+ sendQueues.enqueue(action, frame)
135
+ },
136
+ /**
137
+ * @returns {Promise<void>}
138
+ */
139
+ async closeTransport() {
140
+ unlistenRemote?.()
141
+ sendQueues?.clear()
142
+ try { controlChannel?.close() } catch { /* ignore */ }
143
+ try { bulkChannel?.close() } catch { /* ignore */ }
144
+ try { await pc.close() } catch { /* ignore */ }
145
+ },
146
+ /**
147
+ * @returns {object} WebRTC 附加 stats
148
+ */
149
+ extraStats() {
150
+ return {
151
+ connectionState: pc.connectionState,
152
+ iceConnectionState: pc.iceConnectionState,
153
+ reconnectCount,
154
+ pending: sendQueues?.pending() ?? { control: 0, bulk: 0 },
155
+ controlBufferedAmount: controlChannel?.bufferedAmount ?? 0,
156
+ bulkBufferedAmount: bulkChannel?.bufferedAmount ?? 0,
157
+ controlLowEvents,
158
+ bulkLowEvents,
159
+ }
160
+ },
161
+ })
162
+
163
+ /**
164
+ * @param {RTCDataChannel} channel RTC 数据通道
165
+ * @returns {void}
166
+ */
167
+ function attachBackpressurePump(channel) {
168
+ configureBufferedAmountLowThreshold(channel, CHANNEL_LOW_THRESHOLD_BYTES)
169
+ onBufferedAmountLow(channel, () => {
170
+ if (channel.label === CHANNEL_CONTROL) controlLowEvents++
171
+ if (channel.label === CHANNEL_BULK) bulkLowEvents++
172
+ if (channel.label === CHANNEL_CONTROL) sendQueues?.flush(CHANNEL_CONTROL)
173
+ if (channel.label === CHANNEL_BULK) sendQueues?.flush(CHANNEL_BULK)
174
+ })
175
+ }
176
+
177
+ /**
178
+ * @param {RTCSessionDescriptionInit | RTCSessionDescription} description 远端会话描述
179
+ * @returns {Promise<void>}
180
+ */
181
+ async function applyRemoteDescription(description) {
182
+ await pc.setRemoteDescription(description)
183
+ remoteDescriptionSet = true
184
+ await flushQueuedIceCandidates()
185
+ }
186
+
187
+ /**
188
+ * @param {unknown} error addIceCandidate 错误
189
+ * @returns {boolean} 是否应暂存后重试
190
+ */
191
+ function shouldRetryQueuedIce(error) {
192
+ return /without ice transport/i.test(String(error?.message ?? error ?? ''))
193
+ }
194
+
195
+ /**
196
+ * @returns {Promise<void>}
197
+ */
198
+ async function waitForIceGatheringComplete() {
199
+ if (!trickleIceOff || pc.iceGatheringState === 'complete') return
200
+ const deadline = Date.now() + handshakeTimeoutMs
201
+ while (pc.iceGatheringState !== 'complete' && Date.now() < deadline)
202
+ await new Promise(resolve => setTimeout(resolve, 50))
203
+ }
204
+
205
+ /**
206
+ * @returns {Promise<void>}
207
+ */
208
+ async function flushQueuedIceCandidates() {
209
+ if (!remoteDescriptionSet || !pc.localDescription) return
210
+ while (remoteSignalQueue.length) {
211
+ const candidate = remoteSignalQueue.shift()
212
+ try {
213
+ await pc.addIceCandidate(candidate)
214
+ }
215
+ catch (error) {
216
+ if (shouldRetryQueuedIce(error)) {
217
+ remoteSignalQueue.unshift(candidate)
218
+ return
219
+ }
220
+ throw error
221
+ }
222
+ }
223
+ }
224
+
225
+ /**
226
+ * @param {unknown} message 信令消息
227
+ * @returns {Promise<void>}
228
+ */
229
+ async function handleRemoteSignal(message) {
230
+ if (!message || typeof message !== 'object') return
231
+ const signalKey = JSON.stringify(message)
232
+ if (seenRemoteSignals.has(signalKey)) return
233
+ seenRemoteSignals.touch(signalKey, true)
234
+ if (message.type === 'description' && message.description) {
235
+ if (message.description.type === 'answer' && pc.signalingState === 'stable') return
236
+ await applyRemoteDescription(message.description)
237
+ if (message.description.type === 'offer') {
238
+ const answer = await pc.createAnswer()
239
+ await pc.setLocalDescription(answer)
240
+ await flushQueuedIceCandidates()
241
+ await waitForIceGatheringComplete()
242
+ await sendSignal({
243
+ type: 'description',
244
+ description: pc.localDescription?.toJSON?.() ?? pc.localDescription ?? answer,
245
+ })
246
+ await pipe.maybeSendAuth()
247
+ }
248
+ return
249
+ }
250
+ if (message.type === 'ice' && message.candidate) {
251
+ if (!remoteDescriptionSet || !pc.localDescription || pc.signalingState !== 'stable') {
252
+ remoteSignalQueue.push(message.candidate)
253
+ return
254
+ }
255
+ try {
256
+ await pc.addIceCandidate(message.candidate)
257
+ }
258
+ catch (error) {
259
+ if (shouldRetryQueuedIce(error)) {
260
+ remoteSignalQueue.push(message.candidate)
261
+ return
262
+ }
263
+ throw error
264
+ }
265
+ }
266
+ }
267
+
268
+ /**
269
+ * @param {RTCDataChannel} channel RTC 数据通道
270
+ * @returns {Promise<void>}
271
+ */
272
+ async function attachChannel(channel) {
273
+ if (channel.label === CHANNEL_CONTROL) controlChannel = channel
274
+ else if (channel.label === CHANNEL_BULK) bulkChannel = channel
275
+ else return
276
+ attachChannelMessageListener(channel, data => {
277
+ try {
278
+ if (typeof data === 'string') pipe.handleInbound(data)
279
+ else pipe.handleInbound(signalDataToBytes(data))
280
+ }
281
+ catch { /* drop */ }
282
+ })
283
+ attachBackpressurePump(channel)
284
+ }
285
+
286
+ /**
287
+ * @returns {Promise<void>}
288
+ */
289
+ async function maybeStartPostOpenFlow() {
290
+ if (!controlChannel || !bulkChannel) return
291
+ await Promise.all([
292
+ waitForChannelState(controlChannel, 'open', channelOpenTimeoutMs),
293
+ waitForChannelState(bulkChannel, 'open', channelOpenTimeoutMs),
294
+ ])
295
+ if (!sendQueues)
296
+ sendQueues = createChannelSendQueues({
297
+ /**
298
+ * @param {'control' | 'bulk'} name 通道名
299
+ * @returns {RTCDataChannel | null | undefined} 对应通道
300
+ */
301
+ getChannel: name => name === CHANNEL_CONTROL ? controlChannel : bulkChannel,
302
+ })
303
+ await pipe.startHandshake()
304
+ }
305
+
306
+ unlistenRemote = options.signal.onRemote(message => {
307
+ void handleRemoteSignal(message).catch(error => pipe.close(`signal-error:${formatErrorReason(error)}`))
308
+ }) ?? null
309
+
310
+ attachIceCandidateListener(pc, event => {
311
+ if (trickleIceOff || !event.candidate) return
312
+ void sendSignal({
313
+ type: 'ice',
314
+ candidate: typeof event.candidate.toJSON === 'function' ? event.candidate.toJSON() : event.candidate,
315
+ }).catch(error => pipe.close(`signal-send-failed:${formatErrorReason(error)}`))
316
+ })
317
+ attachDataChannelListener(pc, event => {
318
+ void attachChannel(event.channel)
319
+ .then(() => maybeStartPostOpenFlow())
320
+ .catch(error => pipe.close(`channel-attach-failed:${error?.message ?? error}`))
321
+ })
322
+
323
+ /** 连接失败/关闭时关掉 pipe。 */
324
+ pc.onconnectionstatechange = () => {
325
+ if (['failed', 'closed', 'disconnected'].includes(pc.connectionState)) {
326
+ reconnectCount++
327
+ void pipe.close(`connection-${pc.connectionState}`)
328
+ }
329
+ }
330
+
331
+ if (options.initiator) {
332
+ await attachChannel(pc.createDataChannel(CHANNEL_CONTROL))
333
+ await attachChannel(pc.createDataChannel(CHANNEL_BULK))
334
+ const offer = await pc.createOffer()
335
+ await pc.setLocalDescription(offer)
336
+ await waitForIceGatheringComplete()
337
+ await sendSignal({
338
+ type: 'description',
339
+ description: pc.localDescription?.toJSON?.() ?? pc.localDescription ?? offer,
340
+ })
341
+ }
342
+
343
+ void maybeStartPostOpenFlow().catch(error => pipe.close(`open-flow-failed:${error?.message ?? error}`))
344
+
345
+ return asLinkHandle(pipe, {
346
+ /**
347
+ * @internal 仅 live 背压测试用,不属公开 LinkHandle
348
+ * @param {'control' | 'bulk'} name 通道名
349
+ * @returns {RTCDataChannel | null} 测试用通道
350
+ */
351
+ _channelForTest(name) {
352
+ return name === CHANNEL_CONTROL ? controlChannel : bulkChannel
353
+ },
354
+ })
355
+ }
356
+
357
+ /**
358
+ * 创建 WebRTC LinkProvider。
359
+ * @param {object} [options] 可选覆盖
360
+ * @param {typeof createWebRtcLink} [options.createWebRtcLink] 链路工厂(测试注入)
361
+ * @returns {import('./index.mjs').LinkProvider} WebRTC provider
362
+ */
363
+ export function createWebRtcLinkProvider(options = {}) {
364
+ const createImpl = options.createWebRtcLink ?? createWebRtcLink
365
+ return {
366
+ id: 'webrtc',
367
+ level: LINK_LEVEL_WEBRTC,
368
+ caps: { needsOfferAnswer: true, needsDiscoverySignal: true },
369
+ isAvailable: canUseWebRtcLink,
370
+ /**
371
+ * @param {object} dialOptions dial 参数(含 signal / iceServers 等)
372
+ * @returns {Promise<import('./index.mjs').LinkHandle>} 已建链句柄
373
+ */
374
+ async dial(dialOptions) {
375
+ return createImpl({ ...dialOptions, initiator: true })
376
+ },
377
+ /**
378
+ * @param {object} acceptOptions accept 参数
379
+ * @returns {Promise<import('./index.mjs').LinkHandle>} 已建链句柄
380
+ */
381
+ async accept(acceptOptions) {
382
+ return createImpl({ ...acceptOptions, initiator: false })
383
+ },
384
+ }
385
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve02081504/fount-p2p",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "fount federation P2P layer — link, trust graph, mailbox, DAG, EVFS.",
5
5
  "keywords": [
6
6
  "network",
@@ -39,8 +39,9 @@
39
39
  "./schemas/*": "./schemas/*.mjs",
40
40
  "./node/*": "./node/*.mjs",
41
41
  "./discovery": "./discovery/index.mjs",
42
+ "./discovery/bt": "./discovery/bt/index.mjs",
43
+ "./discovery/bt/*": "./discovery/bt/*.mjs",
42
44
  "./discovery/*": "./discovery/*.mjs",
43
- "./link/*": "./link/*.mjs",
44
45
  "./transport/*": "./transport/*.mjs",
45
46
  "./rooms/*": "./rooms/*.mjs",
46
47
  "./overlay": "./overlay/index.mjs",
@@ -1,5 +1,6 @@
1
+ import { noteAdvertPeerHints } from '../discovery/advert_peer_hints.mjs'
1
2
  import { advertiseTopic, subscribeTopic } from '../discovery/index.mjs'
2
- import { buildSignedAdvert, verifySignedAdvert } from '../link/handshake.mjs'
3
+ import { verifySignedAdvert } from '../link/handshake.mjs'
3
4
  import {
4
5
  groupRendezvousTopic,
5
6
  decryptSignalPacket,
@@ -102,18 +103,19 @@ export function createScopedLinkRoom(options) {
102
103
  if (!discoveredPeers.has(nodeHash)) return
103
104
  notePeerLeave(nodeHash)
104
105
  }))
105
- cleanups.add(await subscribeTopic(topic, async bytes => {
106
+ cleanups.add(await subscribeTopic(topic, async (bytes, meta) => {
106
107
  const packet = decryptSignalPacket(topic, bytes)
107
108
  if (packet?.type !== 'advert' || !packet.body) return
108
109
  const verifiedNodeHash = await verifySignedAdvert(topic, packet.body)
109
110
  if (!verifiedNodeHash || !allowNode(verifiedNodeHash)) return
111
+ noteAdvertPeerHints(verifiedNodeHash, packet.body, meta)
110
112
  discoveredPeers.add(verifiedNodeHash)
111
113
  await registry.ensureLinkToNode(verifiedNodeHash).catch(() => null)
112
114
  if (registry.getLink(verifiedNodeHash)) notePeerJoin(verifiedNodeHash)
113
115
  }))
114
116
  cleanups.add(await advertiseTopic(topic, encryptSignalPacket(topic, {
115
117
  type: 'advert',
116
- body: await buildSignedAdvert(topic, Date.now(), registry.localIdentity),
118
+ body: await registry.buildLocalAdvert(topic),
117
119
  })))
118
120
  for (const peerId of activePeerIds())
119
121
  notePeerJoin(peerId)
@@ -1,5 +1,6 @@
1
+ import { noteAdvertPeerHints } from '../discovery/advert_peer_hints.mjs'
1
2
  import { advertiseTopic, subscribeTopic } from '../discovery/index.mjs'
2
- import { buildSignedAdvert, verifySignedAdvert } from '../link/handshake.mjs'
3
+ import { verifySignedAdvert } from '../link/handshake.mjs'
3
4
  import { loadPeerPoolView } from '../node/network.mjs'
4
5
  import { loadReputation } from '../node/reputation_store.mjs'
5
6
 
@@ -186,17 +187,18 @@ export function createGroupLinkSet(options) {
186
187
  if (!members.has(nodeHash) || nodeHash === selfNodeHash) return
187
188
  notePeerLeave(nodeHash)
188
189
  }))
189
- registerCleanup(await subscribeTopic(topic, async bytes => {
190
+ registerCleanup(await subscribeTopic(topic, async (bytes, meta) => {
190
191
  const packet = decryptSignalPacket(topic, bytes)
191
192
  if (packet?.type !== 'advert' || !packet.body) return
192
193
  const verifiedNodeHash = await verifySignedAdvert(topic, packet.body)
193
194
  if (!verifiedNodeHash || verifiedNodeHash === selfNodeHash) return
195
+ noteAdvertPeerHints(verifiedNodeHash, packet.body, meta)
194
196
  // 发现新成员:并入成员集并去抖重算稀疏建链目标(notePeerCandidate 内部会 scheduleDial)。
195
197
  notePeerCandidate(verifiedNodeHash)
196
198
  }))
197
199
  registerCleanup(await advertiseTopic(topic, encryptSignalPacket(topic, {
198
200
  type: 'advert',
199
- body: await buildSignedAdvert(topic, Date.now(), registry.localIdentity),
201
+ body: await registry.buildLocalAdvert(topic),
200
202
  })))
201
203
  if (autoconnect) selectAndDial()
202
204
  for (const { peerId } of activeRoster())