@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.
- package/core/tcp_port.mjs +11 -0
- package/discovery/advert_peer_hints.mjs +21 -0
- package/discovery/{bt.mjs → bt/index.mjs} +159 -55
- package/discovery/bt/peer_hints.mjs +41 -0
- package/discovery/bt/runtime.mjs +46 -0
- package/discovery/index.mjs +9 -10
- package/discovery/lan_peer_hints.mjs +43 -0
- package/discovery/mdns.mjs +6 -2
- package/index.mjs +3 -2
- package/link/handshake.mjs +48 -21
- package/link/pipe.mjs +404 -0
- package/link/providers/ble_gatt.mjs +339 -0
- package/link/providers/index.mjs +90 -0
- package/link/providers/lan_tcp.mjs +342 -0
- package/link/providers/levels.mjs +9 -0
- package/link/providers/webrtc.mjs +385 -0
- package/package.json +3 -2
- package/rooms/scoped_link.mjs +5 -3
- package/transport/group_link_set.mjs +5 -3
- package/transport/link_registry.mjs +241 -54
- package/utils/ttl_map.mjs +41 -0
- package/link/link.mjs +0 -617
package/link/handshake.mjs
CHANGED
|
@@ -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}
|
|
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,
|
|
38
|
+
export function buildAuthMessage(peerNonce, localBinding, localNodeHash) {
|
|
23
39
|
const nonce = normalizeHex64(peerNonce)
|
|
24
|
-
const
|
|
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 (!
|
|
29
|
-
throw new Error('p2p:
|
|
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${
|
|
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}
|
|
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,
|
|
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,
|
|
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}
|
|
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,
|
|
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
|
|
109
|
-
if (!/^[\da-f]{128}$/u.test(signatureHex) || !
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|