@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.
Files changed (60) hide show
  1. package/README.md +25 -7
  2. package/core/bytes_codec.mjs +65 -10
  3. package/core/tcp_port.mjs +1 -1
  4. package/crypto/checkpoint_sign.mjs +2 -2
  5. package/dag/canonicalize_row.mjs +3 -3
  6. package/dag/index.mjs +2 -3
  7. package/discovery/bt/index.mjs +5 -5
  8. package/discovery/bt/probe_child.mjs +20 -0
  9. package/discovery/bt/runtime.mjs +59 -15
  10. package/discovery/index.mjs +45 -48
  11. package/discovery/mdns.mjs +72 -17
  12. package/discovery/nostr.mjs +165 -129
  13. package/federation/chunk_fetch_pending.mjs +4 -5
  14. package/federation/dag_order_cache.mjs +1 -1
  15. package/federation/entity_key_chain.mjs +3 -4
  16. package/federation/topo_order_memo.mjs +11 -4
  17. package/files/chunk_fetch.mjs +2 -2
  18. package/files/evfs.mjs +2 -2
  19. package/link/channel_mux.mjs +10 -10
  20. package/link/frame.mjs +76 -124
  21. package/link/handshake.mjs +5 -5
  22. package/link/pipe.mjs +49 -75
  23. package/link/providers/ble_gatt.mjs +15 -27
  24. package/link/providers/index.mjs +7 -9
  25. package/link/providers/lan_tcp.mjs +18 -33
  26. package/link/providers/link_id_pipe.mjs +46 -0
  27. package/link/providers/webrtc.mjs +43 -52
  28. package/link/rtc.mjs +21 -9
  29. package/mailbox/deliver_or_store.mjs +1 -1
  30. package/node/identity.mjs +4 -4
  31. package/node/network.mjs +9 -9
  32. package/overlay/index.mjs +13 -13
  33. package/package.json +3 -2
  34. package/rooms/scoped_link.mjs +22 -38
  35. package/schemas/discovery.mjs +1 -2
  36. package/schemas/federation_pull.mjs +2 -2
  37. package/schemas/part_query.mjs +1 -1
  38. package/schemas/remote_event.mjs +1 -1
  39. package/transport/advert_ingest.mjs +20 -0
  40. package/transport/group_link_set.mjs +26 -45
  41. package/transport/ice_servers.mjs +12 -3
  42. package/transport/link_registry.mjs +181 -523
  43. package/transport/offer_answer.mjs +194 -0
  44. package/transport/peer_pool.mjs +53 -64
  45. package/transport/remote_user_room.mjs +13 -15
  46. package/transport/rtc_connection_budget.mjs +18 -4
  47. package/transport/runtime_bootstrap.mjs +369 -0
  48. package/transport/signal_crypto.mjs +104 -0
  49. package/transport/user_room.mjs +22 -15
  50. package/trust_graph/send.mjs +1 -1
  51. package/utils/emit_safe.mjs +11 -0
  52. package/utils/shuffle.mjs +13 -0
  53. package/utils/ttl_map.mjs +29 -4
  54. package/wire/ingress.mjs +1 -1
  55. package/wire/part_ingress.mjs +6 -8
  56. package/wire/part_invoke.mjs +4 -4
  57. package/wire/part_query.mjs +2 -3
  58. package/wire/part_query.tunables.json +6 -1
  59. package/wire/part_query_cache.mjs +1 -1
  60. package/wire/volatile_signature.mjs +1 -1
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto'
2
2
 
3
- import { u8ToB64 } from '../core/bytes_codec.mjs'
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, dataB64: u8ToB64(chunkBytes) }, peerId)
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 (Array.isArray(manifest.meta?.dagParts) && dagGroupId) {
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 (Array.isArray(manifest.meta?.dagParts) && dagGroupId) {
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])
@@ -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[String(action || '').trim()] ?? 5
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
- const normalized = String(action || '').trim()
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 (Number(byteLength) > BULK_CHANNEL_MIN_BYTES)
72
+ if (byteLength > BULK_CHANNEL_MIN_BYTES)
74
73
  return CHANNEL_BULK
