@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.
@@ -2,34 +2,50 @@ import { Buffer } from 'node:buffer'
2
2
  import { randomBytes } from 'node:crypto'
3
3
 
4
4
  import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
5
+ import { normalizeTcpPort } from '../core/tcp_port.mjs'
5
6
  import { keyPairFromSeed, pubKeyHash, sign, verify } from '../crypto/crypto.mjs'
6
7
  import { ensureNodeSeed, getNodeHash } from '../node/identity.mjs'
7
8
 
8
9
  import { normalizeDtlsFingerprint } from './sdp_fingerprint.mjs'
9
10
 
11
+ /** advert / peer-hint 端口规范化(re-export,调用方可直接从 core/tcp_port 导入) */
12
+ export const normalizeAdvertTcpPort = normalizeTcpPort
13
+
10
14
  /**
11
15
  * Link 握手签名域标识符。
12
16
  */
13
17
  export const LINK_HANDSHAKE_DOMAIN = 'fount-link'
14
18
 
19
+ /**
20
+ * 规范化链路绑定材料:DTLS fingerprint 或 64-hex linkId。
21
+ * @param {unknown} value 原始 binding
22
+ * @returns {string | null} 规范化 binding,无效时 null
23
+ */
24
+ export function normalizeLinkBinding(value) {
25
+ const dtls = normalizeDtlsFingerprint(value)
26
+ if (dtls) return dtls
27
+ const hex = normalizeHex64(value)
28
+ return isHex64(hex) ? hex : null
29
+ }
30
+
15
31
  /**
16
32
  * 构造 link auth 待签名字节串。
17
33
  * @param {string} peerNonce 对端 hello 中的 nonce(64 位 hex)
18
- * @param {string} localFingerprint 本地 DTLS fingerprint
34
+ * @param {string} localBinding 本地绑定材料(DTLS fingerprint 或 linkId)
19
35
  * @param {string} localNodeHash 本地节点 nodeHash(64 位 hex)
20
36
  * @returns {Uint8Array} 待签名消息字节
21
37
  */
