@steve02081504/fount-p2p 0.0.10 → 0.0.12

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.
Files changed (60) hide show
  1. package/README.md +25 -7
  2. package/core/bytes_codec.mjs +65 -10
  3. package/core/tcp_port.mjs +1 -1
  4. package/crypto/checkpoint_sign.mjs +2 -2
  5. package/dag/canonicalize_row.mjs +3 -3
  6. package/dag/index.mjs +2 -3
  7. package/discovery/bt/index.mjs +5 -5
  8. package/discovery/bt/probe_child.mjs +20 -0
  9. package/discovery/bt/runtime.mjs +59 -15
  10. package/discovery/index.mjs +45 -48
  11. package/discovery/mdns.mjs +72 -17
  12. package/discovery/nostr.mjs +165 -129
  13. package/federation/chunk_fetch_pending.mjs +4 -5
  14. package/federation/dag_order_cache.mjs +1 -1
  15. package/federation/entity_key_chain.mjs +3 -4
  16. package/federation/topo_order_memo.mjs +11 -4
  17. package/files/chunk_fetch.mjs +2 -2
  18. package/files/evfs.mjs +2 -2
  19. package/link/channel_mux.mjs +10 -10
  20. package/link/frame.mjs +76 -124
  21. package/link/handshake.mjs +5 -5
  22. package/link/pipe.mjs +49 -75
  23. package/link/providers/ble_gatt.mjs +15 -27
  24. package/link/providers/index.mjs +7 -9
  25. package/link/providers/lan_tcp.mjs +18 -33
  26. package/link/providers/link_id_pipe.mjs +46 -0
  27. package/link/providers/webrtc.mjs +43 -52
  28. package/link/rtc.mjs +21 -9
  29. package/mailbox/deliver_or_store.mjs +1 -1
  30. package/node/identity.mjs +4 -4
  31. package/node/network.mjs +9 -9
  32. package/overlay/index.mjs +13 -13
  33. package/package.json +3 -2
  34. package/rooms/scoped_link.mjs +22 -38
  35. package/schemas/discovery.mjs +1 -2
  36. package/schemas/federation_pull.mjs +2 -2
  37. package/schemas/part_query.mjs +1 -1
  38. package/schemas/remote_event.mjs +1 -1
  39. package/transport/advert_ingest.mjs +20 -0
  40. package/transport/group_link_set.mjs +26 -45
  41. package/transport/ice_servers.mjs +12 -3
  42. package/transport/link_registry.mjs +181 -523
  43. package/transport/offer_answer.mjs +194 -0
  44. package/transport/peer_pool.mjs +53 -64
  45. package/transport/remote_user_room.mjs +13 -15
  46. package/transport/rtc_connection_budget.mjs +18 -4
  47. package/transport/runtime_bootstrap.mjs +369 -0
  48. package/transport/signal_crypto.mjs +104 -0
  49. package/transport/user_room.mjs +22 -15
  50. package/trust_graph/send.mjs +1 -1
  51. package/utils/emit_safe.mjs +11 -0
  52. package/utils/shuffle.mjs +13 -0
  53. package/utils/ttl_map.mjs +29 -4
  54. package/wire/ingress.mjs +1 -1
  55. package/wire/part_ingress.mjs +6 -8
  56. package/wire/part_invoke.mjs +4 -4
  57. package/wire/part_query.mjs +2 -3
  58. package/wire/part_query.tunables.json +6 -1
  59. package/wire/part_query_cache.mjs +1 -1
  60. package/wire/volatile_signature.mjs +1 -1
@@ -4,9 +4,10 @@ import { randomBytes } from 'node:crypto'
4
4
  import { normalizeHex64 } from '../../core/hexIds.mjs'
5
5
  import { getBtPeerHint } from '../../discovery/bt/peer_hints.mjs'
6
6
  import { canUseBluetoothRuntime, loadBleno, loadNoble, resolveBtRole, waitPoweredOn } from '../../discovery/bt/runtime.mjs'
7
- import { asLinkHandle, coercePipeInbound, createLinkPipe } from '../pipe.mjs'
7
+ import { asLinkHandle } from '../pipe.mjs'
8
8
 
9
9
  import { LINK_LEVEL_BLE_GATT } from './levels.mjs'
10
+ import { buildLinkOpen, createLinkIdBoundPipe, parseLinkOpen } from './link_id_pipe.mjs'
10
11
 
11
12
  /** BLE GATT 数据 service UUID。 */
12
13
  export const BLE_DATA_SERVICE_UUID = 'f017f017f017f017f017f017f017f019'