75
- return resolveActionPriority(normalized) <= 3 ? CHANNEL_CONTROL : CHANNEL_BULK
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 (!channel || channel.readyState !== 'open') return
164
+ if (channel?.readyState !== 'open') return
166
165
  const queue = queues[channelName]
167
- while (queue.length) {
168
- if (readBufferedAmount(channel) > highWatermarkBytes) return
169
- const item = queue.shift()
170
- channel.send(item.bytes)
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
- * msgId 字段字节长度(128 位)。
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
- */
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
- * 将输入规范化为 Uint8Array
34
- * @param {unknown} value 原始字节数据
35
- * @returns {Uint8Array} 字节视图
21
+ * @param {string | Uint8Array} frameId hex 或 16 字节
22
+ * @returns {Uint8Array} 规范化后的 16 字节 frameId
36
23
  */
37
- function normalizeBytes(value) {
38
- if (value instanceof Uint8Array) return value
39
- if (value instanceof ArrayBuffer) return new Uint8Array(value)
40
- if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
41
- throw new Error('p2p: frame bytes must be Uint8Array-compatible')
42
- }
43
-
44
- /**
45
- * msgId 规范化为 16 字节 Uint8Array。
46
- * @param {unknown} msgId hex 字符串或 16 字节 Uint8Array
47
- * @returns {Uint8Array} 16 字节 msgId
48
- */
49
- function normalizeMsgIdBytes(msgId) {
50
- if (msgId instanceof Uint8Array) {
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
- * 生成随机 msgId hex 字符串。
72
- * @returns {string} 32 字符 hex msgId
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} msgId 消息 ID
81
- * @param {Uint8Array | ArrayBuffer | ArrayBufferView} bytes 消息体字节
82
- * @param {number} [maxChunkBytes=DEFAULT_MAX_FRAME_CHUNK_BYTES] 单帧最大 chunk 大小
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(msgId, bytes, maxChunkBytes = DEFAULT_MAX_FRAME_CHUNK_BYTES) {
86
- const body = normalizeBytes(bytes)
87
- const idBytes = normalizeMsgIdBytes(msgId)
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 + FRAME_MSG_ID_BYTES, seq, false)
101
- view.setUint32(1 + FRAME_MSG_ID_BYTES + 4, total, false)
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, msgId: string, seq: number, total: number, chunk: Uint8Array }} 帧字段
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 = normalizeBytes(frame)
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 msgId = msgIdBytesToHex(bytes.subarray(1, 1 + FRAME_MSG_ID_BYTES))
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 + FRAME_MSG_ID_BYTES, false)
123
- const total = view.getUint32(1 + FRAME_MSG_ID_BYTES + 4, false)
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
- msgId,
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
- * 按序拼接多个 chunk 为完整消息体。
137
- * @param {Uint8Array[]} chunks 已排序的 chunk 数组
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 }} 重组器 API
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, chunks: Uint8Array[], seen: boolean[], bytes: number, firstSeenAt: number, lastSeenAt: 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=Date.now()] 当前时间戳(毫秒)
177
- * @returns {Uint8Array | null} 完整消息体,未收齐时返回 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.msgId) && partials.size >= maxPartials)
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.msgId)
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.msgId, partial)
151
+ partials.set(parsed.frameId, partial)
194
152
  }
195
153
  if (partial.total !== parsed.total) {
196
- drop(parsed.msgId)
154
+ partials.delete(parsed.frameId)
197
155
  throw new Error('p2p: frame total mismatch')
198
156
  }
199
157
  partial.lastSeenAt = now