22
- export function buildAuthMessage(peerNonce, localFingerprint, localNodeHash) {
38
+ export function buildAuthMessage(peerNonce, localBinding, localNodeHash) {
23
39
  const nonce = normalizeHex64(peerNonce)
24
- const fingerprint = normalizeDtlsFingerprint(localFingerprint)
40
+ const binding = normalizeLinkBinding(localBinding)
25
41
  const nodeHash = normalizeHex64(localNodeHash)
26
42
  if (!/^[\da-f]{64}$/u.test(nonce))
27
43
  throw new Error('p2p: auth nonce must be 64 hex characters')
28
- if (!fingerprint)
29
- throw new Error('p2p: DTLS fingerprint missing or invalid')
44
+ if (!binding)
45
+ throw new Error('p2p: link binding missing or invalid')
30
46
  if (!isHex64(nodeHash))
31
47
  throw new Error('p2p: nodeHash must be 64 hex characters')
32
- return Buffer.from(`${LINK_HANDSHAKE_DOMAIN}\0${nonce}\0${fingerprint}\0${nodeHash}`, 'utf8')
48
+ return Buffer.from(`${LINK_HANDSHAKE_DOMAIN}\0${nonce}\0${binding}\0${nodeHash}`, 'utf8')
33
49
  }
34
50
 
35
51
  /**
@@ -56,11 +72,11 @@ export function buildHello(options = {}) {
56
72
  /**
57
73
  * 对 link auth 消息签名。
58
74
  * @param {string} peerNonce 对端 hello 中的 nonce
59
- * @param {string} localFingerprint 本地 DTLS fingerprint
75
+ * @param {string} localBinding 本地绑定材料(DTLS fingerprint 或 linkId)
60
76
  * @param {{ secretKey?: Uint8Array, nodeHash?: string }} [options] 签名密钥与 nodeHash 覆盖
61
77
  * @returns {Promise<{ sig: string }>} hex 签名
62
78
  */
63
- export async function buildAuth(peerNonce, localFingerprint, options = {}) {
79
+ export async function buildAuth(peerNonce, localBinding, options = {}) {
64
80
  const seed = options.secretKey
65
81
  ? Buffer.from(options.secretKey)
66
82
  : Buffer.from(ensureNodeSeed(), 'hex')
@@ -68,7 +84,7 @@ export async function buildAuth(peerNonce, localFingerprint, options = {}) {
68
84
  const nodeHash = normalizeHex64(options.nodeHash || pubKeyHash(publicKey))
69
85
  if (nodeHash !== pubKeyHash(publicKey))
70
86
  throw new Error('p2p: auth nodeHash does not match secretKey')
71
- const message = buildAuthMessage(peerNonce, localFingerprint, nodeHash)
87
+ const message = buildAuthMessage(peerNonce, localBinding, nodeHash)
72
88
  const signature = await sign(message, secretKey)
73
89
  return { sig: Buffer.from(signature).toString('hex') }
74
90
  }
@@ -98,18 +114,18 @@ export function parseHello(hello) {
98
114
  * @param {unknown} hello 对端 hello
99
115
  * @param {unknown} auth 对端 auth(含 sig)
100
116
  * @param {string} expectedNonce 本地 hello 发出的 nonce
101
- * @param {string} remoteFingerprintFromSdp SDP 提取的远端 DTLS fingerprint
117
+ * @param {string} remoteBinding 对端绑定材料(DTLS fingerprint linkId)
102
118
  * @returns {Promise<string | null>} 验证通过的 nodeHash,失败返回 null
103
119
  */
104
- export async function verifyAuth(hello, auth, expectedNonce, remoteFingerprintFromSdp) {
120
+ export async function verifyAuth(hello, auth, expectedNonce, remoteBinding) {
105
121
  const parsedHello = parseHello(hello)
106
122
  if (!parsedHello) return null
107
123
  const signatureHex = String(auth?.sig ?? '').trim().toLowerCase()
108
- const fingerprint = normalizeDtlsFingerprint(remoteFingerprintFromSdp)
109
- if (!/^[\da-f]{128}$/u.test(signatureHex) || !fingerprint) return null
124
+ const binding = normalizeLinkBinding(remoteBinding)
125
+ if (!/^[\da-f]{128}$/u.test(signatureHex) || !binding) return null
110
126
  const normalizedNonce = normalizeHex64(expectedNonce)
111
127
  if (!isHex64(normalizedNonce)) return null
112
- const message = buildAuthMessage(normalizedNonce, fingerprint, parsedHello.nodeHash)
128
+ const message = buildAuthMessage(normalizedNonce, binding, parsedHello.nodeHash)
113
129
  const ok = await verify(
114
130
  Buffer.from(signatureHex, 'hex'),
115
131
  message,
@@ -123,18 +139,21 @@ export async function verifyAuth(hello, auth, expectedNonce, remoteFingerprintFr
123
139
  * @param {string} topic 广播主题
124
140
  * @param {number} ts 时间戳(毫秒)
125
141
  * @param {string} nodeHash 节点 nodeHash
142
+ * @param {number | null} [tcpPort=null] 可选 LAN TCP 监听端口(签入消息)
126
143
  * @returns {Uint8Array} 待签名消息字节
127
144
  */
128
- export function buildAdvertMessage(topic, ts, nodeHash) {
129
- return Buffer.from(`fount-advert\0${String(topic)}\0${String(ts)}\0${normalizeHex64(nodeHash)}`, 'utf8')
145
+ export function buildAdvertMessage(topic, ts, nodeHash, tcpPort = null) {
146
+ const base = `fount-advert\0${String(topic)}\0${String(ts)}\0${normalizeHex64(nodeHash)}`
147
+ const port = normalizeTcpPort(tcpPort)
148
+ return Buffer.from(port == null ? base : `${base}\0${port}`, 'utf8')
130
149
  }
131
150
 
132
151
  /**
133
152
  * 构造带签名的 discovery advert。
134
153
  * @param {string} topic 广播主题
135
154
  * @param {number} [ts=Date.now()] 时间戳(毫秒)
136
- * @param {{ secretKey?: Uint8Array, nodeHash?: string, nodePubKey?: string } | null} [options] 签名身份,省略则用本地节点
137
- * @returns {Promise<{ nodeHash: string, nodePubKey: string, ts: number, sig: string }>} 签名 advert
155
+ * @param {{ secretKey?: Uint8Array, nodeHash?: string, nodePubKey?: string, tcpPort?: number } | null} [options] 签名身份与可选 tcpPort
156
+ * @returns {Promise<{ nodeHash: string, nodePubKey: string, ts: number, sig: string, tcpPort?: number }>} 签名 advert
138
157
  */
139
158
  export async function buildSignedAdvert(topic, ts = Date.now(), options = null) {
140
159
  const seed = options?.secretKey
@@ -145,14 +164,19 @@ export async function buildSignedAdvert(topic, ts = Date.now(), options = null)
145
164
  const nodePubKey = normalizeHex64(options?.nodePubKey || Buffer.from(publicKey).toString('hex'))
146
165
  if (pubKeyHash(Buffer.from(nodePubKey, 'hex')) !== nodeHash)
147
166
  throw new Error('p2p: advert nodePubKey does not match nodeHash')
148
- const message = buildAdvertMessage(topic, ts, nodeHash)
167
+ const tcpPort = normalizeTcpPort(options?.tcpPort)
168
+ if (options?.tcpPort != null && options.tcpPort !== '' && tcpPort == null)
169
+ throw new Error('p2p: advert tcpPort invalid')
170
+ const message = buildAdvertMessage(topic, ts, nodeHash, tcpPort)
149
171
  const sig = await sign(message, secretKey)
150
- return {
172
+ const advert = {
151
173
  nodeHash,
152
174
  nodePubKey,
153
175
  ts,
154
176
  sig: Buffer.from(sig).toString('hex'),
155
177
  }
178
+ if (tcpPort != null) advert.tcpPort = tcpPort
179
+ return advert
156
180
  }
157
181
 
158
182
  /**
@@ -169,7 +193,10 @@ export async function verifySignedAdvert(topic, advert, now = Date.now(), maxSke
169
193
  const ts = Number(advert?.ts)
170
194
  const sig = String(advert?.sig ?? '').trim().toLowerCase()
171
195
  if (!Number.isFinite(ts) || Math.abs(now - ts) > maxSkewMs || !/^[\da-f]{128}$/u.test(sig)) return null
172
- const message = buildAdvertMessage(topic, ts, parsedHello.nodeHash)
196
+ const hasTcpPortField = advert?.tcpPort != null && advert.tcpPort !== ''
197
+ const tcpPort = normalizeTcpPort(advert?.tcpPort)
198
+ if (hasTcpPortField && tcpPort == null) return null
199
+ const message = buildAdvertMessage(topic, ts, parsedHello.nodeHash, tcpPort)
173
200
  const ok = await verify(Buffer.from(sig, 'hex'), message, Buffer.from(parsedHello.nodePubKey, 'hex'))
174
201
  return ok ? parsedHello.nodeHash : null
175
202
  }
package/link/pipe.mjs ADDED
@@ -0,0 +1,404 @@
1
+ import { normalizeHex64 } from '../core/hexIds.mjs'
2
+ import { ms } from '../utils/duration.mjs'
3
+ import { createLruMap } from '../utils/lru.mjs'
4
+
5
+ import { createReassembler, encodeFrames, randomMsgIdHex } from './frame.mjs'
6
+ import { buildAuth, buildHello, parseHello, verifyAuth } from './handshake.mjs'
7
+
8
+ const encoder = new TextEncoder()
9
+ const decoder = new TextDecoder()
10
+
11
+ /**
12
+ * 依次调用监听器集合,忽略单个 listener 抛错。
13
+ * @param {Set<Function>} listeners 监听器集合
14
+ * @param {...unknown} args 传递给 listener 的参数
15
+ * @returns {void}
16
+ */
17
+ function emitListeners(listeners, ...args) {
18
+ for (const listener of listeners)
19
+ try { listener(...args) }
20
+ catch { /* ignore */ }
21
+ }
22
+
23
+ /**
24
+ * 原始字节:以 `{` 开头的 UTF-8 当 control 文本,否则当二进制帧。
25
+ * @param {Buffer | Uint8Array | string} raw 原始数据
26
+ * @returns {string | Uint8Array} control 文本或二进制帧
27
+ */
28
+ export function coercePipeInbound(raw) {
29
+ if (typeof raw === 'string') return raw
30
+ const bytes = raw instanceof Uint8Array ? raw : Uint8Array.from(raw)
31
+ try {
32
+ const text = decoder.decode(bytes)
33
+ if (text.startsWith('{')) return text
34
+ }
35
+ catch { /* binary */ }
36
+ return bytes
37
+ }
38
+
39
+ /**
40
+ * 把 createLinkPipe 句柄收成上层 LinkHandle(可附带测试/内部字段)。
41
+ * @param {ReturnType<typeof createLinkPipe>} pipe pipe
42
+ * @param {object} [extras] 附加字段(如 handleInbound、_channelForTest)
43
+ * @returns {object} LinkHandle 形状
44
+ */
45
+ export function asLinkHandle(pipe, extras = {}) {
46
+ return {
47
+ ready: pipe.ready,
48
+ /** @returns {string | null} 对端 nodeHash */
49
+ get nodeHash() { return pipe.nodeHash },
50
+ /** @returns {boolean} 是否发起方 */
51
+ get initiator() { return pipe.initiator },
52
+ /** @returns {string} 提供者 id */
53
+ get providerId() { return pipe.providerId },
54
+ /** @returns {number} 提供者 level */
55
+ get level() { return pipe.level },
56
+ /**
57
+ * @param {...unknown} args send 参数
58
+ * @returns {Promise<boolean>} 是否发送成功
59
+ */
60
+ send: (...args) => pipe.send(...args),
61
+ /**
62
+ * @param {...unknown} args onEnvelope 参数
63
+ * @returns {() => void} 取消订阅
64
+ */
65
+ onEnvelope: (...args) => pipe.onEnvelope(...args),
66
+ /**
67
+ * @param {...unknown} args onDown 参数
68
+ * @returns {() => void} 取消订阅
69
+ */
70
+ onDown: (...args) => pipe.onDown(...args),
71
+ /**
72
+ * @param {...unknown} args close 参数
73
+ * @returns {Promise<void>}
74
+ */
75
+ close: (...args) => pipe.close(...args),
76
+ /** @returns {object} 运行时统计 */
77
+ stats: () => pipe.stats(),
78
+ ...extras,
79
+ }
80
+ }
81
+
82
+ /**
83
+ * 在已打开的字节/控制双工上跑 hello/auth、分帧 envelope 与心跳。
84
+ * @param {object} options pipe 配置
85
+ * @param {string} options.providerId 提供者 id
86
+ * @param {number} options.level 提供者 level
87
+ * @param {boolean} options.initiator 是否发起方
88
+ * @param {string | null} [options.nodeHash] 期望对端 nodeHash
89
+ * @param {{ nodeHash?: string, nodePubKey?: string, secretKey?: Uint8Array, nonce?: string } | null} [options.localIdentity] 本地身份
90
+ * @param {() => string | null} options.getLocalBinding 本地 binding(未就绪返回 null)
91
+ * @param {() => string | null} options.getRemoteBinding 远端 binding(未就绪返回 null)
92
+ * @param {(text: string) => void | Promise<void>} options.sendControlText 发送 control JSON 文本
93
+ * @param {(action: string, frame: Uint8Array) => void | Promise<void>} options.sendFrame 发送分帧二进制
94
+ * @param {() => void | Promise<void>} [options.closeTransport] 关闭底层传输
95
+ * @param {() => object} [options.extraStats] 附加 stats 字段
96
+ * @param {number} [options.heartbeatMs] 心跳间隔
97
+ * @param {number} [options.idleTimeoutMs] 空闲超时
98
+ * @param {number} [options.handshakeTimeoutMs] 握手超时
99
+ * @returns {object} link 句柄 + 入站 API
100
+ */
101
+ export function createLinkPipe(options) {
102
+ const providerId = String(options.providerId || '')
103
+ const level = Number(options.level) || 0
104
+ const heartbeatMs = Number(options.heartbeatMs) || ms('15s')
105
+ const idleTimeoutMs = Number(options.idleTimeoutMs) || ms('45s')
106
+ const handshakeTimeoutMs = Number(options.handshakeTimeoutMs) || ms('10s')
107
+ const targetNodeHash = normalizeHex64(options.nodeHash || '')
108
+ let closed = false
109
+ let ready = false
110
+ let closeReason = 'closed'
111
+ let remoteNodeHash = targetNodeHash || null
112
+ let remoteHello = null
113
+ let localHello = null
114
+ let handshakeTimer = null
115
+ let idleTimer = null
116
+ let heartbeatTimer = null
117
+ let helloSent = false
118
+ let authSent = false
119
+ let remoteAuthVerified = false
120
+ /** @type {object | null} */
121
+ let pendingAuth = null
122
+ let lastInboundAt = Date.now()
123
+ let lastOutboundAt = 0
124
+ let sentFrames = 0
125
+ let recvFrames = 0
126
+ const envelopeListeners = new Set()
127
+ const downListeners = new Set()
128
+ const completedMsgIds = createLruMap(4096)
129
+ const reassembler = createReassembler()
130
+ /** @type {(value: void | PromiseLike<void>) => void} */
131
+ let resolveReady
132
+ /** @type {(reason?: unknown) => void} */
133
+ let rejectReady
134
+ const readyPromise = new Promise((resolve, reject) => {
135
+ resolveReady = resolve
136
+ rejectReady = reject
137
+ })
138
+ void readyPromise.catch(() => { })
139
+
140
+ /**
141
+ * 经 control 发送 hello/auth JSON。
142
+ * @param {object} body hello 或 auth 字段
143
+ * @returns {Promise<void>}
144
+ */
145
+ async function sendRawControl(body) {
146
+ const text = JSON.stringify({ type: body.sig ? 'auth' : 'hello', ...body })
147
+ await Promise.resolve(options.sendControlText(text))
148
+ lastOutboundAt = Date.now()
149
+ }
150
+
151
+ /**
152
+ * binding 就绪后向对端发送 auth。
153
+ * @returns {Promise<void>}
154
+ */
155
+ async function maybeSendAuth() {
156
+ if (authSent || !remoteHello) return
157
+ const binding = options.getLocalBinding()
158
+ if (!binding) return
159
+ authSent = true
160
+ await sendRawControl(await buildAuth(remoteHello.nonce, binding, options.localIdentity ?? {}))
161
+ }
162
+
163
+ /**
164
+ * 握手完成后启动心跳并 resolve ready。
165
+ * @returns {Promise<void>}
166
+ */
167
+ async function maybeFinishHandshake() {
168
+ if (ready || !remoteHello || !remoteAuthVerified) return
169
+ ready = true
170
+ clearTimeout(handshakeTimer)
171
+ heartbeatTimer = setInterval(() => {
172
+ void send({ scope: 'link', action: 'ping', payload: {} }).catch(() => { })
173
+ }, heartbeatMs)
174
+ idleTimer = setInterval(() => {
175
+ if (Date.now() - lastInboundAt > idleTimeoutMs)
176
+ void close('idle-timeout')
177
+ }, Math.max(1000, Math.floor(heartbeatMs / 3)))
178
+ resolveReady()
179
+ }
180
+
181
+ /**
182
+ * 校验远端 auth。
183
+ * @param {{ sig: string }} auth auth 载荷
184
+ * @returns {Promise<void>}
185
+ */
186
+ async function handleAuth(auth) {
187
+ if (!remoteHello) {
188
+ pendingAuth = auth
189
+ return
190
+ }
191
+ const binding = options.getRemoteBinding()
192
+ const verifiedNodeHash = await verifyAuth(remoteHello, auth, localHello?.nonce, binding)
193
+ if (!verifiedNodeHash) {
194
+ await close(`auth-failed:binding=${binding || 'missing'} localNonce=${localHello?.nonce || 'missing'}`)
195
+ return
196
+ }
197
+ if (targetNodeHash && verifiedNodeHash !== targetNodeHash) {
198
+ await close('nodehash-mismatch')
199
+ return
200
+ }
201
+ remoteNodeHash = verifiedNodeHash
202
+ remoteAuthVerified = true
203
+ await maybeFinishHandshake()
204
+ }
205
+
206
+ /**
207
+ * 处理 control JSON(hello/auth)。
208
+ * @param {unknown} message 解析后的对象
209
+ * @returns {Promise<void>}
210
+ */
211
+ async function handleControlMessage(message) {
212
+ if (!message || typeof message !== 'object') return
213
+ if (message.type === 'hello') {
214
+ const parsed = parseHello(message)
215
+ if (!parsed) {
216
+ await close('hello-invalid')
217
+ return
218
+ }
219
+ remoteHello = parsed
220
+ await maybeSendAuth()
221
+ if (pendingAuth) {
222
+ const bufferedAuth = pendingAuth
223
+ pendingAuth = null
224
+ await handleAuth(bufferedAuth)
225
+ }
226
+ return
227
+ }
228
+ if (message.type === 'auth')
229
+ await handleAuth(message)
230
+ }
231
+
232
+ /**
233
+ * 处理入站二进制帧。
234
+ * @param {Uint8Array} bytes 帧字节
235
+ * @returns {void}
236
+ */
237
+ function handleBinaryFrame(bytes) {
238
+ try {
239
+ recvFrames++
240
+ const merged = reassembler.push(bytes)
241
+ if (!merged) return
242
+ const envelope = JSON.parse(decoder.decode(merged))
243
+ const msgId = envelope?.msgId ? String(envelope.msgId) : null
244
+ if (msgId && completedMsgIds.has(msgId)) return
245
+ if (msgId) completedMsgIds.touch(msgId, true)
246
+ if (envelope?.scope === 'link') {
247
+ if (envelope.action === 'ping') {
248
+ void send({ scope: 'link', action: 'pong', payload: {} }).catch(() => { })
249
+ return
250
+ }
251
+ if (envelope.action === 'pong') return
252
+ }
253
+ if (ready && remoteNodeHash)
254
+ emitListeners(envelopeListeners, envelope, remoteNodeHash)
255
+ }
256
+ catch {
257
+ /* drop malformed network ingress */
258
+ }
259
+ }
260
+
261
+ /**
262
+ * 统一入站:JSON control 或二进制帧。
263
+ * @param {unknown} data 原始数据
264
+ * @returns {void}
265
+ */
266
+ function handleInbound(data) {
267
+ lastInboundAt = Date.now()
268
+ if (typeof data === 'string') {
269
+ if (data.startsWith('{'))
270
+ try {
271
+ void handleControlMessage(JSON.parse(data))
272
+ return
273
+ }
274
+ catch { /* fall through */ }
275
+ try {
276
+ handleBinaryFrame(encoder.encode(data))
277
+ }
278
+ catch { /* ignore */ }
279
+ return
280
+ }
281
+ if (data instanceof ArrayBuffer || ArrayBuffer.isView(data) || data instanceof Uint8Array) {
282
+ const bytes = data instanceof Uint8Array
283
+ ? data
284
+ : data instanceof ArrayBuffer
285
+ ? new Uint8Array(data)
286
+ : new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
287
+ // 尝试 UTF-8 JSON control
288
+ try {
289
+ const text = decoder.decode(bytes)
290
+ if (text.startsWith('{')) {
291
+ void handleControlMessage(JSON.parse(text))
292
+ return
293
+ }
294
+ }
295
+ catch { /* binary path */ }
296
+ handleBinaryFrame(bytes)
297
+ }
298
+ }
299
+
300
+ /**
301
+ * 传输就绪后启动握手(发 hello)。
302
+ * @returns {Promise<void>}
303
+ */
304
+ async function startHandshake() {
305
+ if (helloSent || closed) return
306
+ handshakeTimer = setTimeout(() => { void close('handshake-timeout') }, handshakeTimeoutMs)
307
+ helloSent = true
308
+ localHello = buildHello(options.localIdentity ?? {})
309
+ await sendRawControl(localHello)
310
+ await maybeSendAuth()
311
+ }
312
+
313
+ /**
314
+ * 发送业务 envelope。
315
+ * @param {{ scope: string, action: string, payload: unknown }} envelope 信封
316
+ * @returns {Promise<boolean>} 是否发送成功
317
+ */
318
+ async function send(envelope) {
319
+ await readyPromise
320
+ if (closed) return false
321
+ const message = {
322
+ scope: String(envelope?.scope || ''),
323
+ action: String(envelope?.action || ''),
324
+ payload: envelope?.payload ?? null,
325
+ msgId: randomMsgIdHex(),
326
+ }
327
+ const bytes = encoder.encode(JSON.stringify(message))
328
+ for (const frame of encodeFrames(message.msgId, bytes)) {
329
+ await Promise.resolve(options.sendFrame(message.action, frame))
330
+ sentFrames++
331
+ }
332
+ lastOutboundAt = Date.now()
333
+ return true
334
+ }
335
+
336
+ /**
337
+ * 关闭 pipe 与底层传输。
338
+ * @param {string} [reason='closed'] 关闭原因
339
+ * @returns {Promise<void>}
340
+ */
341
+ async function close(reason = 'closed') {
342
+ if (closed) return
343
+ closed = true
344
+ closeReason = reason
345
+ clearTimeout(handshakeTimer)
346
+ if (heartbeatTimer) clearInterval(heartbeatTimer)
347
+ if (idleTimer) clearInterval(idleTimer)
348
+ try { await Promise.resolve(options.closeTransport?.()) } catch { /* ignore */ }
349
+ if (!ready) rejectReady(new Error(`p2p: link closed before ready (${reason})`))
350
+ emitListeners(downListeners, reason)
351
+ }
352
+
353
+ return {
354
+ ready: readyPromise,
355
+ /** @returns {string | null} 对端 nodeHash(握手后) */
356
+ get nodeHash() { return remoteNodeHash },
357
+ /** @returns {boolean} 是否发起方 */
358
+ get initiator() { return !!options.initiator },
359
+ /** @returns {string} 提供者 id */
360
+ get providerId() { return providerId },
361
+ /** @returns {number} 提供者 level */
362
+ get level() { return level },
363
+ send,
364
+ /**
365
+ * @param {(envelope: object, remoteNodeHash: string) => void} callback 回调
366
+ * @returns {() => void} 取消订阅
367
+ */
368
+ onEnvelope(callback) {
369
+ envelopeListeners.add(callback)
370
+ return () => envelopeListeners.delete(callback)
371
+ },
372
+ /**
373
+ * @param {(reason: string) => void} callback 回调
374
+ * @returns {() => void} 取消订阅
375
+ */
376
+ onDown(callback) {
377
+ downListeners.add(callback)
378
+ return () => downListeners.delete(callback)
379
+ },
380
+ close,
381
+ handleInbound,
382
+ startHandshake,
383
+ maybeSendAuth,
384
+ /**
385
+ * @returns {object} 运行时统计
386
+ */
387
+ stats() {
388
+ return {
389
+ ready,
390
+ providerId,
391
+ level,
392
+ nodeHash: remoteNodeHash,
393
+ targetNodeHash: targetNodeHash || null,
394
+ initiator: !!options.initiator,
395
+ lastInboundAt,
396
+ lastOutboundAt,
397
+ sentFrames,
398
+ recvFrames,
399
+ closeReason,
400
+ ...options.extraStats?.() ?? {},
401
+ }
402
+ },
403
+ }
404
+ }