@steve02081504/fount-p2p 0.0.19 → 0.0.21

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 CHANGED
@@ -107,8 +107,9 @@ Maintainer notes for agents / contributors: [AGENTS.md](./AGENTS.md). Sim harnes
107
107
 
108
108
  ## Optional dependencies
109
109
 
110
- - `@stoprocent/noble` / `@stoprocent/bleno` — Bluetooth (optionalDependencies). Hardware probe is subprocess-only; see [docs/runtime.md](./docs/runtime.md).
111
- - `node-datachannel` — WebRTC DataChannels (dependency).
110
+ - `@stoprocent/noble` / `@stoprocent/bleno` — Bluetooth (optionalDependencies). Hardware probe is in-process; see [docs/runtime.md](./docs/runtime.md).
111
+ - `node-datachannel` — native WebRTC DataChannels (optionalDependencies). Preferred when the addon loads.
112
+ - `node-rtc-connection` — pure-JS WebRTC DataChannel fallback (dependency; used on Termux/Android or when native is missing).
112
113
  - `ws` — Nostr discovery / signaling / last-resort link WebSockets (dependency).
113
114
 
114
115
  Group chunk remote storage (S3, etc.) is implemented by the shell as `GroupStoragePlugin` and injected; see `node/storage_plugins.mjs` for the local reference implementation.