200
- if (!partial.seen[parsed.seq]) {
158
+ if (!partial.chunks[parsed.seq]) {
201
159
  partial.chunks[parsed.seq] = parsed.chunk
202
- partial.seen[parsed.seq] = true
160
+ partial.remaining--
203
161
  partial.bytes += parsed.chunk.byteLength
204
162
  if (partial.bytes > maxMessageBytes) {
205
- drop(parsed.msgId)
163
+ partials.delete(parsed.frameId)
206
164
  throw new Error('p2p: reassembled message exceeds limit')
207
165
  }
208
166
  }
209
- if (partial.seen.every(Boolean)) {
167
+ if (partial.remaining === 0) {
210
168
  const out = concatChunks(partial.chunks)
211
- drop(parsed.msgId)
169
+ partials.delete(parsed.frameId)
212
170
  return out
213
171
  }
214
172
  return null
215
173
  },
216
174
  /**
217
- * 清理超时的分片消息。
218
- * @param {number} [now=Date.now()] 当前时间戳(毫秒)
219
- * @returns {string[]} 被丢弃的 msgId 列表
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 [msgId, partial] of partials.entries())
182
+ for (const [frameId, partial] of partials)
225
183
  if (now - partial.lastSeenAt > partialTimeoutMs) {
226
- expired.push(msgId)
227
- partials.delete(msgId)
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
  },
@@ -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 == null ? base : `${base}\0${port}`, 'utf8')
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 != null && options.tcpPort !== '' && tcpPort == null)
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 != null) advert.tcpPort = 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 != null && advert.tcpPort !== ''
196
+ const hasTcpPortField = !!advert?.tcpPort
197
197
  const tcpPort = normalizeTcpPort(advert?.tcpPort)
198
- if (hasTcpPortField && tcpPort == null) return null
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, randomMsgIdHex } from './frame.mjs'
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] 附加字段(如 handleInbound、_channelForTest)
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 {...unknown} args send 参数
58
- * @returns {Promise<boolean>} 是否发送成功
31
+ * @param {...any} args 透传 send
32
+ * @returns {ReturnType<typeof pipe.send>} 是否发送成功
59
33
  */
60
34
  send: (...args) => pipe.send(...args),
61
35
  /**
62
- * @param {...unknown} args onEnvelope 参数
63
- * @returns {() => void} 取消订阅
36
+ * @param {...any} args 透传 onEnvelope
37
+ * @returns {ReturnType<typeof pipe.onEnvelope>} 取消订阅
64
38
  */
65
39
  onEnvelope: (...args) => pipe.onEnvelope(...args),
66
40
  /**
67
- * @param {...unknown} args onDown 参数
68
- * @returns {() => void} 取消订阅
41
+ * @param {...any} args 透传 onDown
42
+ * @returns {ReturnType<typeof pipe.onDown>} 取消订阅
69
43
  */
70
44
  onDown: (...args) => pipe.onDown(...args),
71
45
  /**
72
- * @param {...unknown} args close 参数
73
- * @returns {Promise<void>}
46
+ * @param {...any} args 透传 close
47
+ * @returns {ReturnType<typeof pipe.close>} 关闭完成
74
48
  */
75
49
  close: (...args) => pipe.close(...args),
76
- /** @returns {object} 运行时统计 */
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 = String(options.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 completedMsgIds = createLruMap(4096)
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 bufferedAuth = pendingAuth
195
+ const auth = pendingAuth
223
196
  pendingAuth = null
224
- await handleAuth(bufferedAuth)
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 msgId = envelope?.msgId ? String(envelope.msgId) : null
244
- if (msgId && completedMsgIds.has(msgId)) return
245
- if (msgId) completedMsgIds.touch(msgId, true)
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
- emitListeners(envelopeListeners, envelope, remoteNodeHash)
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
- 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
- }
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 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))
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
- emitListeners(downListeners, reason)
324
+ emitSafe(downListeners, reason)
351
325
  }
352
326
 
353
327
  return {