@@ -28,19 +29,13 @@ export async function canUseBleGattLink() {
28
29
  * @returns {Promise<import('./index.mjs').LinkHandle>} 已启动握手的 link
29
30
  */
30
31
  async function openGattPipe(options) {
31
- const linkId = normalizeHex64(options.linkId)
32
- if (!linkId) throw new Error('p2p: ble_gatt linkId required')
33
-
34
- const pipe = createLinkPipe({
32
+ const pipe = createLinkIdBoundPipe({
35
33
  providerId: 'ble_gatt',
36
34
  level: LINK_LEVEL_BLE_GATT,
37
35
  initiator: !!options.initiator,
36
+ linkId: options.linkId,
38
37
  nodeHash: options.nodeHash,
39
38
  localIdentity: options.localIdentity,
40
- /** @returns {string} 本端 binding(linkId) */
41
- getLocalBinding: () => linkId,
42
- /** @returns {string} 对端 binding(linkId) */
43
- getRemoteBinding: () => linkId,
44
39
  /**
45
40
  * @param {string} text control JSON
46
41
  * @returns {Promise<void>}
@@ -60,18 +55,17 @@ async function openGattPipe(options) {
60
55
  })
61
56
 
62
57
  const stopNotify = options.onNotify(data => {
63
- pipe.handleInbound(coercePipeInbound(data))
58
+ pipe.handleInbound(data)
64
59
  })
65
60
  pipe.onDown(() => {
66
61
  try { stopNotify() } catch { /* ignore */ }
67
62
  })
68
63
 
69
64
  if (options.initiator)
70
- await Promise.resolve(options.write(Buffer.from(JSON.stringify({
71
- type: 'link-open',
72
- linkId,
73
- from: options.localIdentity?.nodeHash || '',
74
- }), 'utf8')))
65
+ await Promise.resolve(options.write(Buffer.from(
66
+ buildLinkOpen(normalizeHex64(options.linkId), options.localIdentity?.nodeHash),
67
+ 'utf8',
68
+ )))
75
69
 
76
70
  await pipe.startHandshake()
77
71
  return asLinkHandle(pipe)
@@ -131,7 +125,7 @@ async function dialBleGatt(options) {
131
125
  /** @type {Set<(data: Buffer) => void>} */
132
126
  const notifyHandlers = new Set()
133
127
  characteristic.on('data', data => {
134
- if (data == null) return
128
+ if (!data) return
135
129
  for (const handler of notifyHandlers)
136
130
  handler(Buffer.from(data))
137
131
  })
@@ -239,20 +233,14 @@ export function createBleGattLinkProvider() {
239
233
  */
240
234
  async function acceptPeripheralWrite(buf) {
241
235
  if (activeInbound || acceptInflight || !onInbound || !localIdentity) return
242
- let parsed
243
- try {
244
- parsed = JSON.parse(Buffer.from(buf).toString('utf8'))
245
- }
246
- catch {
247
- return
248
- }
249
- if (parsed?.type !== 'link-open' || !parsed.linkId) return
236
+ const opened = parseLinkOpen(buf)
237
+ if (!opened) return
250
238
  acceptInflight = true
251
239
  try {
252
240
  const link = await openGattPipe({
253
241
  initiator: false,
254
- linkId: parsed.linkId,
255
- nodeHash: normalizeHex64(parsed.from) || null,
242
+ linkId: opened.linkId,
243
+ nodeHash: opened.from,
256
244
  localIdentity,
257
245
  /**
258
246
  * @param {Buffer} data 出站
@@ -290,7 +278,7 @@ export function createBleGattLinkProvider() {
290
278
  return {
291
279
  id: instanceId,
292
280
  level: LINK_LEVEL_BLE_GATT,
293
- caps: { needsOfferAnswer: false, needsDiscoverySignal: false },
281
+ caps: { needsOfferAnswer: false, needsDiscoverySignal: false, probe: 'native' },
294
282
  isAvailable: canUseBleGattLink,
295
283
  /**
296
284
  * @param {{ nodeHash: string }} remote 远端
@@ -15,7 +15,7 @@
15
15
  * @typedef {{
16
16
  * id: string,
17
17
  * level: number,
18
- * caps?: { needsOfferAnswer?: boolean, needsDiscoverySignal?: boolean },
18
+ * caps?: { needsOfferAnswer?: boolean, needsDiscoverySignal?: boolean, probe?: 'sync' | 'native' },
19
19
  * isAvailable: () => boolean | Promise<boolean>,
20
20
  * canReach?: (remote: { nodeHash: string, hints?: object }) => boolean | Promise<boolean>,
21
21
  * dial: (options: object) => Promise<LinkHandle | null>,
@@ -57,7 +57,7 @@ export function listLinkProviders() {
57
57
  }
58
58
 
59
59
  /**
60
- * 清空全部 link provider(测试用)。
60
+ * 清空全部 link provider
61
61
  * @returns {void}
62
62
  */
63
63
  export function clearLinkProviders() {
@@ -69,17 +69,15 @@ export function clearLinkProviders() {
69
69
  * @returns {Promise<LinkProvider[]>} 按 level 降序的可用列表
70
70
  */
71
71
  export async function listAvailableLinkProviders() {
72
- const available = []
73
- for (const provider of listLinkProviders())
72
+ const results = await Promise.all(listLinkProviders().map(async provider => {
74
73
  try {
75
- if (await Promise.resolve(provider.isAvailable()))
76
- available.push(provider)
74
+ return await Promise.resolve(provider.isAvailable()) ? provider : null
77
75
  }
78
76
  catch {
79
- /* probe failure → skip */
77
+ return null
80
78
  }
81
-
82
- return available
79
+ }))
80
+ return results.filter(Boolean)
83
81
  }
84
82
 
85
83
  /** 重导出 level 常量。 */
@@ -4,9 +4,10 @@ import net from 'node:net'
4
4
 
5
5
  import { normalizeHex64 } from '../../core/hexIds.mjs'
6
6
  import { getLanPeerHint } from '../../discovery/lan_peer_hints.mjs'
7
- import { asLinkHandle, coercePipeInbound, createLinkPipe } from '../pipe.mjs'
7
+ import { asLinkHandle } from '../pipe.mjs'
8
8
 
9
9
  import { LINK_LEVEL_LAN_TCP } from './levels.mjs'
10
+ import { buildLinkOpen, createLinkIdBoundPipe, parseLinkOpen } from './link_id_pipe.mjs'
10
11
 
11
12
  const MAX_FRAME_BYTES = 1 << 20
12
13
 
@@ -85,22 +86,17 @@ function attachLengthPrefix(socket, onPayload) {
85
86
  /**
86
87
  * 在已挂 codec 的 socket 上创建 pipe。
87
88
  * @param {object} options 配置
88
- * @returns {ReturnType<typeof createLinkPipe>} pipe 句柄
89
+ * @returns {ReturnType<typeof createLinkIdBoundPipe>} pipe 句柄
89
90
  */
90
91
  function createTcpPipe(options) {
91
- const linkId = normalizeHex64(options.linkId)
92
- if (!linkId) throw new Error('p2p: lan_tcp linkId required')
93
92
  const { socket, codec } = options
94
- const pipe = createLinkPipe({
93
+ const pipe = createLinkIdBoundPipe({
95
94
  providerId: 'lan_tcp',
96
95
  level: LINK_LEVEL_LAN_TCP,
97
96
  initiator: !!options.initiator,
97
+ linkId: options.linkId,
98
98
  nodeHash: options.nodeHash,
99
99
  localIdentity: options.localIdentity,
100
- /** @returns {string} 本端 binding(linkId) */
101
- getLocalBinding: () => linkId,
102
- /** @returns {string} 对端 binding(linkId) */
103
- getRemoteBinding: () => linkId,
104
100
  /**
105
101
  * @param {string} text control JSON
106
102
  * @returns {void}
@@ -141,8 +137,8 @@ function createTcpPipe(options) {
141
137
  * @returns {Promise<import('./index.mjs').LinkHandle>} 已启动握手的 link
142
138
  */
143
139
  async function openTcpPipe(options) {
144
- const socket = options.socket
145
- /** @type {ReturnType<typeof createLinkPipe> | null} */
140
+ const { socket } = options
141
+ /** @type {ReturnType<typeof createLinkIdBoundPipe> | null} */
146
142
  let pipe = null
147
143
  /** @type {Buffer[]} */
148
144
  const pending = []
@@ -151,19 +147,15 @@ async function openTcpPipe(options) {
151
147
  pending.push(payload)
152
148
  return
153
149
  }
154
- pipe.handleInbound(coercePipeInbound(payload))
150
+ pipe.handleInbound(payload)
155
151
  })
156
152
 
157
153
  pipe = createTcpPipe({ ...options, codec, socket })
158
154
  for (const payload of pending.splice(0))
159
- pipe.handleInbound(coercePipeInbound(payload))
155
+ pipe.handleInbound(payload)
160
156
 
161
157
  if (options.initiator)
162
- codec.write(JSON.stringify({
163
- type: 'link-open',
164
- linkId: normalizeHex64(options.linkId),
165
- from: options.localIdentity?.nodeHash || '',
166
- }))
158
+ codec.write(buildLinkOpen(normalizeHex64(options.linkId), options.localIdentity?.nodeHash))
167
159
 
168
160
  await pipe.startHandshake()
169
161
  return asLinkHandle(pipe)
@@ -238,30 +230,23 @@ export function createLanTcpLinkProvider() {
238
230
  socket.destroy()
239
231
  return
240
232
  }
241
- /** @type {ReturnType<typeof createLinkPipe> | null} */
233
+ /** @type {ReturnType<typeof createLinkIdBoundPipe> | null} */
242
234
  let pipe = null
243
235
  const codec = attachLengthPrefix(socket, payload => {
244
236
  if (pipe) {
245
- pipe.handleInbound(coercePipeInbound(payload))
237
+ pipe.handleInbound(payload)
246
238
  return
247
239
  }
248
- let parsed
249
- try {
250
- parsed = JSON.parse(payload.toString('utf8'))
251
- }
252
- catch {
253
- socket.destroy()
254
- return
255
- }
256
- if (parsed?.type !== 'link-open' || !parsed.linkId) {
240
+ const opened = parseLinkOpen(payload)
241
+ if (!opened) {
257
242
  socket.destroy()
258
243
  return
259
244
  }
260
245
  try {
261
246
  pipe = createTcpPipe({
262
247
  initiator: false,
263
- linkId: parsed.linkId,
264
- nodeHash: normalizeHex64(parsed.from) || null,
248
+ linkId: opened.linkId,
249
+ nodeHash: opened.from,
265
250
  localIdentity,
266
251
  socket,
267
252
  codec,
@@ -283,7 +268,7 @@ export function createLanTcpLinkProvider() {
283
268
  return {
284
269
  id: instanceId,
285
270
  level: LINK_LEVEL_LAN_TCP,
286
- caps: { needsOfferAnswer: false, needsDiscoverySignal: false },
271
+ caps: { needsOfferAnswer: false, needsDiscoverySignal: false, probe: 'sync' },
287
272
  /**
288
273
  * @returns {boolean} 始终可用
289
274
  */
@@ -324,7 +309,7 @@ export function createLanTcpLinkProvider() {
324
309
  server.listen(0, '0.0.0.0', () => {
325
310
  server.off('error', reject)
326
311
  const addr = server.address()
327
- listenPort = typeof addr === 'object' && addr ? addr.port : 0
312
+ listenPort = addr?.port || 0
328
313
  resolve()
329
314
  })
330
315
  })
@@ -0,0 +1,46 @@
1
+ import { Buffer } from 'node:buffer'
2
+
3
+ import { normalizeHex64 } from '../../core/hexIds.mjs'
4
+ import { createLinkPipe } from '../pipe.mjs'
5
+
6
+ /**
7
+ * 构造 link-open control JSON。
8
+ * @param {string} linkId 链路 id(64 hex)
9
+ * @param {string} [fromNodeHash] 本端 nodeHash
10
+ * @returns {string} JSON 文本
11
+ */
12
+ export function buildLinkOpen(linkId, fromNodeHash = '') {
13
+ return JSON.stringify({ type: 'link-open', linkId, from: fromNodeHash || '' })
14
+ }
15
+
16
+ /**
17
+ * 解析 link-open;非法返回 null。
18
+ * @param {string | Uint8Array | Buffer} raw 原始 control
19
+ * @returns {{ linkId: string, from: string | null } | null} 解析结果
20
+ */
21
+ export function parseLinkOpen(raw) {
22
+ let parsed
23
+ try {
24
+ parsed = JSON.parse(typeof raw === 'string' ? raw : Buffer.from(raw).toString('utf8'))
25
+ }
26
+ catch { return null }
27
+ if (parsed?.type !== 'link-open' || !parsed.linkId) return null
28
+ return { linkId: String(parsed.linkId), from: normalizeHex64(parsed.from) || null }
29
+ }
30
+
31
+ /**
32
+ * 以固定 linkId 作为 hello/auth binding 的 pipe。
33
+ * @param {Parameters<typeof createLinkPipe>[0] & { linkId: string }} options pipe 配置(须含 linkId)
34
+ * @returns {ReturnType<typeof createLinkPipe>} link pipe
35
+ */
36
+ export function createLinkIdBoundPipe(options) {
37
+ const linkId = normalizeHex64(options.linkId)
38
+ if (!linkId) throw new Error(`p2p: ${options.providerId || 'link'} linkId required`)
39
+ return createLinkPipe({
40
+ ...options,
41
+ /** @returns {string} 本地 binding */
42
+ getLocalBinding: () => linkId,
43
+ /** @returns {string} 远端 binding */
44
+ getRemoteBinding: () => linkId,
45
+ })
46
+ }
@@ -11,10 +11,11 @@ import {
11
11
  } from '../channel_mux.mjs'
12
12
  import { asLinkHandle, createLinkPipe } from '../pipe.mjs'
13
13
  import {
14
+ attachChannelMessageListener,
14
15
  attachDataChannelListener,
15
16
  attachIceCandidateListener,
16
17
  loadNodeRtcPolyfill,
17
- signalDataToBytes,
18
+ dataToBytes,
18
19
  waitForChannelState,
19
20
  } from '../rtc.mjs'
20
21
  import { extractDtlsFingerprint } from '../sdp_fingerprint.mjs'
@@ -30,22 +31,6 @@ function formatErrorReason(error) {
30
31
  return String(error?.message ?? error ?? 'unknown-error').replace(/\s+/g, ' ').slice(0, 240)
31
32
  }
32
33
 
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
34
  let cachedAvailable = null
50
35
 
51
36
  /**
@@ -83,7 +68,7 @@ export async function createWebRtcLink(options) {
83
68
  const channelOpenTimeoutMs = Math.max(handshakeTimeoutMs, ms('30s'))
84
69
  const trickleIceOff = getSignalingRuntimeConfig().trickleIceOff === true
85
70
  const rtc = options.rtc ?? await loadNodeRtcPolyfill()
86
- const pc = new rtc.RTCPeerConnection(options.iceServers?.length ? { iceServers: options.iceServers } : undefined)
71
+ const peerConnection = new rtc.RTCPeerConnection(options.iceServers?.length ? { iceServers: options.iceServers } : undefined)
87
72
  const remoteSignalQueue = []
88
73
  const seenRemoteSignals = createLruMap(1024)
89
74
  let remoteDescriptionSet = false
@@ -113,9 +98,9 @@ export async function createWebRtcLink(options) {
113
98
  idleTimeoutMs: options.idleTimeoutMs,
114
99
  handshakeTimeoutMs,
115
100
  /** @returns {string} 本端 DTLS fingerprint */
116
- getLocalBinding: () => extractDtlsFingerprint(pc.localDescription?.sdp || ''),
101
+ getLocalBinding: () => extractDtlsFingerprint(peerConnection.localDescription?.sdp || ''),
117
102
  /** @returns {string} 对端 DTLS fingerprint */
118
- getRemoteBinding: () => extractDtlsFingerprint(pc.remoteDescription?.sdp || ''),
103
+ getRemoteBinding: () => extractDtlsFingerprint(peerConnection.remoteDescription?.sdp || ''),
119
104
  /**
120
105
  * @param {string} text control JSON
121
106
  * @returns {void}
@@ -141,15 +126,15 @@ export async function createWebRtcLink(options) {
141
126
  sendQueues?.clear()
142
127
  try { controlChannel?.close() } catch { /* ignore */ }
143
128
  try { bulkChannel?.close() } catch { /* ignore */ }
144
- try { await pc.close() } catch { /* ignore */ }
129
+ try { await peerConnection.close() } catch { /* ignore */ }
145
130
  },
146
131
  /**
147
132
  * @returns {object} WebRTC 附加 stats
148
133
  */
149
134
  extraStats() {
150
135
  return {
151
- connectionState: pc.connectionState,
152
- iceConnectionState: pc.iceConnectionState,
136
+ connectionState: peerConnection.connectionState,
137
+ iceConnectionState: peerConnection.iceConnectionState,
153
138
  reconnectCount,
154
139
  pending: sendQueues?.pending() ?? { control: 0, bulk: 0 },
155
140
  controlBufferedAmount: controlChannel?.bufferedAmount ?? 0,
@@ -167,10 +152,14 @@ export async function createWebRtcLink(options) {
167
152
  function attachBackpressurePump(channel) {
168
153
  configureBufferedAmountLowThreshold(channel, CHANNEL_LOW_THRESHOLD_BYTES)
169
154
  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)
155
+ if (channel.label === CHANNEL_CONTROL) {
156
+ controlLowEvents++
157
+ sendQueues?.flush(CHANNEL_CONTROL)
158
+ }
159
+ else if (channel.label === CHANNEL_BULK) {
160
+ bulkLowEvents++
161
+ sendQueues?.flush(CHANNEL_BULK)
162
+ }
174
163
  })
175
164
  }
176
165
 
@@ -179,7 +168,7 @@ export async function createWebRtcLink(options) {
179
168
  * @returns {Promise<void>}
180
169
  */
181
170
  async function applyRemoteDescription(description) {
182
- await pc.setRemoteDescription(description)
171
+ await peerConnection.setRemoteDescription(description)
183
172
  remoteDescriptionSet = true
184
173
  await flushQueuedIceCandidates()
185
174
  }
@@ -196,9 +185,9 @@ export async function createWebRtcLink(options) {
196
185
  * @returns {Promise<void>}
197
186
  */
198
187
  async function waitForIceGatheringComplete() {
199
- if (!trickleIceOff || pc.iceGatheringState === 'complete') return
188
+ if (!trickleIceOff || peerConnection.iceGatheringState === 'complete') return
200
189
  const deadline = Date.now() + handshakeTimeoutMs
201
- while (pc.iceGatheringState !== 'complete' && Date.now() < deadline)
190
+ while (peerConnection.iceGatheringState !== 'complete' && Date.now() < deadline)
202
191
  await new Promise(resolve => setTimeout(resolve, 50))
203
192
  }
204
193
 
@@ -206,11 +195,11 @@ export async function createWebRtcLink(options) {
206
195
  * @returns {Promise<void>}
207
196
  */
208
197
  async function flushQueuedIceCandidates() {
209
- if (!remoteDescriptionSet || !pc.localDescription) return
198
+ if (!remoteDescriptionSet || !peerConnection.localDescription) return
210
199
  while (remoteSignalQueue.length) {
211
200
  const candidate = remoteSignalQueue.shift()
212
201
  try {
213
- await pc.addIceCandidate(candidate)
202
+ await peerConnection.addIceCandidate(candidate)
214
203
  }
215
204
  catch (error) {
216
205
  if (shouldRetryQueuedIce(error)) {
@@ -227,33 +216,35 @@ export async function createWebRtcLink(options) {
227
216
  * @returns {Promise<void>}
228
217
  */
229
218
  async function handleRemoteSignal(message) {
230
- if (!message || typeof message !== 'object') return
231
- const signalKey = JSON.stringify(message)
219
+ if (!message?.type) return
220
+ const signalKey = message.type === 'ice' && message.candidate
221
+ ? `ice:${message.candidate.candidate ?? ''}:${message.candidate.sdpMid ?? ''}:${message.candidate.sdpMLineIndex ?? ''}`
222
+ : JSON.stringify(message)
232
223
  if (seenRemoteSignals.has(signalKey)) return
233
224
  seenRemoteSignals.touch(signalKey, true)
234
225
  if (message.type === 'description' && message.description) {
235
- if (message.description.type === 'answer' && pc.signalingState === 'stable') return
226
+ if (message.description.type === 'answer' && peerConnection.signalingState === 'stable') return
236
227
  await applyRemoteDescription(message.description)
237
228
  if (message.description.type === 'offer') {
238
- const answer = await pc.createAnswer()
239
- await pc.setLocalDescription(answer)
229
+ const answer = await peerConnection.createAnswer()
230
+ await peerConnection.setLocalDescription(answer)
240
231
  await flushQueuedIceCandidates()
241
232
  await waitForIceGatheringComplete()
242
233
  await sendSignal({
243
234
  type: 'description',
244
- description: pc.localDescription?.toJSON?.() ?? pc.localDescription ?? answer,
235
+ description: peerConnection.localDescription?.toJSON?.() ?? peerConnection.localDescription ?? answer,
245
236
  })
246
237
  await pipe.maybeSendAuth()
247
238
  }
248
239
  return
249
240
  }
250
241
  if (message.type === 'ice' && message.candidate) {
251
- if (!remoteDescriptionSet || !pc.localDescription || pc.signalingState !== 'stable') {
242
+ if (!remoteDescriptionSet || !peerConnection.localDescription || peerConnection.signalingState !== 'stable') {
252
243
  remoteSignalQueue.push(message.candidate)
253
244
  return
254
245
  }
255
246
  try {
256
- await pc.addIceCandidate(message.candidate)
247
+ await peerConnection.addIceCandidate(message.candidate)
257
248
  }
258
249
  catch (error) {
259
250
  if (shouldRetryQueuedIce(error)) {
@@ -276,7 +267,7 @@ export async function createWebRtcLink(options) {
276
267
  attachChannelMessageListener(channel, data => {
277
268
  try {
278
269
  if (typeof data === 'string') pipe.handleInbound(data)
279
- else pipe.handleInbound(signalDataToBytes(data))
270
+ else pipe.handleInbound(dataToBytes(data))
280
271
  }
281
272
  catch { /* drop */ }
282
273
  })
@@ -307,36 +298,36 @@ export async function createWebRtcLink(options) {
307
298
  void handleRemoteSignal(message).catch(error => pipe.close(`signal-error:${formatErrorReason(error)}`))
308
299
  }) ?? null
309
300
 
310
- attachIceCandidateListener(pc, event => {
301
+ attachIceCandidateListener(peerConnection, event => {
311
302
  if (trickleIceOff || !event.candidate) return
312
303
  void sendSignal({
313
304
  type: 'ice',
314
305
  candidate: typeof event.candidate.toJSON === 'function' ? event.candidate.toJSON() : event.candidate,
315
306
  }).catch(error => pipe.close(`signal-send-failed:${formatErrorReason(error)}`))
316
307
  })
317
- attachDataChannelListener(pc, event => {
308
+ attachDataChannelListener(peerConnection, event => {
318
309
  void attachChannel(event.channel)
319
310
  .then(() => maybeStartPostOpenFlow())
320
311
  .catch(error => pipe.close(`channel-attach-failed:${error?.message ?? error}`))
321
312
  })
322
313
 
323
314
  /** 连接失败/关闭时关掉 pipe。 */
324
- pc.onconnectionstatechange = () => {
325
- if (['failed', 'closed', 'disconnected'].includes(pc.connectionState)) {
315
+ peerConnection.onconnectionstatechange = () => {
316
+ if (['failed', 'closed', 'disconnected'].includes(peerConnection.connectionState)) {
326
317
  reconnectCount++
327
- void pipe.close(`connection-${pc.connectionState}`)
318
+ void pipe.close(`connection-${peerConnection.connectionState}`)
328
319
  }
329
320
  }
330
321
 
331
322
  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)
323
+ await attachChannel(peerConnection.createDataChannel(CHANNEL_CONTROL))
324
+ await attachChannel(peerConnection.createDataChannel(CHANNEL_BULK))
325
+ const offer = await peerConnection.createOffer()
326
+ await peerConnection.setLocalDescription(offer)
336
327
  await waitForIceGatheringComplete()
337
328
  await sendSignal({
338
329
  type: 'description',
339
- description: pc.localDescription?.toJSON?.() ?? pc.localDescription ?? offer,
330
+ description: peerConnection.localDescription?.toJSON?.() ?? peerConnection.localDescription ?? offer,
340
331
  })
341
332
  }
342
333
 
@@ -365,7 +356,7 @@ export function createWebRtcLinkProvider(options = {}) {
365
356
  return {
366
357
  id: 'webrtc',
367
358
  level: LINK_LEVEL_WEBRTC,
368
- caps: { needsOfferAnswer: true, needsDiscoverySignal: true },
359
+ caps: { needsOfferAnswer: true, needsDiscoverySignal: true, probe: 'native' },
369
360
  isAvailable: canUseWebRtcLink,
370
361
  /**
371
362
  * @param {object} dialOptions dial 参数(含 signal / iceServers 等)
package/link/rtc.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import process from 'node:process'
2
2
 
3
+ import { toBytes } from '../core/bytes_codec.mjs'
3
4
  import { getSignalingRuntimeConfig } from '../node/instance.mjs'
4
5
  import { wrapRtcPeerConnectionForMdns } from '../transport/rtc_mdns_filter.mjs'
5
6
 
@@ -96,14 +97,25 @@ export function waitForChannelState(channel, eventName, timeoutMs) {
96
97
  }
97
98
 
98
99
  /**
99
- * 将信令/消息数据统一转为 Uint8Array。
100
- * @param {unknown} data 原始数据(字符串、ArrayBuffer、TypedArray 等)
101
- * @returns {Uint8Array} 字节视图
100
+ * 绑定 data channel message 回调(addEventListener / onmessage / onMessage.subscribe)。
101
+ * @param {RTCDataChannel} channel data channel
102
+ * @param {(data: unknown) => void} handler 消息回调
103
+ * @returns {void}
104
+ */
105
+ export function attachChannelMessageListener(channel, handler) {
106
+ channel.addEventListener?.('message', event => handler(event?.data))
107
+ /**
108
+ * @param {{ data?: unknown }} event message 事件
109
+ * @returns {void}
110
+ */
111
+ channel.onmessage = event => handler(event?.data)
112
+ channel.onMessage?.subscribe(message => handler(message))
113
+ }
114
+
115
+ /**
116
+ * @param {unknown} data 通道原始数据
117
+ * @returns {Uint8Array} 字节
102
118
  */
103
- export function signalDataToBytes(data) {
104
- if (data instanceof Uint8Array) return data
105
- if (data instanceof ArrayBuffer) return new Uint8Array(data)
106
- if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
107
- if (typeof data === 'string') return new TextEncoder().encode(data)
108
- return new TextEncoder().encode(String(data ?? ''))
119
+ export function dataToBytes(data) {
120
+ return toBytes(data, { allowString: true })
109
121
  }
@@ -147,7 +147,7 @@ export async function respondMailboxWant(want, sendGive, peerId) {
147
147
  const { getMailboxRecords, takeMailboxForRecipient } = await import('./store.mjs')
148
148
  const recipient = normalizeHex64(want.toPubKeyHash)
149
149
  if (!recipient) return
150
- const ids = Array.isArray(want.ids) ? want.ids : []
150
+ const ids = want.ids || []
151
151
  const rows = (ids.length
152
152
  ? await getMailboxRecords(ids)
153
153
  : await takeMailboxForRecipient(recipient)
package/node/identity.mjs CHANGED
@@ -66,9 +66,9 @@ export function getNodeHash() {
66
66
  */
67
67
  export function getNodeTransportSettings() {
68
68
  const data = loadNodeFile()
69
- const relayUrls = Array.isArray(data.relayUrls)
70
- ? data.relayUrls.map(url => String(url).trim()).filter(url => url.startsWith('wss://'))
71
- : []
69
+ const relayUrls = (data.relayUrls || [])
70
+ .map(url => url.trim())
71
+ .filter(url => url.startsWith('wss://'))
72
72
  const batterySaver = !!data.batterySaver
73
73
  const mailbox = normalizeMailboxSettings(data.mailbox || {})
74
74
  return { relayUrls, batterySaver, mailbox }
@@ -82,7 +82,7 @@ export function saveNodeTransportSettings(patch) {
82
82
  const data = loadNodeFile()
83
83
  if (patch.batterySaver != null) data.batterySaver = !!patch.batterySaver
84
84
  if (patch.relayUrls)
85
- data.relayUrls = patch.relayUrls.map(url => String(url).trim()).filter(url => url.startsWith('wss://'))
85
+ data.relayUrls = patch.relayUrls.map(url => url.trim()).filter(url => url.startsWith('wss://'))
86
86
  if (patch.mailbox)
87
87
  data.mailbox = normalizeMailboxSettings({ ...data.mailbox, ...patch.mailbox })
88
88
  saveNodeFile(data)
package/node/network.mjs CHANGED
@@ -52,18 +52,18 @@ export function normalizeNetwork(raw) {
52
52
  * @returns {string[]} 去重 nodeHash 列表
53
53
  */
54
54
  const pickIds = key => [...new Set(
55
- (Array.isArray(file[key]) ? file[key] : [])
56
- .map(id => normalizeHex64(id) || String(id).trim())
55
+ (file[key] || [])
56
+ .map(id => normalizeHex64(id) || id.trim())
57
57
  .filter(id => isHex64(id)),
58
58
  )]
59
- const hints = (Array.isArray(file.hints) ? file.hints : [])
59
+ const hints = (file.hints || [])
60
60
  .map(hint => ({
61
- nodeHash: normalizeHex64(hint?.nodeHash) || '',
62
- source: String(hint?.source || '').trim(),
63
- kind: String(hint?.kind || '').trim(),
64
- weight: Number.isFinite(Number(hint?.weight)) ? Number(hint.weight) : 0.1,
65
- expiresAt: Number(hint?.expiresAt) || 0,
66
- ...hint?.groupId ? { groupId: String(hint.groupId).trim() } : {},
61
+ nodeHash: normalizeHex64(hint.nodeHash) || '',
62
+ source: hint.source?.trim() || '',
63
+ kind: hint.kind?.trim() || '',
64
+ weight: Number.isFinite(Number(hint.weight)) ? Number(hint.weight) : 0.1,
65
+ expiresAt: Number(hint.expiresAt) || 0,
66
+ ...hint.groupId ? { groupId: hint.groupId.trim() } : {},
67
67
  }))
68
68
  .filter(hint => isHex64(hint.nodeHash))
69
69
  return {