@@ -11,7 +11,7 @@ import {
11
11
  import { isHex64, normalizeHex64 } from './hexIds.mjs'
12
12
 
13
13
  /**
14
- *
14
+ * entityHash 编解码与校验(re-export)。
15
15
  */
16
16
  export {
17
17
  encodeEntityHash,
package/dag/index.mjs CHANGED
@@ -252,10 +252,10 @@ export function eventsToMetas(events) {
252
252
  }
253
253
 
254
254
  /**
255
- *
255
+ * DAG 行内容/签名 canonicalize(re-export)。
256
256
  */
257
257
  export { canonicalizeRowContent, canonicalizeSignedRow } from './canonicalize_row.mjs'
258
258
  /**
259
- *
259
+ * 剥离 DAG 事件本地扩展字段(re-export)。
260
260
  */
261
261
  export { stripDagEventLocalExtensions } from './strip_extensions.mjs'
package/dag/storage.mjs CHANGED
@@ -6,9 +6,12 @@ import { createInterface } from 'node:readline'
6
6
  import { Readable } from 'node:stream'
7
7
  import { pipeline } from 'node:stream/promises'
8
8
 
9
- import { atomicTemporaryPath, finalizeAtomicRename } from '../utils/atomic_fs.mjs'
10
9
  import { withAsyncMutex } from '../utils/async_mutex.mjs'
10
+ import { atomicTemporaryPath, finalizeAtomicRename } from '../utils/atomic_fs.mjs'
11
11
 
12
+ /**
13
+ * 原子重命名 finalize(re-export)。
14
+ */
12
15
  export { finalizeAtomicRename } from '../utils/atomic_fs.mjs'
13
16
 
14
17
  /** 流式重写 JSONL 时分块写入的行数上限 */
@@ -20,7 +20,7 @@ export function registerChunkFetchWait(key, expectedHash, timeoutMs, options = {
20
20
  return {
21
21
  done: Promise.resolve(null),
22
22
  /**
23
- *
23
+ * 槽位已满时的空操作取消。
24
24
  */
25
25
  cancel: () => { },
26
26
  }
@@ -8,7 +8,7 @@ import { hashFromPubKeyHex } from '../core/entity_id.mjs'
8
8
  import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
9
9
 
10
10
  /**
11
- *
11
+ * entity 密钥撤销签名的域分隔前缀。
12
12
  */
13
13
  export const ENTITY_KEY_REVOKE_DOMAIN = 'fount-entity-key-revoke'
14
14
 
@@ -26,7 +26,7 @@ export function registerManifestFetchWait(key, expectedKey, timeoutMs) {
26
26
  return {
27
27
  done: Promise.resolve(null),
28
28
  /**
29
- *
29
+ * 槽位已满时的空操作取消。
30
30
  */
31
31
  cancel: () => { },
32
32
  }
@@ -57,7 +57,7 @@ export function registerManifestFetchWait(key, expectedKey, timeoutMs) {
57
57
  return {
58
58
  done,
59
59
  /**
60
- *
60
+ * 取消等待并以 null 结算。
61
61
  */
62
62
  cancel: () => {
63
63
  clearTimeout(timer)
@@ -76,7 +76,7 @@ function readStreamChunk(stream) {
76
76
  if (stream.readableEnded) return Promise.resolve(null)
77
77
  return new Promise((resolve, reject) => {
78
78
  /**
79
- *
79
+ * readable 时读取下一块密文。
80
80
  */
81
81
  const onReadable = () => {
82
82
  cleanup()
@@ -84,7 +84,7 @@ function readStreamChunk(stream) {
84
84
  resolve(next ? Buffer.from(next) : Buffer.alloc(0))
85
85
  }
86
86
  /**
87
- *
87
+ * 流结束时 resolve null。
88
88
  */
89
89
  const onEnd = () => {
90
90
  cleanup()
@@ -98,7 +98,7 @@ function readStreamChunk(stream) {
98
98
  reject(error)
99
99
  }
100
100
  /**
101
- *
101
+ * 注销流事件监听。
102
102
  */
103
103
  const cleanup = () => {
104
104
  stream.off('readable', onReadable)
@@ -130,7 +130,7 @@ export function createManifestPlaintextStream(manifest, partStreams, contentKey)
130
130
 
131
131
  return new Readable({
132
132
  /**
133
- *
133
+ * 按 manifest part 顺序解密密文块并推送明文。
134
134
  */
135
135
  async read() {
136
136
  if (finished) return
@@ -3,15 +3,24 @@ import { randomUUID } from 'node:crypto'
3
3
  import { bytesToBase64 } from '../core/bytes_codec.mjs'
4
4
  import {
5
5
  MAX_PENDING_CHUNK_FETCHES,
6
- pendingChunkFetches,
7
6
  registerChunkFetchWait,
8
7
  } from '../federation/chunk_fetch_pending.mjs'
8
+ import { ms } from '../utils/duration.mjs'
9
+ import { createInflightTable } from '../utils/inflight_table.mjs'
9
10
 
10
11
  import { verifiedChunkBytes } from './chunk_fetch_verify.mjs'
11
12
  import { fetchFederationChunk, resolveNodeHash } from './chunk_provider_registry.mjs'
12
13
  import { getChunk, hasChunk, putChunk } from './chunk_store.mjs'
13
14
  import { fanoutFedFetch } from './fetch_fanout.mjs'
14
15
 
16
+ const DEFAULT_CHUNK_FETCH_TIMEOUT_MS = ms('8s')
17
+
18
+ /** 同 username+hash 共享一次 fanout;队满只丢已超基础超时的队首。 */
19
+ const chunkInflight = createInflightTable({
20
+ maxSize: MAX_PENDING_CHUNK_FETCHES,
21
+ baseTimeoutMs: DEFAULT_CHUNK_FETCH_TIMEOUT_MS,
22
+ })
23
+
15
24
  /**
16
25
  * @typedef {{
17
26
  * username: string,
@@ -42,19 +51,27 @@ export async function fetchChunk(context) {
42
51
  }
43
52
  }
44
53
 
45
- if (pendingChunkFetches.size >= MAX_PENDING_CHUNK_FETCHES) return null
54
+ const inflightKey = `${username}\0${hash}`
55
+ const shared = chunkInflight.acquire(inflightKey, () => {
56
+ const requestId = randomUUID()
57
+ const wait = registerChunkFetchWait(requestId, hash, DEFAULT_CHUNK_FETCH_TIMEOUT_MS)
58
+ void (async () => {
59
+ try {
60
+ const { nodeHash } = await resolveNodeHash(username)
61
+ await fanoutFedFetch(username, 'fed_chunk_get', {
62
+ requestId,
63
+ nodeHash,
64
+ chunkHash: hash,
65
+ ownerEntityHash: context.ownerEntityHash,
66
+ })
67
+ }
68
+ catch { /* pending wait 超时/cancel 负责 settle */ }
69
+ })()
70
+ return { done: wait.done, cancel: wait.cancel }
71
+ })
72
+ if (!shared) return null
46
73
 
47
- const requestId = randomUUID()
48
- const { done } = registerChunkFetchWait(requestId, hash, 8000)
49
- const { nodeHash } = await resolveNodeHash(username)
50
- const payload = {
51
- requestId,
52
- nodeHash,
53
- chunkHash: hash,
54
- ownerEntityHash: context.ownerEntityHash,
55
- }
56
- await fanoutFedFetch(username, 'fed_chunk_get', payload)
57
- const result = await done
74
+ const result = await shared
58
75
  const verified = verifiedChunkBytes(hash, result)
59
76
  if (verified) {
60
77
  await putChunk(hash, verified)
@@ -99,7 +99,7 @@ export function attachTrustGraphFedChunkResponder(username, room, fedOut, guardG
99
99
  if (!String(data.requestId || '')) return
100
100
  await handleFedChunkGetIngress(username, data, peerId, (resp, pid) => {
101
101
  /**
102
- *
102
+ * 发送 fed_chunk_data 响应(可经 fedOut 限速队列)。
103
103
  */
104
104
  const send = () => {
105
105
  try { sendChunkData(resp, pid) }
@@ -123,7 +123,7 @@ export function attachTrustGraphFedChunkResponder(username, room, fedOut, guardG
123
123
  if (!String(data.requestId || '')) return
124
124
  await handleFedManifestGetIngress(username, data, peerId, (resp, pid) => {
125
125
  /**
126
- *
126
+ * 发送 fed_manifest_data 响应(可经 fedOut 限速队列)。
127
127
  */
128
128
  const send = () => {
129
129
  try { sendManifestData(resp, pid) }
@@ -3,11 +3,12 @@ import { randomUUID } from 'node:crypto'
3
3
  import {
4
4
  manifestFetchExpectedKey,
5
5
  MAX_PENDING_MANIFEST_FETCHES,
6
- pendingManifestFetches,
7
6
  registerManifestFetchWait,
8
7
  } from '../federation/manifest_fetch_pending.mjs'
9
8
  import { isWritableLocalEntity } from '../node/identity.mjs'
10
9
  import { getEntityStore } from '../node/instance.mjs'
10
+ import { ms } from '../utils/duration.mjs'
11
+ import { createInflightTable } from '../utils/inflight_table.mjs'
11
12
 
12
13
  import { resolveNodeHash } from './chunk_provider_registry.mjs'
13
14
  import { loadFileManifest, saveFileManifest } from './evfs.mjs'
@@ -15,11 +16,18 @@ import { fanoutFedFetch } from './fetch_fanout.mjs'
15
16
  import { normalizeFileManifest } from './manifest.mjs'
16
17
  import { shouldPreferIncomingPublicManifest } from './public_manifest.mjs'
17
18
 
18
- const DEFAULT_MANIFEST_FETCH_TIMEOUT_MS = 8000
19
+ const DEFAULT_MANIFEST_FETCH_TIMEOUT_MS = ms('8s')
20
+
21
+ /** 同 username+owner+path 共享一次 fanout;队满只丢已超基础超时的队首。 */
22
+ const manifestInflight = createInflightTable({
23
+ maxSize: MAX_PENDING_MANIFEST_FETCHES,
24
+ baseTimeoutMs: DEFAULT_MANIFEST_FETCH_TIMEOUT_MS,
25
+ })
19
26
 
20
27
  /**
21
28
  * 拉取公开 manifest;默认不写盘。`cache: true` 或 `cachePublicManifest` 才缓存。
22
29
  * 本地已有 publicSig 时仍会 fanout 再校验,按 publishedAt 择新;超时则回退本地。
30
+ * 同 key in-flight 去重;调用方外层超时不 abort,后台继续填缓存。
23
31
  * @param {{ username: string, ownerEntityHash: string, logicalPath: string, cache?: boolean, timeoutMs?: number }} context - 拉取上下文
24
32
  * @returns {Promise<import('./manifest.mjs').FileManifest | null>} 验签后的 manifest,失败为 null
25
33
  */
@@ -29,30 +37,37 @@ export async function fetchPublicManifest(context) {
29
37
  const { username } = context
30
38
  if (!ownerEntityHash || !logicalPath || !username) return null
31
39
 
32
- const local = await loadFileManifest(ownerEntityHash, logicalPath)
33
- const hasLocalPublic = local?.transferKeyDescriptor?.type === 'public' && !!local?.meta?.publicSig
34
-
35
- if (pendingManifestFetches.size >= MAX_PENDING_MANIFEST_FETCHES)
36
- return hasLocalPublic ? local : null
37
-
38
40
  const timeoutMs = Number(context.timeoutMs) > 0
39
41
  ? Number(context.timeoutMs)
40
42
  : DEFAULT_MANIFEST_FETCH_TIMEOUT_MS
41
- const requestId = randomUUID()
42
- const { done } = registerManifestFetchWait(
43
- requestId,
44
- manifestFetchExpectedKey(ownerEntityHash, logicalPath),
45
- timeoutMs,
46
- )
47
- const { nodeHash } = await resolveNodeHash(username)
48
- const payload = {
49
- requestId,
50
- nodeHash,
51
- ownerEntityHash,
52
- logicalPath,
53
- }
54
- await fanoutFedFetch(username, 'fed_manifest_get', payload)
55
- const result = await done
43
+ const expectedKey = manifestFetchExpectedKey(ownerEntityHash, logicalPath)
44
+ const inflightKey = `${username}\0${expectedKey}`
45
+
46
+ // 先挂 in-flight(同步),再读本地 — 避免并发调用在 await 间隙各自 start
47
+ const localPromise = loadFileManifest(ownerEntityHash, logicalPath)
48
+ const shared = manifestInflight.acquire(inflightKey, () => {
49
+ const requestId = randomUUID()
50
+ const wait = registerManifestFetchWait(requestId, expectedKey, timeoutMs)
51
+ void (async () => {
52
+ try {
53
+ const { nodeHash } = await resolveNodeHash(username)
54
+ await fanoutFedFetch(username, 'fed_manifest_get', {
55
+ requestId,
56
+ nodeHash,
57
+ ownerEntityHash,
58
+ logicalPath,
59
+ })
60
+ }
61
+ catch { /* pending wait 超时/cancel 负责 settle */ }
62
+ })()
63
+ return { done: wait.done, cancel: wait.cancel }
64
+ })
65
+
66
+ const local = await localPromise
67
+ const hasLocalPublic = local?.transferKeyDescriptor?.type === 'public' && !!local?.meta?.publicSig
68
+ if (!shared) return hasLocalPublic ? local : null
69
+
70
+ const result = await shared
56
71
 
57
72
  if (result && (!hasLocalPublic || shouldPreferIncomingPublicManifest(local, result))) {
58
73
  if (context.cache === true)
@@ -75,35 +75,23 @@ export function pickChannel(action, byteLength) {
75
75
  }
76
76
 
77
77
  /**
78
- * 读取 RTC 数据通道当前 bufferedAmount,失败时返回 0
78
+ * 读取 RTC 数据通道当前 bufferedAmount。
79
79
  * @param {RTCDataChannel} channel RTC 数据通道
80
80
  * @returns {number} 缓冲区待发送字节数
81
81
  */
82
82
  export function readBufferedAmount(channel) {
83
- try {
84
- return Number.isFinite(channel?.bufferedAmount) ? Number(channel.bufferedAmount) : 0
85
- }
86
- catch {
87
- return 0
88
- }
83
+ return channel.bufferedAmount
89
84
  }
90
85
 
91
86
  /**
92
87
  * 配置数据通道的 bufferedAmountLowThreshold。
93
88
  * @param {RTCDataChannel} channel RTC 数据通道
94
89
  * @param {number} [thresholdBytes=CHANNEL_LOW_THRESHOLD_BYTES] 低水位阈值(字节)
95
- * @returns {number | null} 实际生效的阈值,不支持时返回 null
90
+ * @returns {number} 实际生效的阈值
96
91
  */
97
92
  export function configureBufferedAmountLowThreshold(channel, thresholdBytes = CHANNEL_LOW_THRESHOLD_BYTES) {
98
- try {
99
- channel.bufferedAmountLowThreshold = thresholdBytes
100
- return Number.isFinite(channel.bufferedAmountLowThreshold)
101
- ? Number(channel.bufferedAmountLowThreshold)
102
- : null
103
- }
104
- catch {
105
- return null
106
- }
93
+ channel.bufferedAmountLowThreshold = thresholdBytes
94
+ return channel.bufferedAmountLowThreshold
107
95
  }
108
96
 
109
97
  /**
@@ -113,18 +101,9 @@ export function configureBufferedAmountLowThreshold(channel, thresholdBytes = CH
113
101
  * @returns {() => void} 取消订阅函数
114
102
  */
115
103
  export function onBufferedAmountLow(channel, callback) {
116
- /**
117
- * bufferedamountlow 事件处理函数。
118
- * @returns {void}
119
- */
120
- const handler = () => callback()
121
- channel.addEventListener?.('bufferedamountlow', handler)
122
- channel.onbufferedamountlow = handler
123
- if (channel.bufferedAmountLow?.subscribe)
124
- channel.bufferedAmountLow.subscribe(handler)
104
+ channel.onbufferedamountlow = callback
125
105
  return () => {
126
- channel.removeEventListener?.('bufferedamountlow', handler)
127
- if (channel.onbufferedamountlow === handler) channel.onbufferedamountlow = null
106
+ if (channel.onbufferedamountlow === callback) channel.onbufferedamountlow = null
128
107
  }
129
108
  }
130
109
 
@@ -163,12 +142,12 @@ export function createChannelSendQueues(options) {
163
142
  const channel = getChannel(channelName)
164
143
  if (channel?.readyState !== 'open') return
165
144
  const queue = queues[channelName]
166
- let i = 0
167
- while (i < queue.length) {
145
+ let sent = 0
146
+ while (sent < queue.length) {
168
147
  if (readBufferedAmount(channel) > highWatermarkBytes) break
169
- channel.send(queue[i++].bytes)
148
+ channel.send(queue[sent++].bytes)
170
149
  }
171
- if (i > 0) queue.splice(0, i)
150
+ if (sent > 0) queue.splice(0, sent)
172
151
  }
173
152
 
174
153
  return {
@@ -1,3 +1,4 @@
1
+ import { toBytes } from '../../core/bytes_codec.mjs'
1
2
  import { getSignalingRuntimeConfig } from '../../node/instance.mjs'
2
3
  import { nodeDebug } from '../../node/log.mjs'
3
4
  import { ms } from '../../utils/duration.mjs'
@@ -11,20 +12,12 @@ import {
11
12
  onBufferedAmountLow,
12
13
  } from '../channel_mux.mjs'
13
14
  import { asLinkHandle, createLinkPipe } from '../pipe.mjs'
14
- import {
15
- attachChannelMessageListener,
16
- attachDataChannelListener,
17
- attachIceCandidateListener,
18
- loadNodeRtcPolyfill,
19
- dataToBytes,
20
- waitForChannelState,
21
- } from '../rtc.mjs'
15
+ import { loadNodeRtcPolyfill, waitForChannelState } from '../rtc/index.mjs'
22
16
  import { extractDtlsFingerprint } from '../sdp_fingerprint.mjs'
23
17
 
24
18
  import { LINK_LEVEL_WEBRTC } from './levels.mjs'
25
19
 
26
20
  /**
27
- * 将错误对象格式化为短字符串。
28
21
  * @param {unknown} error 原始错误
29
22
  * @returns {string} 短 reason
30
23
  */
@@ -35,27 +28,21 @@ function formatErrorReason(error) {
35
28
  let cachedAvailable = null
36
29
 
37
30
  /**
38
- * @returns {boolean} 是否跑在 Deno
39
- */
40
- function isDenoRuntime() {
41
- return typeof globalThis.Deno !== 'undefined'
42
- }
43
-
44
- /**
45
- * 探测 WebRTC(node-datachannel)是否可用。
31
+ * 探测 WebRTC(node-datachannel 或纯 JS fallback)是否可用。
46
32
  * @returns {Promise<boolean>} 可用为 true
47
33
  */
48
34
  export async function canUseWebRtcLink() {
49
35
  if (cachedAvailable !== null) return cachedAvailable
50
36
  try {
51
- await loadNodeRtcPolyfill()
37
+ const rtc = await loadNodeRtcPolyfill()
52
38
  cachedAvailable = true
39
+ nodeDebug('p2p:webrtc backend', { backend: rtc.backend })
53
40
  }
54
41
  catch (error) {
55
42
  cachedAvailable = false
56
43
  const reason = formatErrorReason(error)
57
44
  nodeDebug('p2p:webrtc unavailable', {
58
- err: isDenoRuntime()
45
+ err: typeof globalThis.Deno !== 'undefined'
59
46
  ? `${reason} | deno: see docs/runtime.md (node-datachannel scripts only)`
60
47
  : reason,
61
48
  })
@@ -73,15 +60,16 @@ export async function canUseWebRtcLink() {
73
60
  * @param {number} [options.heartbeatMs] 心跳间隔
74
61
  * @param {number} [options.idleTimeoutMs] 无入站流量超时
75
62
  * @param {number} [options.handshakeTimeoutMs] 握手超时
76
- * @param {{ RTCPeerConnection: typeof RTCPeerConnection } | null} [options.rtc] RTC 构造器
63
+ * @param {import('../rtc/polyfill.mjs').LoadedRtcPolyfill} [options.rtc] RTC 构造器
77
64
  * @param {{ nodeHash?: string, nodePubKey?: string, secretKey?: Uint8Array, nonce?: string } | null} [options.localIdentity] 本地握手身份
78
65
  * @returns {Promise<import('./index.mjs').LinkHandle>} link 句柄
79
66
  */
80
67
  export async function createWebRtcLink(options) {
81
68
  const handshakeTimeoutMs = Number(options.handshakeTimeoutMs) || ms('10s')
82
69
  const channelOpenTimeoutMs = Math.max(handshakeTimeoutMs, ms('30s'))
83
- const trickleIceOff = getSignalingRuntimeConfig().trickleIceOff === true
84
70
  const rtc = options.rtc ?? await loadNodeRtcPolyfill()
71
+ // 纯 JS 后端只做 trickle(SDP 不含 candidate);强制开启 trickle。
72
+ const trickleIceOff = !rtc.forcesTrickleIce && getSignalingRuntimeConfig().trickleIceOff === true
85
73
  const peerConnection = new rtc.RTCPeerConnection(options.iceServers?.length ? { iceServers: options.iceServers } : undefined)
86
74
  const remoteSignalQueue = []
87
75
  const seenRemoteSignals = createLruMap(1024)
@@ -187,14 +175,6 @@ export async function createWebRtcLink(options) {
187
175
  await flushQueuedIceCandidates()
188
176
  }
189
177
 
190
- /**
191
- * @param {unknown} error addIceCandidate 错误
192
- * @returns {boolean} 是否应暂存后重试
193
- */
194
- function shouldRetryQueuedIce(error) {
195
- return /without ice transport/i.test(String(error?.message ?? error ?? ''))
196
- }
197
-
198
178
  /**
199
179
  * @returns {Promise<void>}
200
180
  */
@@ -216,7 +196,7 @@ export async function createWebRtcLink(options) {
216
196
  await peerConnection.addIceCandidate(candidate)
217
197
  }
218
198
  catch (error) {
219
- if (shouldRetryQueuedIce(error)) {
199
+ if (/without ice transport/i.test(String(error?.message ?? error))) {
220
200
  remoteSignalQueue.unshift(candidate)
221
201
  return
222
202
  }
@@ -261,7 +241,7 @@ export async function createWebRtcLink(options) {
261
241
  await peerConnection.addIceCandidate(message.candidate)
262
242
  }
263
243
  catch (error) {
264
- if (shouldRetryQueuedIce(error)) {
244
+ if (/without ice transport/i.test(String(error?.message ?? error))) {
265
245
  remoteSignalQueue.push(message.candidate)
266
246
  return
267
247
  }
@@ -272,19 +252,23 @@ export async function createWebRtcLink(options) {
272
252
 
273
253
  /**
274
254
  * @param {RTCDataChannel} channel RTC 数据通道
275
- * @returns {Promise<void>}
255
+ * @returns {void}
276
256
  */
277
- async function attachChannel(channel) {
257
+ function attachChannel(channel) {
278
258
  if (channel.label === CHANNEL_CONTROL) controlChannel = channel
279
259
  else if (channel.label === CHANNEL_BULK) bulkChannel = channel
280
260
  else return
281
- attachChannelMessageListener(channel, data => {
261
+ /**
262
+ * @param {MessageEvent} event 入站消息事件
263
+ * @returns {void}
264
+ */
265
+ channel.onmessage = event => {
282
266
  try {
283
- if (typeof data === 'string') pipe.handleInbound(data)
284
- else pipe.handleInbound(dataToBytes(data))
267
+ const data = event.data
268
+ pipe.handleInbound(typeof data === 'string' ? data : toBytes(data, { allowString: true }))
285
269
  }
286
270
  catch { /* drop */ }
287
- })
271
+ }
288
272
  attachBackpressurePump(channel)
289
273
  }
290
274
 
@@ -312,20 +296,29 @@ export async function createWebRtcLink(options) {
312
296
  void handleRemoteSignal(message).catch(error => pipe.close(`signal-error:${formatErrorReason(error)}`))
313
297
  }) ?? null
314
298
 
315
- attachIceCandidateListener(peerConnection, event => {
299
+ /**
300
+ * @param {RTCPeerConnectionIceEvent} event ICE candidate 事件
301
+ * @returns {void}
302
+ */
303
+ peerConnection.onicecandidate = event => {
316
304
  if (trickleIceOff || !event.candidate) return
317
305
  void sendSignal({
318
306
  type: 'ice',
319
307
  candidate: typeof event.candidate.toJSON === 'function' ? event.candidate.toJSON() : event.candidate,
320
308
  }).catch(error => pipe.close(`signal-send-failed:${formatErrorReason(error)}`))
321
- })
322
- attachDataChannelListener(peerConnection, event => {
323
- void attachChannel(event.channel)
324
- .then(() => maybeStartPostOpenFlow())
325
- .catch(error => pipe.close(`channel-attach-failed:${error?.message ?? error}`))
326
- })
309
+ }
310
+ /**
311
+ * @param {RTCDataChannelEvent} event 远端 data channel 事件
312
+ * @returns {void}
313
+ */
314
+ peerConnection.ondatachannel = event => {
315
+ attachChannel(event.channel)
316
+ void maybeStartPostOpenFlow().catch(error => pipe.close(`channel-attach-failed:${formatErrorReason(error)}`))
317
+ }
327
318
 
328
- /** 连接失败/关闭时关掉 pipe。 */
319
+ /**
320
+ * 连接失败/断开时递增重连计数并关闭 pipe。
321
+ */
329
322
  peerConnection.onconnectionstatechange = () => {
330
323
  if (['failed', 'closed', 'disconnected'].includes(peerConnection.connectionState)) {
331
324
  reconnectCount++
@@ -334,8 +327,8 @@ export async function createWebRtcLink(options) {
334
327
  }
335
328
 
336
329
  if (options.initiator) {
337
- await attachChannel(peerConnection.createDataChannel(CHANNEL_CONTROL))
338
- await attachChannel(peerConnection.createDataChannel(CHANNEL_BULK))
330
+ attachChannel(peerConnection.createDataChannel(CHANNEL_CONTROL))
331
+ attachChannel(peerConnection.createDataChannel(CHANNEL_BULK))
339
332
  const offer = await peerConnection.createOffer()
340
333
  await peerConnection.setLocalDescription(offer)
341
334
  await waitForIceGatheringComplete()
@@ -345,7 +338,7 @@ export async function createWebRtcLink(options) {
345
338
  })
346
339
  }
347
340
 
348
- void maybeStartPostOpenFlow().catch(error => pipe.close(`open-flow-failed:${error?.message ?? error}`))
341
+ void maybeStartPostOpenFlow().catch(error => pipe.close(`open-flow-failed:${formatErrorReason(error)}`))
349
342
 
350
343
  return asLinkHandle(pipe, {
351
344
  /**
@@ -353,7 +346,7 @@ export async function createWebRtcLink(options) {
353
346
  * @param {'control' | 'bulk'} name 通道名
354
347
  * @returns {RTCDataChannel | null} 测试用通道
355
348
  */
356
- _channelForTest(name) {
349
+ channelForTest(name) {
357
350
  return name === CHANNEL_CONTROL ? controlChannel : bulkChannel
358
351
  },
359
352
  })
@@ -0,0 +1,41 @@
1
+ /**
2
+ * 等待 data channel 进入 open 或 close 状态,超时则 reject。
3
+ * 同一 channel 可并发等待:新订阅链式调用已有 onopen/onclose,cleanup 只摘掉自身。
4
+ * @param {RTCDataChannel} channel RTC 数据通道
5
+ * @param {'open' | 'close'} eventName 目标状态事件名
6
+ * @param {number} timeoutMs 超时毫秒数
7
+ * @returns {Promise<void>}
8
+ */
9
+ export function waitForChannelState(channel, eventName, timeoutMs) {
10
+ return new Promise((resolve, reject) => {
11
+ if (eventName === 'open' && channel.readyState === 'open') return resolve()
12
+ if (eventName === 'close' && channel.readyState === 'closed') return resolve()
13
+ const eventHandlerProperty = eventName === 'open' ? 'onopen' : 'onclose'
14
+ const previousHandler = channel[eventHandlerProperty]
15
+ let active = true
16
+ const timer = setTimeout(() => {
17
+ cleanup()
18
+ reject(new Error(`p2p: data channel ${eventName} timeout after ${timeoutMs}ms`))
19
+ }, timeoutMs)
20
+ /**
21
+ * 清除超时并恢复 data channel 事件 handler。
22
+ */
23
+ const cleanup = () => {
24
+ if (!active) return
25
+ active = false
26
+ clearTimeout(timer)
27
+ if (channel[eventHandlerProperty] === chained) channel[eventHandlerProperty] = previousHandler
28
+ }
29
+ /**
30
+ * @param {...any} eventArguments 原 handler 参数
31
+ * @returns {void}
32
+ */
33
+ const chained = (...eventArguments) => {
34
+ previousHandler?.(...eventArguments)
35
+ if (!active) return
36
+ cleanup()
37
+ resolve()
38
+ }
39
+ channel[eventHandlerProperty] = chained
40
+ })
41
+ }