@steve02081504/fount-p2p 0.0.10 → 0.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -7
- package/core/bytes_codec.mjs +65 -10
- package/core/tcp_port.mjs +1 -1
- package/crypto/checkpoint_sign.mjs +2 -2
- package/dag/canonicalize_row.mjs +3 -3
- package/dag/index.mjs +2 -3
- package/discovery/bt/index.mjs +5 -5
- package/discovery/bt/probe_child.mjs +20 -0
- package/discovery/bt/runtime.mjs +59 -15
- package/discovery/index.mjs +45 -48
- package/discovery/mdns.mjs +72 -17
- package/discovery/nostr.mjs +165 -129
- package/federation/chunk_fetch_pending.mjs +4 -5
- package/federation/dag_order_cache.mjs +1 -1
- package/federation/entity_key_chain.mjs +3 -4
- package/federation/topo_order_memo.mjs +11 -4
- package/files/chunk_fetch.mjs +2 -2
- package/files/evfs.mjs +2 -2
- package/link/channel_mux.mjs +10 -10
- package/link/frame.mjs +76 -124
- package/link/handshake.mjs +5 -5
- package/link/pipe.mjs +49 -75
- package/link/providers/ble_gatt.mjs +15 -27
- package/link/providers/index.mjs +7 -9
- package/link/providers/lan_tcp.mjs +18 -33
- package/link/providers/link_id_pipe.mjs +46 -0
- package/link/providers/webrtc.mjs +43 -52
- package/link/rtc.mjs +21 -9
- package/mailbox/deliver_or_store.mjs +1 -1
- package/node/identity.mjs +4 -4
- package/node/network.mjs +9 -9
- package/overlay/index.mjs +13 -13
- package/package.json +3 -2
- package/rooms/scoped_link.mjs +22 -38
- 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 +20 -0
- package/transport/group_link_set.mjs +26 -45
- package/transport/ice_servers.mjs +12 -3
- package/transport/link_registry.mjs +181 -523
- package/transport/offer_answer.mjs +194 -0
- package/transport/peer_pool.mjs +53 -64
- package/transport/remote_user_room.mjs +13 -15
- package/transport/rtc_connection_budget.mjs +18 -4
- package/transport/runtime_bootstrap.mjs +369 -0
- package/transport/signal_crypto.mjs +104 -0
- package/transport/user_room.mjs +22 -15
- package/trust_graph/send.mjs +1 -1
- package/utils/emit_safe.mjs +11 -0
- package/utils/shuffle.mjs +13 -0
- package/utils/ttl_map.mjs +29 -4
- package/wire/ingress.mjs +1 -1
- package/wire/part_ingress.mjs +6 -8
- package/wire/part_invoke.mjs +4 -4
- package/wire/part_query.mjs +2 -3
- package/wire/part_query.tunables.json +6 -1
- package/wire/part_query_cache.mjs +1 -1
- package/wire/volatile_signature.mjs +1 -1
package/files/chunk_fetch.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { bytesToBase64 } from '../core/bytes_codec.mjs'
|
|
4
4
|
import {
|
|
5
5
|
MAX_PENDING_CHUNK_FETCHES,
|
|
6
6
|
pendingChunkFetches,
|
|
@@ -78,5 +78,5 @@ export async function handleIncomingChunkGet(username, payload, sendResponse, pe
|
|
|
78
78
|
if (!await hasChunk(hash)) return
|
|
79
79
|
const chunkBytes = await getChunk(hash)
|
|
80
80
|
if (!chunkBytes?.length) return
|
|
81
|
-
sendResponse({ requestId: payload.requestId,
|
|
81
|
+
sendResponse({ requestId: payload.requestId, dataBase64: bytesToBase64(chunkBytes) }, peerId)
|
|
82
82
|
}
|
package/files/evfs.mjs
CHANGED
|
@@ -86,7 +86,7 @@ export async function storeManifestParts(manifest, partBytes) {
|
|
|
86
86
|
*/
|
|
87
87
|
export async function readManifestPlaintext(replicaUsername, manifest, options = {}) {
|
|
88
88
|
const dagGroupId = manifest.meta?.groupId
|
|
89
|
-
if (
|
|
89
|
+
if (manifest.meta?.dagParts?.length && dagGroupId) {
|
|
90
90
|
const dagPlain = await readDagManifestPlaintext(replicaUsername, manifest)
|
|
91
91
|
if (dagPlain) return dagPlain
|
|
92
92
|
}
|
|
@@ -110,7 +110,7 @@ export async function readManifestPlaintext(replicaUsername, manifest, options =
|
|
|
110
110
|
*/
|
|
111
111
|
export async function readManifestPlaintextStream(replicaUsername, manifest, options = {}) {
|
|
112
112
|
const dagGroupId = manifest.meta?.groupId
|
|
113
|
-
if (
|
|
113
|
+
if (manifest.meta?.dagParts?.length && dagGroupId) {
|
|
114
114
|
const plain = await readManifestPlaintext(replicaUsername, manifest, options)
|
|
115
115
|
if (!plain) return null
|
|
116
116
|
return Readable.from([plain])
|
package/link/channel_mux.mjs
CHANGED
|
@@ -57,7 +57,7 @@ const DEFAULT_ACTION_PRIORITIES = Object.freeze({
|
|
|
57
57
|
* @returns {number} 优先级(未知 action 默认 5)
|
|
58
58
|
*/
|
|
59
59
|
export function resolveActionPriority(action) {
|
|
60
|
-
return DEFAULT_ACTION_PRIORITIES[
|
|
60
|
+
return DEFAULT_ACTION_PRIORITIES[action] ?? 5
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/**
|
|
@@ -67,12 +67,11 @@ export function resolveActionPriority(action) {
|
|
|
67
67
|
* @returns {'control' | 'bulk'} 目标通道名
|
|
68
68
|
*/
|
|
69
69
|
export function pickChannel(action, byteLength) {
|
|
70
|
-
|
|
71
|
-
if (normalized === 'ping' || normalized === 'pong' || normalized.startsWith('route_'))
|
|
70
|
+
if (action === 'ping' || action === 'pong' || action.startsWith('route_'))
|
|
72
71
|
return CHANNEL_CONTROL
|
|
73
|
-
if (
|
|
72
|
+
if (byteLength > BULK_CHANNEL_MIN_BYTES)
|
|
74
73
|
return CHANNEL_BULK
|
|
75
|
-
return resolveActionPriority(
|
|
74
|
+
return resolveActionPriority(action) <= 3 ? CHANNEL_CONTROL : CHANNEL_BULK
|
|
76
75
|
}
|
|
77
76
|
|
|
78
77
|
/**
|
|
@@ -162,13 +161,14 @@ export function createChannelSendQueues(options) {
|
|
|
162
161
|
const flush = channelName => {
|
|
163
162
|
scheduled[channelName] = false
|
|
164
163
|
const channel = getChannel(channelName)
|
|
165
|
-
if (
|
|
164
|
+
if (channel?.readyState !== 'open') return
|
|
166
165
|
const queue = queues[channelName]
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
channel.send(
|
|
166
|
+
let i = 0
|
|
167
|
+
while (i < queue.length) {
|
|
168
|
+
if (readBufferedAmount(channel) > highWatermarkBytes) break
|
|
169
|
+
channel.send(queue[i++].bytes)
|
|
171
170
|
}
|
|
171
|
+
if (i > 0) queue.splice(0, i)
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
return {
|
package/link/frame.mjs
CHANGED
|
@@ -1,90 +1,58 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
*/
|
|
3
|
+
import { bytesToHex, hexToBytes, toBytes } from '../core/bytes_codec.mjs'
|
|
4
|
+
|
|
5
|
+
/** 二进制帧协议版本号。 */
|
|
6
6
|
export const FRAME_VERSION = 1
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
*/
|
|
10
|
-
export const
|
|
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
|
-
*/
|
|
7
|
+
/** frameId 字段字节长度(128 位)。 */
|
|
8
|
+
export const FRAME_ID_BYTES = 16
|
|
9
|
+
/** 帧头:version(1) + frameId(16) + seq(4) + total(4)。 */
|
|
10
|
+
export const FRAME_HEADER_BYTES = 1 + FRAME_ID_BYTES + 4 + 4
|
|
11
|
+
/** 默认单帧最大 chunk 大小(15 KiB)。 */
|
|
18
12
|
export const DEFAULT_MAX_FRAME_CHUNK_BYTES = 15 * 1024
|
|
19
|
-
/**
|
|
20
|
-
* 重组后消息最大字节数(8 MiB)。
|
|
21
|
-
*/
|
|
13
|
+
/** 重组后消息最大字节数(8 MiB)。 */
|
|
22
14
|
export const DEFAULT_MAX_MESSAGE_BYTES = 8 * 1024 * 1024
|
|
23
|
-
/**
|
|
24
|
-
* 同时进行中的分片消息数量上限。
|
|
25
|
-
*/
|
|
15
|
+
/** 同时进行中的分片消息数量上限。 */
|
|
26
16
|
export const DEFAULT_MAX_PARTIAL_MESSAGES = 32
|
|
27
|
-
/**
|
|
28
|
-
* 分片消息超时时间(毫秒)。
|
|
29
|
-
*/
|
|
17
|
+
/** 分片消息超时时间(毫秒)。 */
|
|
30
18
|
export const DEFAULT_PARTIAL_TIMEOUT_MS = 30_000
|
|
31
19
|
|
|
32
20
|
/**
|
|
33
|
-
*
|
|
34
|
-
* @
|
|
35
|
-
* @returns {Uint8Array} 字节视图
|
|
21
|
+
* @param {string | Uint8Array} frameId hex 或 16 字节
|
|
22
|
+
* @returns {Uint8Array} 规范化后的 16 字节 frameId
|
|
36
23
|
*/
|
|
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
|
|
24
|
+
function normalizeFrameIdBytes(frameId) {
|
|
25
|
+
if (frameId instanceof Uint8Array) {
|
|
26
|
+
if (frameId.byteLength !== FRAME_ID_BYTES)
|
|
27
|
+
throw new Error(`p2p: frameId must be ${FRAME_ID_BYTES} bytes`)
|
|
28
|
+
return frameId
|
|
29
|
+
}
|
|
30
|
+
const text = frameId.trim().toLowerCase()
|
|
31
|
+
if (text.length !== FRAME_ID_BYTES * 2)
|
|
32
|
+
throw new Error('p2p: frameId must be 32 hex characters')
|
|
33
|
+
try {
|
|
34
|
+
return hexToBytes(text)
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new Error('p2p: frameId must be 32 hex characters')
|
|
54
38
|
}
|
|
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
39
|
}
|
|
69
40
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
*/
|
|
74
|
-
export function randomMsgIdHex() {
|
|
75
|
-
return msgIdBytesToHex(randomBytes(FRAME_MSG_ID_BYTES))
|
|
41
|
+
/** @returns {string} 32 字符 hex frameId */
|
|
42
|
+
export function randomFrameIdHex() {
|
|
43
|
+
return bytesToHex(randomBytes(FRAME_ID_BYTES))
|
|
76
44
|
}
|
|
77
45
|
|
|
78
46
|
/**
|
|
79
|
-
*
|
|
80
|
-
* @param {string | Uint8Array}
|
|
81
|
-
* @param {Uint8Array | ArrayBuffer | ArrayBufferView} bytes
|
|
82
|
-
* @param {number} [maxChunkBytes
|
|
83
|
-
* @returns {Uint8Array[]}
|
|
47
|
+
* 将消息切成带帧头的分片。
|
|
48
|
+
* @param {string | Uint8Array} frameId 消息 id(hex 或 16 字节)
|
|
49
|
+
* @param {Uint8Array | ArrayBuffer | ArrayBufferView} bytes 消息体
|
|
50
|
+
* @param {number} [maxChunkBytes] 单片上限
|
|
51
|
+
* @returns {Uint8Array[]} 分片帧列表
|
|
84
52
|
*/
|
|
85
|
-
export function encodeFrames(
|
|
86
|
-
const body =
|
|
87
|
-
const idBytes =
|
|
53
|
+
export function encodeFrames(frameId, bytes, maxChunkBytes = DEFAULT_MAX_FRAME_CHUNK_BYTES) {
|
|
54
|
+
const body = toBytes(bytes)
|
|
55
|
+
const idBytes = normalizeFrameIdBytes(frameId)
|
|
88
56
|
const chunkBytes = Math.max(256, Math.min(DEFAULT_MAX_MESSAGE_BYTES, Number(maxChunkBytes) || DEFAULT_MAX_FRAME_CHUNK_BYTES))
|
|
89
57
|
const total = Math.max(1, Math.ceil(body.byteLength / chunkBytes))
|
|
90
58
|
/** @type {Uint8Array[]} */
|
|
@@ -97,8 +65,8 @@ export function encodeFrames(msgId, bytes, maxChunkBytes = DEFAULT_MAX_FRAME_CHU
|
|
|
97
65
|
frame[0] = FRAME_VERSION
|
|
98
66
|
frame.set(idBytes, 1)
|
|
99
67
|
const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength)
|
|
100
|
-
view.setUint32(1 +
|
|
101
|
-
view.setUint32(1 +
|
|
68
|
+
view.setUint32(1 + FRAME_ID_BYTES, seq, false)
|
|
69
|
+
view.setUint32(1 + FRAME_ID_BYTES + 4, total, false)
|
|
102
70
|
frame.set(chunk, FRAME_HEADER_BYTES)
|
|
103
71
|
frames.push(frame)
|
|
104
72
|
}
|
|
@@ -106,26 +74,26 @@ export function encodeFrames(msgId, bytes, maxChunkBytes = DEFAULT_MAX_FRAME_CHU
|
|
|
106
74
|
}
|
|
107
75
|
|
|
108
76
|
/**
|
|
109
|
-
*
|
|
110
|
-
* @param {Uint8Array | ArrayBuffer | ArrayBufferView} frame
|
|
111
|
-
* @returns {{ version: number,
|
|
77
|
+
* 解析单帧头与 chunk。
|
|
78
|
+
* @param {Uint8Array | ArrayBuffer | ArrayBufferView} frame 原始帧
|
|
79
|
+
* @returns {{ version: number, frameId: string, seq: number, total: number, chunk: Uint8Array }} 帧字段
|
|
112
80
|
*/
|
|
113
81
|
export function decodeFrame(frame) {
|
|
114
|
-
const bytes =
|
|
82
|
+
const bytes = toBytes(frame)
|
|
115
83
|
if (bytes.byteLength < FRAME_HEADER_BYTES)
|
|
116
84
|
throw new Error('p2p: frame too short')
|
|
117
85
|
const version = bytes[0]
|
|
118
86
|
if (version !== FRAME_VERSION)
|
|
119
87
|
throw new Error(`p2p: unsupported frame version ${version}`)
|
|
120
|
-
const
|
|
88
|
+
const frameId = bytesToHex(bytes.subarray(1, 1 + FRAME_ID_BYTES))
|
|
121
89
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
122
|
-
const seq = view.getUint32(1 +
|
|
123
|
-
const total = view.getUint32(1 +
|
|
90
|
+
const seq = view.getUint32(1 + FRAME_ID_BYTES, false)
|
|
91
|
+
const total = view.getUint32(1 + FRAME_ID_BYTES + 4, false)
|
|
124
92
|
if (!total || seq >= total)
|
|
125
93
|
throw new Error('p2p: invalid frame sequence')
|
|
126
94
|
return {
|
|
127
95
|
version,
|
|
128
|
-
|
|
96
|
+
frameId,
|
|
129
97
|
seq,
|
|
130
98
|
total,
|
|
131
99
|
chunk: bytes.subarray(FRAME_HEADER_BYTES),
|
|
@@ -133,9 +101,8 @@ export function decodeFrame(frame) {
|
|
|
133
101
|
}
|
|
134
102
|
|
|
135
103
|
/**
|
|
136
|
-
*
|
|
137
|
-
* @
|
|
138
|
-
* @returns {Uint8Array} 拼接后的消息体
|
|
104
|
+
* @param {Uint8Array[]} chunks 有序分片
|
|
105
|
+
* @returns {Uint8Array} 拼接结果
|
|
139
106
|
*/
|
|
140
107
|
function concatChunks(chunks) {
|
|
141
108
|
const totalBytes = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0)
|
|
@@ -149,96 +116,81 @@ function concatChunks(chunks) {
|
|
|
149
116
|
}
|
|
150
117
|
|
|
151
118
|
/**
|
|
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 }}
|
|
119
|
+
* 创建分片重组器(按 frameId 聚合,超时 prune)。
|
|
120
|
+
* @param {{ maxMessageBytes?: number, maxPartials?: number, partialTimeoutMs?: number }} [options] 上限与超时
|
|
121
|
+
* @returns {{ push: (frame: Uint8Array | ArrayBuffer | ArrayBufferView, now?: number) => Uint8Array | null, prune: (now?: number) => string[], clear: () => void, size: () => number }} 重组 API
|
|
155
122
|
*/
|
|
156
123
|
export function createReassembler(options = {}) {
|
|
157
124
|
const maxMessageBytes = Math.max(1024, Number(options.maxMessageBytes) || DEFAULT_MAX_MESSAGE_BYTES)
|
|
158
125
|
const maxPartials = Math.max(1, Number(options.maxPartials) || DEFAULT_MAX_PARTIAL_MESSAGES)
|
|
159
126
|
const partialTimeoutMs = Math.max(1000, Number(options.partialTimeoutMs) || DEFAULT_PARTIAL_TIMEOUT_MS)
|
|
160
|
-
/** @type {Map<string, { total: number,
|
|
127
|
+
/** @type {Map<string, { total: number, remaining: number, chunks: Uint8Array[], bytes: number, firstSeenAt: number, lastSeenAt: number }>} */
|
|
161
128
|
const partials = new Map()
|
|
162
129
|
|
|
163
|
-
/**
|
|
164
|
-
* 丢弃指定 msgId 的分片状态。
|
|
165
|
-
* @param {string} msgId 消息 ID
|
|
166
|
-
* @returns {void}
|
|
167
|
-
*/
|
|
168
|
-
function drop(msgId) {
|
|
169
|
-
partials.delete(msgId)
|
|
170
|
-
}
|
|
171
|
-
|
|
172
130
|
return {
|
|
173
131
|
/**
|
|
174
|
-
*
|
|
175
|
-
* @param {Uint8Array | ArrayBuffer | ArrayBufferView} frame
|
|
176
|
-
* @param {number} [now
|
|
177
|
-
* @returns {Uint8Array | null}
|
|
132
|
+
* 喂入一帧;凑齐则返回完整消息,否则 null。
|
|
133
|
+
* @param {Uint8Array | ArrayBuffer | ArrayBufferView} frame 原始帧
|
|
134
|
+
* @param {number} [now] 当前时间戳(测试可注入)
|
|
135
|
+
* @returns {Uint8Array | null} 完整消息或尚未齐
|
|
178
136
|
*/
|
|
179
137
|
push(frame, now = Date.now()) {
|
|
180
138
|
const parsed = decodeFrame(frame)
|
|
181
|
-
if (!partials.has(parsed.
|
|
139
|
+
if (!partials.has(parsed.frameId) && partials.size >= maxPartials)
|
|
182
140
|
throw new Error('p2p: too many partial messages')
|
|
183
|
-
let partial = partials.get(parsed.
|
|
141
|
+
let partial = partials.get(parsed.frameId)
|
|
184
142
|
if (!partial) {
|
|
185
143
|
partial = {
|
|
186
144
|
total: parsed.total,
|
|
145
|
+
remaining: parsed.total,
|
|
187
146
|
chunks: new Array(parsed.total),
|
|
188
|
-
seen: new Array(parsed.total).fill(false),
|
|
189
147
|
bytes: 0,
|
|
190
148
|
firstSeenAt: now,
|
|
191
149
|
lastSeenAt: now,
|
|
192
150
|
}
|
|
193
|
-
partials.set(parsed.
|
|
151
|
+
partials.set(parsed.frameId, partial)
|
|
194
152
|
}
|
|
195
153
|
if (partial.total !== parsed.total) {
|
|
196
|
-
|
|
154
|
+
partials.delete(parsed.frameId)
|
|
197
155
|
throw new Error('p2p: frame total mismatch')
|
|
198
156
|
}
|
|
199
157
|
partial.lastSeenAt = now
|
|
200
|
-
if (!partial.
|
|
158
|
+
if (!partial.chunks[parsed.seq]) {
|
|
201
159
|
partial.chunks[parsed.seq] = parsed.chunk
|
|
202
|
-
partial.
|
|
160
|
+
partial.remaining--
|
|
203
161
|
partial.bytes += parsed.chunk.byteLength
|
|
204
162
|
if (partial.bytes > maxMessageBytes) {
|
|
205
|
-
|
|
163
|
+
partials.delete(parsed.frameId)
|
|
206
164
|
throw new Error('p2p: reassembled message exceeds limit')
|
|
207
165
|
}
|
|
208
166
|
}
|
|
209
|
-
if (partial.
|
|
167
|
+
if (partial.remaining === 0) {
|
|
210
168
|
const out = concatChunks(partial.chunks)
|
|
211
|
-
|
|
169
|
+
partials.delete(parsed.frameId)
|
|
212
170
|
return out
|
|
213
171
|
}
|
|
214
172
|
return null
|
|
215
173
|
},
|
|
216
174
|
/**
|
|
217
|
-
*
|
|
218
|
-
* @param {number} [now
|
|
219
|
-
* @returns {string[]} 被丢弃的
|
|
175
|
+
* 丢弃超时未齐的分片。
|
|
176
|
+
* @param {number} [now] 当前时间戳
|
|
177
|
+
* @returns {string[]} 被丢弃的 frameId 列表
|
|
220
178
|
*/
|
|
221
179
|
prune(now = Date.now()) {
|
|
222
180
|
/** @type {string[]} */
|
|
223
181
|
const expired = []
|
|
224
|
-
for (const [
|
|
182
|
+
for (const [frameId, partial] of partials)
|
|
225
183
|
if (now - partial.lastSeenAt > partialTimeoutMs) {
|
|
226
|
-
expired.push(
|
|
227
|
-
partials.delete(
|
|
184
|
+
expired.push(frameId)
|
|
185
|
+
partials.delete(frameId)
|
|
228
186
|
}
|
|
229
187
|
return expired
|
|
230
188
|
},
|
|
231
|
-
/**
|
|
232
|
-
* 清空所有进行中的分片状态。
|
|
233
|
-
* @returns {void}
|
|
234
|
-
*/
|
|
189
|
+
/** @returns {void} */
|
|
235
190
|
clear() {
|
|
236
191
|
partials.clear()
|
|
237
192
|
},
|
|
238
|
-
/**
|
|
239
|
-
* 返回当前进行中的分片消息数量。
|
|
240
|
-
* @returns {number} 分片 msgId 数量
|
|
241
|
-
*/
|
|
193
|
+
/** @returns {number} 进行中的分片消息数 */
|
|
242
194
|
size() {
|
|
243
195
|
return partials.size
|
|
244
196
|
},
|
package/link/handshake.mjs
CHANGED
|
@@ -145,7 +145,7 @@ export async function verifyAuth(hello, auth, expectedNonce, remoteBinding) {
|
|
|
145
145
|
export function buildAdvertMessage(topic, ts, nodeHash, tcpPort = null) {
|
|
146
146
|
const base = `fount-advert\0${String(topic)}\0${String(ts)}\0${normalizeHex64(nodeHash)}`
|
|
147
147
|
const port = normalizeTcpPort(tcpPort)
|
|
148
|
-
return Buffer.from(port
|
|
148
|
+
return Buffer.from(port ? `${base}\0${port}` : base, 'utf8')
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
/**
|
|
@@ -165,7 +165,7 @@ export async function buildSignedAdvert(topic, ts = Date.now(), options = null)
|
|
|
165
165
|
if (pubKeyHash(Buffer.from(nodePubKey, 'hex')) !== nodeHash)
|
|
166
166
|
throw new Error('p2p: advert nodePubKey does not match nodeHash')
|
|
167
167
|
const tcpPort = normalizeTcpPort(options?.tcpPort)
|
|
168
|
-
if (options?.tcpPort
|
|
168
|
+
if (options?.tcpPort && !tcpPort)
|
|
169
169
|
throw new Error('p2p: advert tcpPort invalid')
|
|
170
170
|
const message = buildAdvertMessage(topic, ts, nodeHash, tcpPort)
|
|
171
171
|
const sig = await sign(message, secretKey)
|
|
@@ -175,7 +175,7 @@ export async function buildSignedAdvert(topic, ts = Date.now(), options = null)
|
|
|
175
175
|
ts,
|
|
176
176
|
sig: Buffer.from(sig).toString('hex'),
|
|
177
177
|
}
|
|
178
|
-
if (tcpPort
|
|
178
|
+
if (tcpPort) advert.tcpPort = tcpPort
|
|
179
179
|
return advert
|
|
180
180
|
}
|
|
181
181
|
|
|
@@ -193,9 +193,9 @@ export async function verifySignedAdvert(topic, advert, now = Date.now(), maxSke
|
|
|
193
193
|
const ts = Number(advert?.ts)
|
|
194
194
|
const sig = String(advert?.sig ?? '').trim().toLowerCase()
|
|
195
195
|
if (!Number.isFinite(ts) || Math.abs(now - ts) > maxSkewMs || !/^[\da-f]{128}$/u.test(sig)) return null
|
|
196
|
-
const hasTcpPortField = advert?.tcpPort
|
|
196
|
+
const hasTcpPortField = !!advert?.tcpPort
|
|
197
197
|
const tcpPort = normalizeTcpPort(advert?.tcpPort)
|
|
198
|
-
if (hasTcpPortField && tcpPort
|
|
198
|
+
if (hasTcpPortField && !tcpPort) return null
|
|
199
199
|
const message = buildAdvertMessage(topic, ts, parsedHello.nodeHash, tcpPort)
|
|
200
200
|
const ok = await verify(Buffer.from(sig, 'hex'), message, Buffer.from(parsedHello.nodePubKey, 'hex'))
|
|
201
201
|
return ok ? parsedHello.nodeHash : null
|
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} args 透传 send
|
|
32
|
+
* @returns {ReturnType<typeof pipe.send>} 是否发送成功
|
|
59
33
|
*/
|
|
60
34
|
send: (...args) => pipe.send(...args),
|
|
61
35
|
/**
|
|
62
|
-
* @param {...
|
|
63
|
-
* @returns {
|
|
36
|
+
* @param {...any} args 透传 onEnvelope
|
|
37
|
+
* @returns {ReturnType<typeof pipe.onEnvelope>} 取消订阅
|
|
64
38
|
*/
|
|
65
39
|
onEnvelope: (...args) => pipe.onEnvelope(...args),
|
|
66
40
|
/**
|
|
67
|
-
* @param {...
|
|
68
|
-
* @returns {
|
|
41
|
+
* @param {...any} args 透传 onDown
|
|
42
|
+
* @returns {ReturnType<typeof pipe.onDown>} 取消订阅
|
|
69
43
|
*/
|
|
70
44
|
onDown: (...args) => pipe.onDown(...args),
|
|
71
45
|
/**
|
|
72
|
-
* @param {...
|
|
73
|
-
* @returns {
|
|
46
|
+
* @param {...any} args 透传 close
|
|
47
|
+
* @returns {ReturnType<typeof pipe.close>} 关闭完成
|
|
74
48
|
*/
|
|
75
49
|
close: (...args) => pipe.close(...args),
|
|
76
|
-
/** @returns {
|
|
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 {
|