@steve02081504/fount-p2p 0.0.11 → 0.0.13
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 +42 -11
- package/core/bytes_codec.mjs +65 -10
- package/core/composite_key.mjs +5 -5
- package/core/tcp_port.mjs +1 -1
- package/crypto/checkpoint_sign.mjs +2 -2
- package/crypto/crypto.mjs +3 -3
- package/crypto/key.mjs +12 -12
- package/dag/canonicalize_row.mjs +3 -3
- package/dag/index.mjs +2 -3
- package/dag/storage.mjs +36 -36
- package/discovery/advert_peer_hints.mjs +1 -1
- package/discovery/adverts.mjs +109 -0
- package/discovery/bt/index.mjs +154 -113
- package/discovery/bt/probe_child.mjs +2 -1
- package/discovery/bt/runtime.mjs +5 -12
- package/discovery/index.mjs +267 -62
- package/discovery/internal/signal_crypto.mjs +109 -0
- package/discovery/lan.mjs +228 -0
- package/discovery/nostr.mjs +430 -141
- package/federation/chunk_fetch_pending.mjs +14 -18
- package/federation/dag_order_cache.mjs +1 -1
- package/federation/entity_key_chain.mjs +3 -4
- package/federation/manifest_fetch_pending.mjs +1 -3
- package/federation/topo_order_memo.mjs +11 -4
- package/files/assemble.mjs +14 -14
- package/files/assemble_stream.mjs +6 -6
- package/files/chunk_fetch.mjs +2 -2
- package/files/chunk_responder.mjs +29 -20
- package/files/evfs.mjs +31 -30
- package/files/manifest_fetch.mjs +16 -3
- package/files/public_manifest.mjs +11 -11
- package/files/transfer_key_registry.mjs +2 -2
- package/index.mjs +86 -11
- package/infra/cli.mjs +62 -0
- package/infra/debug_log.mjs +56 -0
- package/infra/default_node_dir.mjs +22 -0
- package/infra/priority.mjs +56 -0
- package/infra/service.mjs +140 -0
- package/infra/tunables.json +5 -0
- package/link/channel_mux.mjs +10 -10
- package/link/frame.mjs +76 -131
- package/link/handshake.mjs +19 -20
- package/link/pipe.mjs +53 -79
- package/link/providers/ble_gatt.mjs +19 -31
- package/link/providers/lan_tcp.mjs +17 -32
- package/link/providers/link_id_pipe.mjs +46 -0
- package/link/providers/webrtc.mjs +42 -51
- package/link/rtc.mjs +24 -12
- package/mailbox/consumer_registry.mjs +2 -2
- package/mailbox/deliver_or_store.mjs +6 -6
- package/mailbox/wire.mjs +28 -24
- package/node/entity_store.mjs +6 -6
- package/node/identity.mjs +4 -4
- package/node/instance.mjs +46 -27
- package/node/local_data_revision.mjs +26 -0
- package/node/log.mjs +56 -0
- package/node/network.mjs +46 -14
- package/node/reputation_store.mjs +4 -4
- package/node/reputation_sync.mjs +318 -0
- package/node/routing_profile.mjs +21 -0
- package/node/signaling_config.mjs +30 -11
- package/overlay/index.mjs +35 -14
- package/package.json +13 -2
- package/rooms/scoped_link.mjs +17 -182
- package/schemas/discovery.mjs +1 -2
- package/schemas/federation_pull.mjs +2 -2
- package/schemas/part_query.mjs +1 -1
- package/schemas/remote_event.mjs +1 -1
- package/transport/advert_ingest.mjs +17 -0
- package/transport/group_link_set.mjs +165 -134
- package/transport/ice_servers.mjs +2 -2
- package/transport/link_registry.mjs +283 -124
- package/transport/mesh_keepalive.mjs +217 -0
- package/transport/node_scope.mjs +289 -0
- package/transport/offer_answer.mjs +48 -47
- package/transport/peer_pool.mjs +169 -78
- package/transport/remote_user_room.mjs +18 -23
- package/transport/rtc_connection_budget.mjs +18 -4
- package/transport/{rtc_mdns_filter.mjs → rtc_ice_local_hostname.mjs} +13 -13
- package/transport/runtime_bootstrap.mjs +172 -104
- package/transport/tunables.json +11 -0
- package/transport/tunables.mjs +18 -0
- package/transport/user_room.mjs +93 -161
- package/trust_graph/build.mjs +0 -2
- package/trust_graph/cache.mjs +12 -3
- package/trust_graph/registry.mjs +6 -6
- package/trust_graph/send.mjs +1 -4
- package/utils/async_mutex.mjs +4 -4
- package/utils/emit_safe.mjs +11 -0
- package/utils/json_io.mjs +12 -12
- package/utils/map_pool.mjs +5 -5
- package/utils/shuffle.mjs +13 -0
- package/utils/ttl_map.mjs +29 -4
- package/wire/ingress.mjs +1 -1
- package/wire/part_ingress.mjs +38 -36
- package/wire/part_invoke.mjs +4 -4
- package/wire/part_query.mjs +28 -23
- package/wire/part_query.tunables.json +6 -1
- package/wire/part_query_cache.mjs +1 -1
- package/wire/volatile_signature.mjs +1 -1
- package/discovery/mdns.mjs +0 -191
- package/transport/signal_crypto.mjs +0 -82
package/link/frame.mjs
CHANGED
|
@@ -1,90 +1,56 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
*/
|
|
6
|
-
export const
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
*/
|
|
10
|
-
export const FRAME_MSG_ID_BYTES = 16
|
|
11
|
-
/**
|
|
12
|
-
* 帧头字节长度:version(1) + msgId(16) + seq(4) + total(4)。
|
|
13
|
-
*/
|
|
14
|
-
export const FRAME_HEADER_BYTES = 1 + FRAME_MSG_ID_BYTES + 4 + 4
|
|
15
|
-
/**
|
|
16
|
-
* 默认单帧最大 chunk 大小(15 KiB)。
|
|
17
|
-
*/
|
|
3
|
+
import { bytesToHex, hexToBytes, toBytes } from '../core/bytes_codec.mjs'
|
|
4
|
+
|
|
5
|
+
/** frameId 字段字节长度(128 位)。 */
|
|
6
|
+
export const FRAME_ID_BYTES = 16
|
|
7
|
+
/** 帧头:frameId(16) + seq(4) + total(4)。 */
|
|
8
|
+
export const FRAME_HEADER_BYTES = FRAME_ID_BYTES + 4 + 4
|
|
9
|
+
/** 默认单帧最大 chunk 大小(15 KiB)。 */
|
|
18
10
|
export const DEFAULT_MAX_FRAME_CHUNK_BYTES = 15 * 1024
|
|
19
|
-
/**
|
|
20
|
-
* 重组后消息最大字节数(8 MiB)。
|
|
21
|
-
*/
|
|
11
|
+
/** 重组后消息最大字节数(8 MiB)。 */
|
|
22
12
|
export const DEFAULT_MAX_MESSAGE_BYTES = 8 * 1024 * 1024
|
|
23
|
-
/**
|
|
24
|
-
* 同时进行中的分片消息数量上限。
|
|
25
|
-
*/
|
|
13
|
+
/** 同时进行中的分片消息数量上限。 */
|
|
26
14
|
export const DEFAULT_MAX_PARTIAL_MESSAGES = 32
|
|
27
|
-
/**
|
|
28
|
-
* 分片消息超时时间(毫秒)。
|
|
29
|
-
*/
|
|
15
|
+
/** 分片消息超时时间(毫秒)。 */
|
|
30
16
|
export const DEFAULT_PARTIAL_TIMEOUT_MS = 30_000
|
|
31
17
|
|
|
32
18
|
/**
|
|
33
|
-
*
|
|
34
|
-
* @
|
|
35
|
-
* @returns {Uint8Array} 字节视图
|
|
19
|
+
* @param {string | Uint8Array} frameId hex 或 16 字节
|
|
20
|
+
* @returns {Uint8Array} 规范化后的 16 字节 frameId
|
|
36
21
|
*/
|
|
37
|
-
function
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (msgId.byteLength !== FRAME_MSG_ID_BYTES)
|
|
52
|
-
throw new Error(`p2p: msgId must be ${FRAME_MSG_ID_BYTES} bytes`)
|
|
53
|
-
return msgId
|
|
22
|
+
function normalizeFrameIdBytes(frameId) {
|
|
23
|
+
if (frameId instanceof Uint8Array) {
|
|
24
|
+
if (frameId.byteLength !== FRAME_ID_BYTES)
|
|
25
|
+
throw new Error(`p2p: frameId must be ${FRAME_ID_BYTES} bytes`)
|
|
26
|
+
return frameId
|
|
27
|
+
}
|
|
28
|
+
const text = frameId.trim().toLowerCase()
|
|
29
|
+
if (text.length !== FRAME_ID_BYTES * 2)
|
|
30
|
+
throw new Error('p2p: frameId must be 32 hex characters')
|
|
31
|
+
try {
|
|
32
|
+
return hexToBytes(text)
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw new Error('p2p: frameId must be 32 hex characters')
|
|
54
36
|
}
|
|
55
|
-
const text = String(msgId ?? '').trim().toLowerCase()
|
|
56
|
-
if (!/^[\da-f]{32}$/u.test(text))
|
|
57
|
-
throw new Error('p2p: msgId must be 32 hex characters')
|
|
58
|
-
return Uint8Array.from(text.match(/../g).map(chunk => Number.parseInt(chunk, 16)))
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* 将 msgId 字节转为 32 字符小写 hex。
|
|
63
|
-
* @param {Uint8Array} bytes 16 字节 msgId
|
|
64
|
-
* @returns {string} hex 字符串
|
|
65
|
-
*/
|
|
66
|
-
export function msgIdBytesToHex(bytes) {
|
|
67
|
-
return [...bytes].map(byte => byte.toString(16).padStart(2, '0')).join('')
|
|
68
37
|
}
|
|
69
38
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
*/
|
|
74
|
-
export function randomMsgIdHex() {
|
|
75
|
-
return msgIdBytesToHex(randomBytes(FRAME_MSG_ID_BYTES))
|
|
39
|
+
/** @returns {string} 32 字符 hex frameId */
|
|
40
|
+
export function randomFrameIdHex() {
|
|
41
|
+
return bytesToHex(randomBytes(FRAME_ID_BYTES))
|
|
76
42
|
}
|
|
77
43
|
|
|
78
44
|
/**
|
|
79
|
-
*
|
|
80
|
-
* @param {string | Uint8Array}
|
|
81
|
-
* @param {Uint8Array | ArrayBuffer | ArrayBufferView} bytes
|
|
82
|
-
* @param {number} [maxChunkBytes
|
|
83
|
-
* @returns {Uint8Array[]}
|
|
45
|
+
* 将消息切成带帧头的分片。
|
|
46
|
+
* @param {string | Uint8Array} frameId 消息 id(hex 或 16 字节)
|
|
47
|
+
* @param {Uint8Array | ArrayBuffer | ArrayBufferView} bytes 消息体
|
|
48
|
+
* @param {number} [maxChunkBytes] 单片上限
|
|
49
|
+
* @returns {Uint8Array[]} 分片帧列表
|
|
84
50
|
*/
|
|
85
|
-
export function encodeFrames(
|
|
86
|
-
const body =
|
|
87
|
-
const idBytes =
|
|
51
|
+
export function encodeFrames(frameId, bytes, maxChunkBytes = DEFAULT_MAX_FRAME_CHUNK_BYTES) {
|
|
52
|
+
const body = toBytes(bytes)
|
|
53
|
+
const idBytes = normalizeFrameIdBytes(frameId)
|
|
88
54
|
const chunkBytes = Math.max(256, Math.min(DEFAULT_MAX_MESSAGE_BYTES, Number(maxChunkBytes) || DEFAULT_MAX_FRAME_CHUNK_BYTES))
|
|
89
55
|
const total = Math.max(1, Math.ceil(body.byteLength / chunkBytes))
|
|
90
56
|
/** @type {Uint8Array[]} */
|
|
@@ -94,11 +60,10 @@ export function encodeFrames(msgId, bytes, maxChunkBytes = DEFAULT_MAX_FRAME_CHU
|
|
|
94
60
|
const end = Math.min(body.byteLength, start + chunkBytes)
|
|
95
61
|
const chunk = body.subarray(start, end)
|
|
96
62
|
const frame = new Uint8Array(FRAME_HEADER_BYTES + chunk.byteLength)
|
|
97
|
-
frame
|
|
98
|
-
frame.set(idBytes, 1)
|
|
63
|
+
frame.set(idBytes, 0)
|
|
99
64
|
const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength)
|
|
100
|
-
view.setUint32(
|
|
101
|
-
view.setUint32(
|
|
65
|
+
view.setUint32(FRAME_ID_BYTES, seq, false)
|
|
66
|
+
view.setUint32(FRAME_ID_BYTES + 4, total, false)
|
|
102
67
|
frame.set(chunk, FRAME_HEADER_BYTES)
|
|
103
68
|
frames.push(frame)
|
|
104
69
|
}
|
|
@@ -106,26 +71,22 @@ export function encodeFrames(msgId, bytes, maxChunkBytes = DEFAULT_MAX_FRAME_CHU
|
|
|
106
71
|
}
|
|
107
72
|
|
|
108
73
|
/**
|
|
109
|
-
*
|
|
110
|
-
* @param {Uint8Array | ArrayBuffer | ArrayBufferView} frame
|
|
111
|
-
* @returns {{
|
|
74
|
+
* 解析单帧头与 chunk。
|
|
75
|
+
* @param {Uint8Array | ArrayBuffer | ArrayBufferView} frame 原始帧
|
|
76
|
+
* @returns {{ frameId: string, seq: number, total: number, chunk: Uint8Array }} 帧字段
|
|
112
77
|
*/
|
|
113
78
|
export function decodeFrame(frame) {
|
|
114
|
-
const bytes =
|
|
79
|
+
const bytes = toBytes(frame)
|
|
115
80
|
if (bytes.byteLength < FRAME_HEADER_BYTES)
|
|
116
81
|
throw new Error('p2p: frame too short')
|
|
117
|
-
const
|
|
118
|
-
if (version !== FRAME_VERSION)
|
|
119
|
-
throw new Error(`p2p: unsupported frame version ${version}`)
|
|
120
|
-
const msgId = msgIdBytesToHex(bytes.subarray(1, 1 + FRAME_MSG_ID_BYTES))
|
|
82
|
+
const frameId = bytesToHex(bytes.subarray(0, FRAME_ID_BYTES))
|
|
121
83
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
122
|
-
const seq = view.getUint32(
|
|
123
|
-
const total = view.getUint32(
|
|
84
|
+
const seq = view.getUint32(FRAME_ID_BYTES, false)
|
|
85
|
+
const total = view.getUint32(FRAME_ID_BYTES + 4, false)
|
|
124
86
|
if (!total || seq >= total)
|
|
125
87
|
throw new Error('p2p: invalid frame sequence')
|
|
126
88
|
return {
|
|
127
|
-
|
|
128
|
-
msgId,
|
|
89
|
+
frameId,
|
|
129
90
|
seq,
|
|
130
91
|
total,
|
|
131
92
|
chunk: bytes.subarray(FRAME_HEADER_BYTES),
|
|
@@ -133,9 +94,8 @@ export function decodeFrame(frame) {
|
|
|
133
94
|
}
|
|
134
95
|
|
|
135
96
|
/**
|
|
136
|
-
*
|
|
137
|
-
* @
|
|
138
|
-
* @returns {Uint8Array} 拼接后的消息体
|
|
97
|
+
* @param {Uint8Array[]} chunks 有序分片
|
|
98
|
+
* @returns {Uint8Array} 拼接结果
|
|
139
99
|
*/
|
|
140
100
|
function concatChunks(chunks) {
|
|
141
101
|
const totalBytes = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0)
|
|
@@ -149,96 +109,81 @@ function concatChunks(chunks) {
|
|
|
149
109
|
}
|
|
150
110
|
|
|
151
111
|
/**
|
|
152
|
-
*
|
|
153
|
-
* @param {{ maxMessageBytes?: number, maxPartials?: number, partialTimeoutMs?: number }} [options]
|
|
154
|
-
* @returns {{ push: (frame: Uint8Array | ArrayBuffer | ArrayBufferView, now?: number) => Uint8Array | null, prune: (now?: number) => string[], clear: () => void, size: () => number }}
|
|
112
|
+
* 创建分片重组器(按 frameId 聚合,超时 prune)。
|
|
113
|
+
* @param {{ maxMessageBytes?: number, maxPartials?: number, partialTimeoutMs?: number }} [options] 上限与超时
|
|
114
|
+
* @returns {{ push: (frame: Uint8Array | ArrayBuffer | ArrayBufferView, now?: number) => Uint8Array | null, prune: (now?: number) => string[], clear: () => void, size: () => number }} 重组 API
|
|
155
115
|
*/
|
|
156
116
|
export function createReassembler(options = {}) {
|
|
157
117
|
const maxMessageBytes = Math.max(1024, Number(options.maxMessageBytes) || DEFAULT_MAX_MESSAGE_BYTES)
|
|
158
118
|
const maxPartials = Math.max(1, Number(options.maxPartials) || DEFAULT_MAX_PARTIAL_MESSAGES)
|
|
159
119
|
const partialTimeoutMs = Math.max(1000, Number(options.partialTimeoutMs) || DEFAULT_PARTIAL_TIMEOUT_MS)
|
|
160
|
-
/** @type {Map<string, { total: number,
|
|
120
|
+
/** @type {Map<string, { total: number, remaining: number, chunks: Uint8Array[], bytes: number, firstSeenAt: number, lastSeenAt: number }>} */
|
|
161
121
|
const partials = new Map()
|
|
162
122
|
|
|
163
|
-
/**
|
|
164
|
-
* 丢弃指定 msgId 的分片状态。
|
|
165
|
-
* @param {string} msgId 消息 ID
|
|
166
|
-
* @returns {void}
|
|
167
|
-
*/
|
|
168
|
-
function drop(msgId) {
|
|
169
|
-
partials.delete(msgId)
|
|
170
|
-
}
|
|
171
|
-
|
|
172
123
|
return {
|
|
173
124
|
/**
|
|
174
|
-
*
|
|
175
|
-
* @param {Uint8Array | ArrayBuffer | ArrayBufferView} frame
|
|
176
|
-
* @param {number} [now
|
|
177
|
-
* @returns {Uint8Array | null}
|
|
125
|
+
* 喂入一帧;凑齐则返回完整消息,否则 null。
|
|
126
|
+
* @param {Uint8Array | ArrayBuffer | ArrayBufferView} frame 原始帧
|
|
127
|
+
* @param {number} [now] 当前时间戳(测试可注入)
|
|
128
|
+
* @returns {Uint8Array | null} 完整消息或尚未齐
|
|
178
129
|
*/
|
|
179
130
|
push(frame, now = Date.now()) {
|
|
180
131
|
const parsed = decodeFrame(frame)
|
|
181
|
-
if (!partials.has(parsed.
|
|
132
|
+
if (!partials.has(parsed.frameId) && partials.size >= maxPartials)
|
|
182
133
|
throw new Error('p2p: too many partial messages')
|
|
183
|
-
let partial = partials.get(parsed.
|
|
134
|
+
let partial = partials.get(parsed.frameId)
|
|
184
135
|
if (!partial) {
|
|
185
136
|
partial = {
|
|
186
137
|
total: parsed.total,
|
|
138
|
+
remaining: parsed.total,
|
|
187
139
|
chunks: new Array(parsed.total),
|
|
188
|
-
seen: new Array(parsed.total).fill(false),
|
|
189
140
|
bytes: 0,
|
|
190
141
|
firstSeenAt: now,
|
|
191
142
|
lastSeenAt: now,
|
|
192
143
|
}
|
|
193
|
-
partials.set(parsed.
|
|
144
|
+
partials.set(parsed.frameId, partial)
|
|
194
145
|
}
|
|
195
146
|
if (partial.total !== parsed.total) {
|
|
196
|
-
|
|
147
|
+
partials.delete(parsed.frameId)
|
|
197
148
|
throw new Error('p2p: frame total mismatch')
|
|
198
149
|
}
|
|
199
150
|
partial.lastSeenAt = now
|
|
200
|
-
if (!partial.
|
|
151
|
+
if (!partial.chunks[parsed.seq]) {
|
|
201
152
|
partial.chunks[parsed.seq] = parsed.chunk
|
|
202
|
-
partial.
|
|
153
|
+
partial.remaining--
|
|
203
154
|
partial.bytes += parsed.chunk.byteLength
|
|
204
155
|
if (partial.bytes > maxMessageBytes) {
|
|
205
|
-
|
|
156
|
+
partials.delete(parsed.frameId)
|
|
206
157
|
throw new Error('p2p: reassembled message exceeds limit')
|
|
207
158
|
}
|
|
208
159
|
}
|
|
209
|
-
if (partial.
|
|
160
|
+
if (partial.remaining === 0) {
|
|
210
161
|
const out = concatChunks(partial.chunks)
|
|
211
|
-
|
|
162
|
+
partials.delete(parsed.frameId)
|
|
212
163
|
return out
|
|
213
164
|
}
|
|
214
165
|
return null
|
|
215
166
|
},
|
|
216
167
|
/**
|
|
217
|
-
*
|
|
218
|
-
* @param {number} [now
|
|
219
|
-
* @returns {string[]} 被丢弃的
|
|
168
|
+
* 丢弃超时未齐的分片。
|
|
169
|
+
* @param {number} [now] 当前时间戳
|
|
170
|
+
* @returns {string[]} 被丢弃的 frameId 列表
|
|
220
171
|
*/
|
|
221
172
|
prune(now = Date.now()) {
|
|
222
173
|
/** @type {string[]} */
|
|
223
174
|
const expired = []
|
|
224
|
-
for (const [
|
|
175
|
+
for (const [frameId, partial] of partials)
|
|
225
176
|
if (now - partial.lastSeenAt > partialTimeoutMs) {
|
|
226
|
-
expired.push(
|
|
227
|
-
partials.delete(
|
|
177
|
+
expired.push(frameId)
|
|
178
|
+
partials.delete(frameId)
|
|
228
179
|
}
|
|
229
180
|
return expired
|
|
230
181
|
},
|
|
231
|
-
/**
|
|
232
|
-
* 清空所有进行中的分片状态。
|
|
233
|
-
* @returns {void}
|
|
234
|
-
*/
|
|
182
|
+
/** @returns {void} */
|
|
235
183
|
clear() {
|
|
236
184
|
partials.clear()
|
|
237
185
|
},
|
|
238
|
-
/**
|
|
239
|
-
* 返回当前进行中的分片消息数量。
|
|
240
|
-
* @returns {number} 分片 msgId 数量
|
|
241
|
-
*/
|
|
186
|
+
/** @returns {number} 进行中的分片消息数 */
|
|
242
187
|
size() {
|
|
243
188
|
return partials.size
|
|
244
189
|
},
|
package/link/handshake.mjs
CHANGED
|
@@ -51,7 +51,7 @@ export function buildAuthMessage(peerNonce, localBinding, localNodeHash) {
|
|
|
51
51
|
/**
|
|
52
52
|
* 构造 link hello 握手包。
|
|
53
53
|
* @param {{ nodeHash?: string, nodePubKey?: string, nonce?: string }} [options] 可选身份字段,省略则从本地节点种子推导
|
|
54
|
-
* @returns {{
|
|
54
|
+
* @returns {{ nodeHash: string, nodePubKey: string, nonce: string }} hello 对象
|
|
55
55
|
*/
|
|
56
56
|
export function buildHello(options = {}) {
|
|
57
57
|
let publicKey = null
|
|
@@ -66,7 +66,7 @@ export function buildHello(options = {}) {
|
|
|
66
66
|
throw new Error('p2p: invalid hello fields')
|
|
67
67
|
if (pubKeyHash(Buffer.from(nodePubKey, 'hex')) !== nodeHash)
|
|
68
68
|
throw new Error('p2p: hello nodePubKey does not match nodeHash')
|
|
69
|
-
return {
|
|
69
|
+
return { nodeHash, nodePubKey, nonce }
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
/**
|
|
@@ -92,13 +92,12 @@ export async function buildAuth(peerNonce, localBinding, options = {}) {
|
|
|
92
92
|
/**
|
|
93
93
|
* 解析并校验 hello 对象,无效时返回 null。
|
|
94
94
|
* @param {unknown} hello 原始 hello 载荷
|
|
95
|
-
* @returns {{
|
|
95
|
+
* @returns {{ nodeHash: string, nodePubKey: string, nonce: string } | null} 规范化 hello 或 null
|
|
96
96
|
*/
|
|
97
97
|
export function parseHello(hello) {
|
|
98
98
|
const nodeHash = normalizeHex64(hello?.nodeHash)
|
|
99
99
|
const nodePubKey = normalizeHex64(hello?.nodePubKey)
|
|
100
100
|
const nonce = normalizeHex64(hello?.nonce)
|
|
101
|
-
if (Number(hello?.v) !== 1) return null
|
|
102
101
|
if (!isHex64(nodeHash) || !isHex64(nodePubKey) || !isHex64(nonce)) return null
|
|
103
102
|
try {
|
|
104
103
|
if (pubKeyHash(Buffer.from(nodePubKey, 'hex')) !== nodeHash) return null
|
|
@@ -106,7 +105,7 @@ export function parseHello(hello) {
|
|
|
106
105
|
catch {
|
|
107
106
|
return null
|
|
108
107
|
}
|
|
109
|
-
return {
|
|
108
|
+
return { nodeHash, nodePubKey, nonce }
|
|
110
109
|
}
|
|
111
110
|
|
|
112
111
|
/**
|
|
@@ -136,26 +135,26 @@ export async function verifyAuth(hello, auth, expectedNonce, remoteBinding) {
|
|
|
136
135
|
|
|
137
136
|
/**
|
|
138
137
|
* 构造 discovery advert 待签名字节串。
|
|
139
|
-
* @param {string}
|
|
138
|
+
* @param {string} rendezvousKey discovery 内部汇合键
|
|
140
139
|
* @param {number} ts 时间戳(毫秒)
|
|
141
140
|
* @param {string} nodeHash 节点 nodeHash
|
|
142
141
|
* @param {number | null} [tcpPort=null] 可选 LAN TCP 监听端口(签入消息)
|
|
143
142
|
* @returns {Uint8Array} 待签名消息字节
|
|
144
143
|
*/
|
|
145
|
-
export function buildAdvertMessage(
|
|
146
|
-
const base = `fount-advert\0${String(
|
|
144
|
+
export function buildAdvertMessage(rendezvousKey, ts, nodeHash, tcpPort = null) {
|
|
145
|
+
const base = `fount-advert\0${String(rendezvousKey)}\0${String(ts)}\0${normalizeHex64(nodeHash)}`
|
|
147
146
|
const port = normalizeTcpPort(tcpPort)
|
|
148
|
-
return Buffer.from(port
|
|
147
|
+
return Buffer.from(port ? `${base}\0${port}` : base, 'utf8')
|
|
149
148
|
}
|
|
150
149
|
|
|
151
150
|
/**
|
|
152
151
|
* 构造带签名的 discovery advert。
|
|
153
|
-
* @param {string}
|
|
152
|
+
* @param {string} rendezvousKey discovery 内部汇合键
|
|
154
153
|
* @param {number} [ts=Date.now()] 时间戳(毫秒)
|
|
155
154
|
* @param {{ secretKey?: Uint8Array, nodeHash?: string, nodePubKey?: string, tcpPort?: number } | null} [options] 签名身份与可选 tcpPort
|
|
156
155
|
* @returns {Promise<{ nodeHash: string, nodePubKey: string, ts: number, sig: string, tcpPort?: number }>} 签名 advert
|
|
157
156
|
*/
|
|
158
|
-
export async function buildSignedAdvert(
|
|
157
|
+
export async function buildSignedAdvert(rendezvousKey, ts = Date.now(), options = null) {
|
|
159
158
|
const seed = options?.secretKey
|
|
160
159
|
? Buffer.from(options.secretKey)
|
|
161
160
|
: Buffer.from(ensureNodeSeed(), 'hex')
|
|
@@ -165,9 +164,9 @@ export async function buildSignedAdvert(topic, ts = Date.now(), options = null)
|
|
|
165
164
|
if (pubKeyHash(Buffer.from(nodePubKey, 'hex')) !== nodeHash)
|
|
166
165
|
throw new Error('p2p: advert nodePubKey does not match nodeHash')
|
|
167
166
|
const tcpPort = normalizeTcpPort(options?.tcpPort)
|
|
168
|
-
if (options?.tcpPort
|
|
167
|
+
if (options?.tcpPort && !tcpPort)
|
|
169
168
|
throw new Error('p2p: advert tcpPort invalid')
|
|
170
|
-
const message = buildAdvertMessage(
|
|
169
|
+
const message = buildAdvertMessage(rendezvousKey, ts, nodeHash, tcpPort)
|
|
171
170
|
const sig = await sign(message, secretKey)
|
|
172
171
|
const advert = {
|
|
173
172
|
nodeHash,
|
|
@@ -175,28 +174,28 @@ export async function buildSignedAdvert(topic, ts = Date.now(), options = null)
|
|
|
175
174
|
ts,
|
|
176
175
|
sig: Buffer.from(sig).toString('hex'),
|
|
177
176
|
}
|
|
178
|
-
if (tcpPort
|
|
177
|
+
if (tcpPort) advert.tcpPort = tcpPort
|
|
179
178
|
return advert
|
|
180
179
|
}
|
|
181
180
|
|
|
182
181
|
/**
|
|
183
182
|
* 验证 discovery advert 签名与时间戳,成功返回发布者 nodeHash。
|
|
184
|
-
* @param {string}
|
|
183
|
+
* @param {string} rendezvousKey 期望的汇合键
|
|
185
184
|
* @param {unknown} advert 原始 advert 载荷
|
|
186
185
|
* @param {number} [now=Date.now()] 当前时间(毫秒)
|
|
187
186
|
* @param {number} [maxSkewMs=10 * 60_000] 允许的最大时钟偏差(毫秒)
|
|
188
187
|
* @returns {Promise<string | null>} 验证通过的 nodeHash,失败返回 null
|
|
189
188
|
*/
|
|
190
|
-
export async function verifySignedAdvert(
|
|
191
|
-
const parsedHello = parseHello({
|
|
189
|
+
export async function verifySignedAdvert(rendezvousKey, advert, now = Date.now(), maxSkewMs = 10 * 60_000) {
|
|
190
|
+
const parsedHello = parseHello({ nodeHash: advert?.nodeHash, nodePubKey: advert?.nodePubKey, nonce: '0'.repeat(64) })
|
|
192
191
|
if (!parsedHello) return null
|
|
193
192
|
const ts = Number(advert?.ts)
|
|
194
193
|
const sig = String(advert?.sig ?? '').trim().toLowerCase()
|
|
195
194
|
if (!Number.isFinite(ts) || Math.abs(now - ts) > maxSkewMs || !/^[\da-f]{128}$/u.test(sig)) return null
|
|
196
|
-
const hasTcpPortField = advert?.tcpPort
|
|
195
|
+
const hasTcpPortField = !!advert?.tcpPort
|
|
197
196
|
const tcpPort = normalizeTcpPort(advert?.tcpPort)
|
|
198
|
-
if (hasTcpPortField && tcpPort
|
|
199
|
-
const message = buildAdvertMessage(
|
|
197
|
+
if (hasTcpPortField && !tcpPort) return null
|
|
198
|
+
const message = buildAdvertMessage(rendezvousKey, ts, parsedHello.nodeHash, tcpPort)
|
|
200
199
|
const ok = await verify(Buffer.from(sig, 'hex'), message, Buffer.from(parsedHello.nodePubKey, 'hex'))
|
|
201
200
|
return ok ? parsedHello.nodeHash : null
|
|
202
201
|
}
|
package/link/pipe.mjs
CHANGED
|
@@ -1,46 +1,20 @@
|
|
|
1
|
+
import { toBytes } from '../core/bytes_codec.mjs'
|
|
1
2
|
import { normalizeHex64 } from '../core/hexIds.mjs'
|
|
2
3
|
import { ms } from '../utils/duration.mjs'
|
|
4
|
+
import { emitSafe } from '../utils/emit_safe.mjs'
|
|
3
5
|
import { createLruMap } from '../utils/lru.mjs'
|
|
4
6
|
|
|
5
|
-
import { createReassembler, encodeFrames,
|
|
7
|
+
import { createReassembler, encodeFrames, randomFrameIdHex } from './frame.mjs'
|
|
6
8
|
import { buildAuth, buildHello, parseHello, verifyAuth } from './handshake.mjs'
|
|
7
9
|
|
|
8
10
|
const encoder = new TextEncoder()
|
|
9
11
|
const decoder = new TextDecoder()
|
|
10
12
|
|
|
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
13
|
/**
|
|
40
14
|
* 把 createLinkPipe 句柄收成上层 LinkHandle(可附带测试/内部字段)。
|
|
41
|
-
* @param {ReturnType<typeof createLinkPipe>} pipe pipe
|
|
42
|
-
* @param {object} [extras]
|
|
43
|
-
* @returns {object} LinkHandle
|
|
15
|
+
* @param {ReturnType<typeof createLinkPipe>} pipe pipe 句柄
|
|
16
|
+
* @param {object} [extras] 额外字段(覆盖同名)
|
|
17
|
+
* @returns {object} LinkHandle
|
|
44
18
|
*/
|
|
45
19
|
export function asLinkHandle(pipe, extras = {}) {
|
|
46
20
|
return {
|
|
@@ -54,26 +28,26 @@ export function asLinkHandle(pipe, extras = {}) {
|
|
|
54
28
|
/** @returns {number} 提供者 level */
|
|
55
29
|
get level() { return pipe.level },
|
|
56
30
|
/**
|
|
57
|
-
* @param {...
|
|
58
|
-
* @returns {
|
|
31
|
+
* @param {...any} callArguments 透传 send
|
|
32
|
+
* @returns {ReturnType<typeof pipe.send>} 是否发送成功
|
|
59
33
|
*/
|
|
60
|
-
send: (...
|
|
34
|
+
send: (...callArguments) => pipe.send(...callArguments),
|
|
61
35
|
/**
|
|
62
|
-
* @param {...
|
|
63
|
-
* @returns {
|
|
36
|
+
* @param {...any} callArguments 透传 onEnvelope
|
|
37
|
+
* @returns {ReturnType<typeof pipe.onEnvelope>} 取消订阅
|
|
64
38
|
*/
|
|
65
|
-
onEnvelope: (...
|
|
39
|
+
onEnvelope: (...callArguments) => pipe.onEnvelope(...callArguments),
|
|
66
40
|
/**
|
|
67
|
-
* @param {...
|
|
68
|
-
* @returns {
|
|
41
|
+
* @param {...any} callArguments 透传 onDown
|
|
42
|
+
* @returns {ReturnType<typeof pipe.onDown>} 取消订阅
|
|
69
43
|
*/
|
|
70
|
-
onDown: (...
|
|
44
|
+
onDown: (...callArguments) => pipe.onDown(...callArguments),
|
|
71
45
|
/**
|
|
72
|
-
* @param {...
|
|
73
|
-
* @returns {
|
|
46
|
+
* @param {...any} callArguments 透传 close
|
|
47
|
+
* @returns {ReturnType<typeof pipe.close>} 关闭完成
|
|
74
48
|
*/
|
|
75
|
-
close: (...
|
|
76
|
-
/** @returns {
|
|
49
|
+
close: (...callArguments) => pipe.close(...callArguments),
|
|
50
|
+
/** @returns {ReturnType<typeof pipe.stats>} 运行时统计 */
|
|
77
51
|
stats: () => pipe.stats(),
|
|
78
52
|
...extras,
|
|
79
53
|
}
|
|
@@ -99,8 +73,7 @@ export function asLinkHandle(pipe, extras = {}) {
|
|
|
99
73
|
* @returns {object} link 句柄 + 入站 API
|
|
100
74
|
*/
|
|
101
75
|
export function createLinkPipe(options) {
|
|
102
|
-
const providerId =
|
|
103
|
-
const level = Number(options.level) || 0
|
|
76
|
+
const { providerId, level } = options
|
|
104
77
|
const heartbeatMs = Number(options.heartbeatMs) || ms('15s')
|
|
105
78
|
const idleTimeoutMs = Number(options.idleTimeoutMs) || ms('45s')
|
|
106
79
|
const handshakeTimeoutMs = Number(options.handshakeTimeoutMs) || ms('10s')
|
|
@@ -125,7 +98,7 @@ export function createLinkPipe(options) {
|
|
|
125
98
|
let recvFrames = 0
|
|
126
99
|
const envelopeListeners = new Set()
|
|
127
100
|
const downListeners = new Set()
|
|
128
|
-
const
|
|
101
|
+
const completedFrameIds = createLruMap(4096)
|
|
129
102
|
const reassembler = createReassembler()
|
|
130
103
|
/** @type {(value: void | PromiseLike<void>) => void} */
|
|
131
104
|
let resolveReady
|
|
@@ -219,9 +192,9 @@ export function createLinkPipe(options) {
|
|
|
219
192
|
remoteHello = parsed
|
|
220
193
|
await maybeSendAuth()
|
|
221
194
|
if (pendingAuth) {
|
|
222
|
-
const
|
|
195
|
+
const auth = pendingAuth
|
|
223
196
|
pendingAuth = null
|
|
224
|
-
await handleAuth(
|
|
197
|
+
await handleAuth(auth)
|
|
225
198
|
}
|
|
226
199
|
return
|
|
227
200
|
}
|
|
@@ -240,9 +213,9 @@ export function createLinkPipe(options) {
|
|
|
240
213
|
const merged = reassembler.push(bytes)
|
|
241
214
|
if (!merged) return
|
|
242
215
|
const envelope = JSON.parse(decoder.decode(merged))
|
|
243
|
-
const
|
|
244
|
-
if (
|
|
245
|
-
if (
|
|
216
|
+
const frameId = typeof envelope.frameId === 'string' ? envelope.frameId : null
|
|
217
|
+
if (frameId && completedFrameIds.has(frameId)) return
|
|
218
|
+
if (frameId) completedFrameIds.touch(frameId, true)
|
|
246
219
|
if (envelope?.scope === 'link') {
|
|
247
220
|
if (envelope.action === 'ping') {
|
|
248
221
|
void send({ scope: 'link', action: 'pong', payload: {} }).catch(() => { })
|
|
@@ -251,7 +224,7 @@ export function createLinkPipe(options) {
|
|
|
251
224
|
if (envelope.action === 'pong') return
|
|
252
225
|
}
|
|
253
226
|
if (ready && remoteNodeHash)
|
|
254
|
-
|
|
227
|
+
emitSafe(envelopeListeners, envelope, remoteNodeHash)
|
|
255
228
|
}
|
|
256
229
|
catch {
|
|
257
230
|
/* drop malformed network ingress */
|
|
@@ -278,23 +251,22 @@ export function createLinkPipe(options) {
|
|
|
278
251
|
catch { /* ignore */ }
|
|
279
252
|
return
|
|
280
253
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
}
|
|
254
|
+
let bytes
|
|
255
|
+
try {
|
|
256
|
+
bytes = toBytes(data)
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
const text = decoder.decode(bytes)
|
|
263
|
+
if (text.startsWith('{')) {
|
|
264
|
+
void handleControlMessage(JSON.parse(text))
|
|
265
|
+
return
|
|
294
266
|
}
|
|
295
|
-
catch { /* binary path */ }
|
|
296
|
-
handleBinaryFrame(bytes)
|
|
297
267
|
}
|
|
268
|
+
catch { /* binary path */ }
|
|
269
|
+
handleBinaryFrame(bytes)
|
|
298
270
|
}
|
|
299
271
|
|
|
300
272
|
/**
|
|
@@ -318,15 +290,17 @@ export function createLinkPipe(options) {
|
|
|
318
290
|
async function send(envelope) {
|
|
319
291
|
await readyPromise
|
|
320
292
|
if (closed) return false
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
for (const frame of encodeFrames(
|
|
329
|
-
|
|
293
|
+
const frameId = randomFrameIdHex()
|
|
294
|
+
const bytes = encoder.encode(JSON.stringify({
|
|
295
|
+
scope: envelope.scope,
|
|
296
|
+
action: envelope.action,
|
|
297
|
+
payload: envelope.payload ?? null,
|
|
298
|
+
frameId,
|
|
299
|
+
}))
|
|
300
|
+
for (const frame of encodeFrames(frameId, bytes)) {
|
|
301
|
+
const sent = options.sendFrame(envelope.action, frame)
|
|
302
|
+
if (sent != null && typeof /** @type {{ then?: unknown }} */ sent.then === 'function')
|
|
303
|
+
await sent
|
|
330
304
|
sentFrames++
|
|
331
305
|
}
|
|
332
306
|
lastOutboundAt = Date.now()
|
|
@@ -347,7 +321,7 @@ export function createLinkPipe(options) {
|
|
|
347
321
|
if (idleTimer) clearInterval(idleTimer)
|
|
348
322
|
try { await Promise.resolve(options.closeTransport?.()) } catch { /* ignore */ }
|
|
349
323
|
if (!ready) rejectReady(new Error(`p2p: link closed before ready (${reason})`))
|
|
350
|
-
|
|
324
|
+
emitSafe(downListeners, reason)
|
|
351
325
|
}
|
|
352
326
|
|
|
353
327
|
return {
|