@steve02081504/fount-p2p 0.0.26 → 0.0.28

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 (100) hide show
  1. package/README.md +3 -2
  2. package/core/bytes_codec.mjs +5 -6
  3. package/core/composite_key.mjs +0 -79
  4. package/core/entity_id_parse.mjs +2 -3
  5. package/core/evfs_logical_path.mjs +2 -3
  6. package/core/hexIds.mjs +5 -5
  7. package/core/logical_entity.mjs +3 -4
  8. package/crypto/channel.mjs +2 -25
  9. package/crypto/checkpoint_sign.mjs +5 -4
  10. package/crypto/crypto.mjs +0 -8
  11. package/crypto/key.mjs +9 -10
  12. package/dag/canonicalize_row.mjs +1 -1
  13. package/discovery/advert_peer_hints.mjs +1 -1
  14. package/discovery/adverts.mjs +7 -12
  15. package/discovery/bt/index.mjs +6 -11
  16. package/discovery/bt/peer_hints.mjs +2 -3
  17. package/discovery/bt/runtime.mjs +1 -1
  18. package/discovery/index.mjs +14 -14
  19. package/discovery/lan.mjs +3 -3
  20. package/discovery/lan_interfaces.mjs +2 -2
  21. package/discovery/lan_peer_hints.mjs +1 -1
  22. package/discovery/nostr.mjs +19 -24
  23. package/federation/entity_key_chain.mjs +5 -24
  24. package/federation/message_rate_limit.mjs +3 -2
  25. package/federation/part_query/runtime.mjs +9 -20
  26. package/files/assemble.mjs +3 -3
  27. package/files/assemble_stream.mjs +1 -1
  28. package/files/chunk/fetch.mjs +1 -2
  29. package/files/chunk/responder.mjs +4 -41
  30. package/files/chunk/store.mjs +2 -3
  31. package/files/chunk/verify.mjs +2 -3
  32. package/files/evfs.mjs +1 -1
  33. package/files/evfs_ref.mjs +3 -4
  34. package/files/fetch_fanout.mjs +3 -3
  35. package/files/manifest/fetch.mjs +2 -3
  36. package/files/manifest/normalize.mjs +9 -9
  37. package/files/manifest/pending.mjs +1 -4
  38. package/files/manifest/public.mjs +8 -8
  39. package/files/transfer_key.mjs +3 -3
  40. package/files/transfer_key_registry.mjs +6 -6
  41. package/governance/branch.mjs +2 -1
  42. package/governance/join_pow.mjs +7 -5
  43. package/governance/owner_succession_ballot.mjs +6 -6
  44. package/index.mjs +8 -2
  45. package/infra/default_node_dir.mjs +2 -2
  46. package/infra/priority.mjs +2 -2
  47. package/link/frame.mjs +2 -3
  48. package/link/handshake.mjs +6 -6
  49. package/link/pipe.mjs +69 -5
  50. package/link/providers/ble_gatt.mjs +1 -9
  51. package/link/providers/index.mjs +2 -2
  52. package/link/providers/nostr.mjs +1 -3
  53. package/link/providers/webrtc.mjs +2 -2
  54. package/link/rtc/ice_local_hostname.mjs +6 -5
  55. package/link/rtc/w3c_bridge.mjs +1 -1
  56. package/link/sdp_fingerprint.mjs +1 -1
  57. package/mailbox/consumer_registry.mjs +2 -2
  58. package/mailbox/deliver_or_store.mjs +4 -4
  59. package/mailbox/parse.mjs +1 -1
  60. package/mailbox/prune.mjs +2 -2
  61. package/mailbox/rate.mjs +3 -10
  62. package/mailbox/store.mjs +7 -7
  63. package/node/denylist.mjs +38 -38
  64. package/node/entity_store.mjs +5 -5
  65. package/node/identity.mjs +7 -8
  66. package/node/instance.mjs +1 -1
  67. package/node/log.mjs +1 -1
  68. package/node/network.mjs +14 -16
  69. package/node/personal_block.mjs +22 -20
  70. package/node/reputation_store.mjs +11 -19
  71. package/node/reputation_sync.mjs +0 -7
  72. package/node/signaling_config.mjs +1 -1
  73. package/node/storage_plugins.mjs +2 -2
  74. package/overlay/index.mjs +1 -1
  75. package/package.json +1 -1
  76. package/registries/event_type.mjs +2 -2
  77. package/registries/room_provider.mjs +2 -2
  78. package/reputation/engine.mjs +17 -15
  79. package/schemas/discovery.mjs +5 -5
  80. package/schemas/federation_pull.mjs +16 -24
  81. package/schemas/part_query.mjs +4 -4
  82. package/timeline/verify_remote.mjs +4 -4
  83. package/transport/group_link_set.mjs +3 -3
  84. package/transport/ice_servers.mjs +2 -2
  85. package/transport/link_registry.mjs +51 -20
  86. package/transport/mesh_keepalive.mjs +1 -1
  87. package/transport/node_scope/wire.mjs +1 -1
  88. package/transport/peer_health.mjs +127 -0
  89. package/transport/remote_user_room.mjs +13 -15
  90. package/transport/rtc_connection_budget.mjs +6 -6
  91. package/transport/runtime_bootstrap.mjs +12 -12
  92. package/trust_graph/cache.mjs +2 -3
  93. package/trust_graph/engine.mjs +1 -1
  94. package/trust_graph/registry.mjs +2 -2
  95. package/trust_graph/send.mjs +1 -1
  96. package/utils/async_mutex.mjs +1 -1
  97. package/wire/ingress.mjs +1 -1
  98. package/wire/part/ingress.mjs +6 -6
  99. package/wire/part/query.mjs +3 -3
  100. package/wire/volatile_signature.mjs +4 -9
@@ -10,7 +10,7 @@ const dagPlaintextReadersByOwner = new Map()
10
10
  * @returns {void}
11
11
  */
12
12
  export function registerTransferKeyDependencies(ownerId, dependencies) {
13
- const key = String(ownerId)
13
+ const key = ownerId
14
14
  const prev = transferDependenciesByOwner.get(key) || {}
15
15
  transferDependenciesByOwner.set(key, { ...prev, ...dependencies })
16
16
  }
@@ -21,7 +21,7 @@ export function registerTransferKeyDependencies(ownerId, dependencies) {
21
21
  * @returns {void}
22
22
  */
23
23
  export function registerDagManifestPlaintextReader(ownerId, reader) {
24
- dagPlaintextReadersByOwner.set(String(ownerId), reader)
24
+ dagPlaintextReadersByOwner.set(ownerId, reader)
25
25
  }
26
26
 
27
27
  /** @type {Array<{ ownerId: string, match: (manifest: import('./manifest/normalize.mjs').FileManifest) => boolean }>} */
@@ -33,7 +33,7 @@ const manifestOwnerMatchers = []
33
33
  * @returns {void}
34
34
  */
35
35
  export function registerManifestOwnerMatcher(ownerId, match) {
36
- manifestOwnerMatchers.push({ ownerId: String(ownerId), match })
36
+ manifestOwnerMatchers.push({ ownerId, match })
37
37
  }
38
38
 
39
39
  /**
@@ -41,7 +41,7 @@ export function registerManifestOwnerMatcher(ownerId, match) {
41
41
  * @returns {void}
42
42
  */
43
43
  export function unregisterManifestOwnerMatchers(ownerId) {
44
- const id = String(ownerId)
44
+ const id = ownerId
45
45
  for (let i = manifestOwnerMatchers.length - 1; i >= 0; i--)
46
46
  if (manifestOwnerMatchers[i].ownerId === id) manifestOwnerMatchers.splice(i, 1)
47
47
  }
@@ -51,7 +51,7 @@ export function unregisterManifestOwnerMatchers(ownerId) {
51
51
  * @returns {void}
52
52
  */
53
53
  export function unregisterTransferKeyDependencies(ownerId) {
54
- const key = String(ownerId)
54
+ const key = ownerId
55
55
  transferDependenciesByOwner.delete(key)
56
56
  dagPlaintextReadersByOwner.delete(key)
57
57
  unregisterManifestOwnerMatchers(key)
@@ -75,7 +75,7 @@ export function resolveManifestTransferOwnerId(manifest) {
75
75
  */
76
76
  export function resolveTransferKeyDependencies(ownerId, manifest) {
77
77
  const resolved = ownerId || (manifest ? resolveManifestTransferOwnerId(manifest) : null)
78
- if (resolved) return transferDependenciesByOwner.get(String(resolved)) || {}
78
+ if (resolved) return transferDependenciesByOwner.get(resolved) || {}
79
79
  return {}
80
80
  }
81
81
 
@@ -1,3 +1,4 @@
1
+ import { normalizeHex64 } from '../core/hexIds.mjs'
1
2
  import { sortedPrevEventIds } from '../dag/index.mjs'
2
3
  import { getGovernanceAuthzTypes } from '../registries/event_type.mjs'
3
4
 
@@ -104,7 +105,7 @@ function governanceAuthzScoreForTip(tipId, byId, reputationBySender) {
104
105
  for (const eventId of ancestorClosureFromTip(tipId, byId)) {
105
106
  const event = byId.get(eventId)
106
107
  if (!event || !getGovernanceAuthzTypes().has(event.type)) continue
107
- const sender = String(event.sender).trim().toLowerCase()
108
+ const sender = normalizeHex64(event.sender)
108
109
  if (sender) score += Number(reputationBySender[sender] ?? 0)
109
110
  }
110
111
  return score
@@ -3,6 +3,8 @@
3
3
  */
4
4
  import { createHash } from 'node:crypto'
5
5
 
6
+ import { normalizeHex64 } from '../core/hexIds.mjs'
7
+
6
8
  import admissionTunables from './tunables.json' with { type: 'json' }
7
9
 
8
10
  /** 默认 epoch 窗口(1 小时) */
@@ -21,7 +23,7 @@ export const JOIN_POW_DEFAULT_EPOCH_SKEW = 1
21
23
  * @returns {string} SHA-256 十六进制
22
24
  */
23
25
  export function computeJoinPowHash({ groupId, anchorRef, joinerNodeHash, epoch, nonce }) {
24
- const preimage = `${String(groupId)}:${String(anchorRef)}:${String(joinerNodeHash)}:${String(epoch)}:${String(nonce)}`
26
+ const preimage = `${groupId}:${anchorRef}:${joinerNodeHash}:${String(epoch)}:${String(nonce)}`
25
27
  return createHash('sha256').update(preimage, 'utf8').digest('hex')
26
28
  }
27
29
 
@@ -100,16 +102,16 @@ export function verifyJoinPow(powSolution, options) {
100
102
  if (floorBits <= 0) return { ok: true, achievedBits: 0 }
101
103
  if (!powSolution || typeof powSolution !== 'object') return { ok: false, achievedBits: 0 }
102
104
 
103
- const anchorRef = String(powSolution.anchorRef ?? '').trim()
105
+ const anchorRef = String(powSolution.anchorRef ?? '')
104
106
  const { nonce } = powSolution
105
107
  const epoch = Number(powSolution.epoch)
106
- const joinerNodeHash = String(powSolution.joinerNodeHash ?? options.senderNodeHash ?? '').trim().toLowerCase()
107
- const senderNodeHash = String(options.senderNodeHash ?? '').trim().toLowerCase()
108
+ const joinerNodeHash = normalizeHex64(powSolution.joinerNodeHash ?? options.senderNodeHash)
109
+ const senderNodeHash = normalizeHex64(options.senderNodeHash)
108
110
 
109
111
  if (!anchorRef || nonce == null || !Number.isFinite(epoch)) return { ok: false, achievedBits: 0 }
110
112
  if (!joinerNodeHash || joinerNodeHash !== senderNodeHash) return { ok: false, achievedBits: 0 }
111
113
 
112
- const anchors = (options.knownAnchors ?? []).map(a => String(a).trim()).filter(Boolean)
114
+ const anchors = (options.knownAnchors ?? []).filter(Boolean)
113
115
  if (!anchors.length || !anchors.includes(anchorRef)) return { ok: false, achievedBits: 0 }
114
116
 
115
117
  const epochMs = Math.max(60_000, Number(options.epochMs) || JOIN_POW_DEFAULT_EPOCH_MS)
@@ -4,7 +4,7 @@
4
4
  import { Buffer } from 'node:buffer'
5
5
 
6
6
  import { canonicalStringify } from '../core/canonical_json.mjs'
7
- import { isHex64, isSignatureHex128 } from '../core/hexIds.mjs'
7
+ import { isHex64, isSignatureHex128, normalizeHex64 } from '../core/hexIds.mjs'
8
8
  import { pubKeyHash, verify } from '../crypto/crypto.mjs'
9
9
 
10
10
  const BALLOT_DOMAIN = 'fount-owner-succession'
@@ -15,9 +15,9 @@ const BALLOT_DOMAIN = 'fount-owner-succession'
15
15
  */
16
16
  export function ownerSuccessionBallotSignBytes(ballot) {
17
17
  const body = {
18
- proposedOwnerPubKeyHash: String(ballot.proposedOwnerPubKeyHash ?? '').trim().toLowerCase(),
19
- groupId: String(ballot.groupId ?? '').trim(),
20
- ballotId: String(ballot.ballotId ?? '').trim(),
18
+ proposedOwnerPubKeyHash: normalizeHex64(ballot.proposedOwnerPubKeyHash),
19
+ groupId: ballot.groupId,
20
+ ballotId: ballot.ballotId,
21
21
  }
22
22
  return Buffer.from(`${BALLOT_DOMAIN}\0${canonicalStringify(body)}`, 'utf8')
23
23
  }
@@ -42,8 +42,8 @@ export async function verifyOwnerSuccessionThreshold(ballot, adminPubKeyHashes,
42
42
  let valid = 0
43
43
 
44
44
  for (const entry of ballot.adminSignatures || []) {
45
- const pubKeyHex = String(entry?.pubKeyHex ?? '').trim().toLowerCase()
46
- const signatureHex = String(entry?.signature ?? '').trim().toLowerCase()
45
+ const pubKeyHex = normalizeHex64(entry.pubKeyHex)
46
+ const signatureHex = entry.signature
47
47
  if (!isHex64(pubKeyHex) || !isSignatureHex128(signatureHex)) continue
48
48
 
49
49
  const hash = pubKeyHash(Buffer.from(pubKeyHex, 'hex'))
package/index.mjs CHANGED
@@ -18,11 +18,11 @@ import {
18
18
  setSignalingRuntimeConfig,
19
19
  } from './node/instance.mjs'
20
20
  import { setConnectivityDebug } from './node/log.mjs'
21
+ import { loadReputation } from './node/reputation_store.mjs'
21
22
  import {
22
23
  attachReputationSyncWire,
23
24
  getReputationExportAllowlist,
24
25
  getReputationLocks,
25
- getReputationTable,
26
26
  getTrustSyncDonors,
27
27
  lockReputationMax,
28
28
  pullReputationFromNode,
@@ -38,6 +38,9 @@ import {
38
38
  ensureLinkToNode,
39
39
  ensureOverlayRouter,
40
40
  getLinkRegistry,
41
+ getPeerHealth,
42
+ listPeerHealth,
43
+ onPeerHealth,
41
44
  reloadDiscoveryRelays,
42
45
  sendToNodeLink,
43
46
  } from './transport/link_registry.mjs'
@@ -66,16 +69,19 @@ export {
66
69
  getLinkRegistry,
67
70
  getNodeDir,
68
71
  getNodeHash,
72
+ getPeerHealth,
69
73
  getReputationExportAllowlist,
70
74
  getReputationLocks,
71
- getReputationTable,
72
75
  getRoutingProfile,
73
76
  getTrustSyncDonors,
74
77
  getUserRoomSlot,
75
78
  initNode,
76
79
  isInfraRunning,
77
80
  isNodeInitialized,
81
+ listPeerHealth,
82
+ loadReputation,
78
83
  lockReputationMax,
84
+ onPeerHealth,
79
85
  pullReputationFromNode,
80
86
  registerDiscoveryProvider,
81
87
  registerLinkProvider,
@@ -17,6 +17,6 @@ export function defaultNodeDir() {
17
17
  * @returns {string} 解析后的绝对 node 目录
18
18
  */
19
19
  export function resolveNodeDir(override) {
20
- const trimmed = String(override || '').trim()
21
- return trimmed ? path.resolve(trimmed) : defaultNodeDir()
20
+ const value = override || ''
21
+ return value ? path.resolve(value) : defaultNodeDir()
22
22
  }
@@ -1,5 +1,5 @@
1
1
  import { normalizeHex64 } from '../core/hexIds.mjs'
2
- import { getReputationTable } from '../node/reputation_sync.mjs'
2
+ import { loadReputation } from '../node/reputation_store.mjs'
3
3
  import { pickNodeScoreFromReputation } from '../reputation/pick_score.mjs'
4
4
  import { getLinkRegistry } from '../transport/link_registry.mjs'
5
5
 
@@ -39,7 +39,7 @@ export function applyPriorityToRegistry() {
39
39
  }
40
40
  registry.setPriorityWeightFunction(nodeHash => {
41
41
  const score = pickNodeScoreFromReputation(
42
- getReputationTable(),
42
+ loadReputation(),
43
43
  normalizeHex64(nodeHash) || nodeHash,
44
44
  )
45
45
  return Math.floor(score * PRIORITY_BOOST)
package/link/frame.mjs CHANGED
@@ -25,11 +25,10 @@ function normalizeFrameIdBytes(frameId) {
25
25
  throw new Error(`p2p: frameId must be ${FRAME_ID_BYTES} bytes`)
26
26
  return frameId
27
27
  }
28
- const text = frameId.trim().toLowerCase()
29
- if (text.length !== FRAME_ID_BYTES * 2)
28
+ if (frameId.length !== FRAME_ID_BYTES * 2)
30
29
  throw new Error('p2p: frameId must be 32 hex characters')
31
30
  try {
32
- return hexToBytes(text)
31
+ return hexToBytes(frameId)
33
32
  }
34
33
  catch {
35
34
  throw new Error('p2p: frameId must be 32 hex characters')
@@ -1,7 +1,7 @@
1
1
  import { Buffer } from 'node:buffer'
2
2
  import { randomBytes } from 'node:crypto'
3
3
 
4
- import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
4
+ import { isHex64, isSignatureHex128, normalizeHex64 } from '../core/hexIds.mjs'
5
5
  import { normalizeTcpPort } from '../core/tcp_port.mjs'
6
6
  import { keyPairFromSeed, pubKeyHash, sign, verify } from '../crypto/crypto.mjs'
7
7
  import { normalizeLanHosts } from '../discovery/lan_interfaces.mjs'
@@ -117,9 +117,9 @@ export function parseHello(hello) {
117
117
  export async function verifyAuth(hello, auth, expectedNonce, remoteBinding) {
118
118
  const parsedHello = parseHello(hello)
119
119
  if (!parsedHello) return null
120
- const signatureHex = String(auth?.sig ?? '').trim().toLowerCase()
120
+ const signatureHex = String(auth?.sig ?? '')
121
121
  const binding = normalizeLinkBinding(remoteBinding)
122
- if (!/^[\da-f]{128}$/u.test(signatureHex) || !binding) return null
122
+ if (!isSignatureHex128(signatureHex) || !binding) return null
123
123
  const normalizedNonce = normalizeHex64(expectedNonce)
124
124
  if (!isHex64(normalizedNonce)) return null
125
125
  const message = buildAuthMessage(normalizedNonce, binding, parsedHello.nodeHash)
@@ -141,7 +141,7 @@ export async function verifyAuth(hello, auth, expectedNonce, remoteBinding) {
141
141
  * @returns {Uint8Array} 待签名消息字节
142
142
  */
143
143
  export function buildAdvertMessage(rendezvousKey, ts, nodeHash, tcpPort = null, lanHosts = null) {
144
- const base = `fount-advert\0${String(rendezvousKey)}\0${String(ts)}\0${normalizeHex64(nodeHash)}`
144
+ const base = `fount-advert\0${rendezvousKey}\0${ts}\0${normalizeHex64(nodeHash)}`
145
145
  const port = normalizeTcpPort(tcpPort)
146
146
  let message = port ? `${base}\0${port}` : base
147
147
  const hosts = normalizeLanHosts(lanHosts)
@@ -194,8 +194,8 @@ export async function verifySignedAdvert(rendezvousKey, advert, now = Date.now()
194
194
  const parsedHello = parseHello({ nodeHash: advert?.nodeHash, nodePubKey: advert?.nodePubKey, nonce: '0'.repeat(64) })
195
195
  if (!parsedHello) return null
196
196
  const ts = Number(advert?.ts)
197
- const sig = String(advert?.sig ?? '').trim().toLowerCase()
198
- if (!Number.isFinite(ts) || Math.abs(now - ts) > maxSkewMs || !/^[\da-f]{128}$/u.test(sig)) return null
197
+ const sig = String(advert?.sig ?? '')
198
+ if (!Number.isFinite(ts) || Math.abs(now - ts) > maxSkewMs || !isSignatureHex128(sig)) return null
199
199
  const hasTcpPortField = !!advert?.tcpPort
200
200
  const tcpPort = normalizeTcpPort(advert?.tcpPort)
201
201
  if (hasTcpPortField && !tcpPort) return null
package/link/pipe.mjs CHANGED
@@ -10,6 +10,11 @@ import { buildAuth, buildHello, parseHello, verifyAuth } from './handshake.mjs'
10
10
  const encoder = new TextEncoder()
11
11
  const decoder = new TextDecoder()
12
12
 
13
+ /** RTT 样本滑动窗口大小。 */
14
+ const RTT_WINDOW_SIZE = 10
15
+ /** 未收到 pong 的 ping 记录保留时长。 */
16
+ const PING_RECORD_TTL_MS = 30000
17
+
13
18
  /**
14
19
  * 把 createLinkPipe 句柄收成上层 LinkHandle(可附带测试/内部字段)。
15
20
  * @param {ReturnType<typeof createLinkPipe>} pipe pipe 句柄
@@ -42,6 +47,11 @@ export function asLinkHandle(pipe, extras = {}) {
42
47
  * @returns {ReturnType<typeof pipe.onDown>} 取消订阅
43
48
  */
44
49
  onDown: (...callArguments) => pipe.onDown(...callArguments),
50
+ /**
51
+ * @param {...any} callArguments 透传 onRtt
52
+ * @returns {ReturnType<typeof pipe.onRtt>} 取消订阅
53
+ */
54
+ onRtt: (...callArguments) => pipe.onRtt(...callArguments),
45
55
  /**
46
56
  * @param {...any} callArguments 透传 close
47
57
  * @returns {ReturnType<typeof pipe.close>} 关闭完成
@@ -70,6 +80,7 @@ export function asLinkHandle(pipe, extras = {}) {
70
80
  * @param {number} [options.heartbeatMs] 心跳间隔
71
81
  * @param {number} [options.idleTimeoutMs] 空闲超时
72
82
  * @param {number} [options.handshakeTimeoutMs] 握手超时
83
+ * @param {number} [options.rttWindowSize] RTT 样本滑动窗口大小
73
84
  * @returns {object} link 句柄 + 入站 API
74
85
  */
75
86
  export function createLinkPipe(options) {
@@ -96,6 +107,20 @@ export function createLinkPipe(options) {
96
107
  let lastOutboundAt = 0
97
108
  let sentFrames = 0
98
109
  let recvFrames = 0
110
+ const rttWindowSize = Math.max(2, Math.floor(Number(options.rttWindowSize) || RTT_WINDOW_SIZE))
111
+ let pingSeq = 0
112
+ /** @type {Map<number, number>} seq -> 发送时间戳,未收到 pong 的 ping */
113
+ const outstandingPings = new Map()
114
+ let rttMs = null
115
+ let avgRttMs = null
116
+ let minRttMs = null
117
+ let maxRttMs = null
118
+ let pingCount = 0
119
+ let pongCount = 0
120
+ /** @type {number[]} */
121
+ const rttSamples = []
122
+ /** @type {Set<(rttMs: number) => void>} */
123
+ const rttListeners = new Set()
99
124
  const envelopeListeners = new Set()
100
125
  const downListeners = new Set()
101
126
  const completedFrameIds = createLruMap(4096)
@@ -142,7 +167,14 @@ export function createLinkPipe(options) {
142
167
  ready = true
143
168
  clearTimeout(handshakeTimer)
144
169
  heartbeatTimer = setInterval(() => {
145
- void send({ scope: 'link', action: 'ping', payload: {} }).catch(() => { })
170
+ pingCount++
171
+ const seq = pingSeq++
172
+ const ts = Date.now()
173
+ const cutoff = ts - PING_RECORD_TTL_MS
174
+ for (const [outSeq, outTs] of outstandingPings)
175
+ if (outTs < cutoff) outstandingPings.delete(outSeq)
176
+ outstandingPings.set(seq, ts)
177
+ void send({ scope: 'link', action: 'ping', payload: { ts, seq } }).catch(() => { })
146
178
  }, heartbeatMs)
147
179
  idleTimer = setInterval(() => {
148
180
  if (Date.now() - lastInboundAt > idleTimeoutMs)
@@ -218,10 +250,29 @@ export function createLinkPipe(options) {
218
250
  if (frameId) completedFrameIds.touch(frameId, true)
219
251
  if (envelope?.scope === 'link') {
220
252
  if (envelope.action === 'ping') {
221
- void send({ scope: 'link', action: 'pong', payload: {} }).catch(() => { })
253
+ const pingPayload = envelope.payload ?? {}
254
+ void send({ scope: 'link', action: 'pong', payload: { ts: pingPayload.ts, seq: pingPayload.seq } }).catch(() => { })
255
+ return
256
+ }
257
+ if (envelope.action === 'pong') {
258
+ const pongPayload = envelope.payload ?? {}
259
+ const sentTs = outstandingPings.get(pongPayload.seq)
260
+ if (Number.isFinite(pongPayload.ts) && sentTs !== undefined && sentTs === pongPayload.ts) {
261
+ outstandingPings.delete(pongPayload.seq)
262
+ pongCount++
263
+ const sample = Date.now() - sentTs
264
+ if (sample >= 0) {
265
+ rttMs = sample
266
+ rttSamples.push(sample)
267
+ if (rttSamples.length > rttWindowSize) rttSamples.shift()
268
+ avgRttMs = Math.round(rttSamples.reduce((total, value) => total + value, 0) / rttSamples.length)
269
+ minRttMs = rttSamples.reduce((minimum, sample) => Math.min(minimum, sample))
270
+ maxRttMs = rttSamples.reduce((minimum, sample) => Math.max(minimum, sample))
271
+ emitSafe(rttListeners, rttMs)
272
+ }
273
+ }
222
274
  return
223
275
  }
224
- if (envelope.action === 'pong') return
225
276
  }
226
277
  if (ready && remoteNodeHash)
227
278
  emitSafe(envelopeListeners, envelope, remoteNodeHash)
@@ -299,8 +350,7 @@ export function createLinkPipe(options) {
299
350
  }))
300
351
  for (const frame of encodeFrames(frameId, bytes)) {
301
352
  const sent = options.sendFrame(envelope.action, frame)
302
- if (sent != null && typeof /** @type {{ then?: unknown }} */ sent.then === 'function')
303
- await sent
353
+ if (sent?.then) await sent
304
354
  sentFrames++
305
355
  }
306
356
  lastOutboundAt = Date.now()
@@ -351,6 +401,14 @@ export function createLinkPipe(options) {
351
401
  downListeners.add(callback)
352
402
  return () => downListeners.delete(callback)
353
403
  },
404
+ /**
405
+ * @param {(rttMs: number) => void} callback RTT 样本回调
406
+ * @returns {() => void} 取消订阅
407
+ */
408
+ onRtt(callback) {
409
+ rttListeners.add(callback)
410
+ return () => rttListeners.delete(callback)
411
+ },
354
412
  close,
355
413
  handleInbound,
356
414
  startHandshake,
@@ -370,6 +428,12 @@ export function createLinkPipe(options) {
370
428
  lastOutboundAt,
371
429
  sentFrames,
372
430
  recvFrames,
431
+ rttMs,
432
+ avgRttMs,
433
+ minRttMs,
434
+ maxRttMs,
435
+ pingCount,
436
+ pongCount,
373
437
  closeReason,
374
438
  ...options.extraStats?.() ?? {},
375
439
  }
@@ -15,14 +15,6 @@ export const BLE_DATA_SERVICE_UUID = 'f017f017f017f017f017f017f017f019'
15
15
  export const BLE_DATA_CHAR_UUID = 'f017f017f017f017f017f017f017f01a'
16
16
  const BT_DEVICE_NAME = 'fount-bt'
17
17
 
18
- /**
19
- * 探测 BLE GATT 数据链路是否可用;失败则回落其它 link provider。
20
- * @returns {Promise<boolean>} 可用为 true
21
- */
22
- export async function canUseBleGattLink() {
23
- return canUseBluetoothRuntime()
24
- }
25
-
26
18
  /**
27
19
  * 在 GATT write/notify 上建立 pipe。
28
20
  * @param {object} options 配置
@@ -279,7 +271,7 @@ export function createBleGattLinkProvider() {
279
271
  id: instanceId,
280
272
  level: LINK_LEVEL_BLE_GATT,
281
273
  caps: { needsOfferAnswer: false, needsDiscoverySignal: false, probe: 'native' },
282
- isAvailable: canUseBleGattLink,
274
+ isAvailable: canUseBluetoothRuntime,
283
275
  /**
284
276
  * @param {{ nodeHash: string }} remote 远端
285
277
  * @returns {boolean} 是否有 BT peer hint
@@ -35,7 +35,7 @@ const providers = new Map()
35
35
  */
36
36
  export function registerLinkProvider(provider) {
37
37
  if (!provider?.id) throw new Error('p2p: link provider requires id')
38
- providers.set(String(provider.id), provider)
38
+ providers.set(provider.id, provider)
39
39
  return () => unregisterLinkProvider(provider.id)
40
40
  }
41
41
 
@@ -45,7 +45,7 @@ export function registerLinkProvider(provider) {
45
45
  * @returns {void}
46
46
  */
47
47
  export function unregisterLinkProvider(id) {
48
- providers.delete(String(id))
48
+ providers.delete(id)
49
49
  }
50
50
 
51
51
  /**
@@ -48,9 +48,7 @@ async function publishLinkPacket(remoteNodeHash, packet) {
48
48
  * @returns {import('./index.mjs').LinkProvider & { deliverPacket: (packet: object) => void }} provider
49
49
  */
50
50
  export function createNostrLinkProvider(options = {}) {
51
- const resolveRelayUrls = typeof options.getRelayUrls === 'function'
52
- ? options.getRelayUrls
53
- : resolveDefaultRelayUrls
51
+ const resolveRelayUrls = options.getRelayUrls || resolveDefaultRelayUrls
54
52
 
55
53
  /** @type {((link: import('./index.mjs').LinkHandle) => void) | null} */
56
54
  let onInbound = null
@@ -264,7 +264,7 @@ export async function createWebRtcLink(options) {
264
264
  */
265
265
  channel.onmessage = event => {
266
266
  try {
267
- const data = event.data
267
+ const { data } = event
268
268
  pipe.handleInbound(typeof data === 'string' ? data : toBytes(data, { allowString: true }))
269
269
  }
270
270
  catch { /* drop */ }
@@ -304,7 +304,7 @@ export async function createWebRtcLink(options) {
304
304
  if (trickleIceOff || !event.candidate) return
305
305
  void sendSignal({
306
306
  type: 'ice',
307
- candidate: typeof event.candidate.toJSON === 'function' ? event.candidate.toJSON() : event.candidate,
307
+ candidate: event.candidate.toJSON?.() || event.candidate,
308
308
  }).catch(error => pipe.close(`signal-send-failed:${formatErrorReason(error)}`))
309
309
  }
310
310
  /**
@@ -11,7 +11,7 @@
11
11
  * @returns {string | null} 处理后的 SDP;drop 策略下不可用时返回 null
12
12
  */
13
13
  export function applyIceLocalHostnamePolicy(candidateSdp, policy) {
14
- const sdp = String(candidateSdp || '').trim()
14
+ const sdp = candidateSdp || ''
15
15
  if (!sdp || !/\.local/i.test(sdp)) return sdp || null
16
16
  if (!/\btyp host\b/i.test(sdp)) return sdp
17
17
  if (policy === 'drop') return null
@@ -34,9 +34,10 @@ export function filterIceLocalHostnameCandidate(candidate, RTCIceCandidateCtor,
34
34
  const rewritten = applyIceLocalHostnamePolicy(raw, policy)
35
35
  if (!rewritten) return null
36
36
  if (rewritten === raw) return candidate
37
- const init = typeof candidate.toJSON === 'function'
38
- ? { ...candidate.toJSON(), candidate: rewritten }
39
- : { candidate: rewritten, sdpMid: candidate.sdpMid, sdpMLineIndex: candidate.sdpMLineIndex }
37
+ const init = {
38
+ ...candidate.toJSON?.() || { sdpMid: candidate.sdpMid, sdpMLineIndex: candidate.sdpMLineIndex },
39
+ candidate: rewritten,
40
+ }
40
41
  return new RTCIceCandidateCtor(init)
41
42
  }
42
43
 
@@ -49,7 +50,7 @@ export function filterIceLocalHostnameCandidate(candidate, RTCIceCandidateCtor,
49
50
  export function wrapRtcPeerConnectionForIceLocalHostname(BaseRTC, RTCIceCandidate, policy = 'drop') {
50
51
  if (policy === 'none') return BaseRTC
51
52
 
52
- const baseRoutesIce = typeof BaseRTC.prototype.prepareIceCandidateEvent === 'function'
53
+ const baseRoutesIce = !!BaseRTC.prototype.prepareIceCandidateEvent
53
54
 
54
55
  return class IceLocalHostnameFilteredRTCPeerConnection extends BaseRTC {
55
56
  /** @type {((event: RTCPeerConnectionIceEvent) => void) | null} */
@@ -36,7 +36,7 @@ function defineEmitterHandler(target, property, eventName, adapt = payload => pa
36
36
  * @returns {RTCDataChannel} 已挂 W3C handler 的通道
37
37
  */
38
38
  export function bridgeDataChannel(channel) {
39
- if (bridgedChannels.has(channel) || typeof channel.on !== 'function') return channel
39
+ if (bridgedChannels.has(channel) || !channel.on) return channel
40
40
  bridgedChannels.add(channel)
41
41
  defineEmitterHandler(channel, 'onmessage', 'message')
42
42
  defineEmitterHandler(channel, 'onopen', 'open')
@@ -20,6 +20,6 @@ export function normalizeDtlsFingerprint(value) {
20
20
  * @returns {string | null} 规范化 fingerprint,未找到时返回 null
21
21
  */
22
22
  export function extractDtlsFingerprint(sdp) {
23
- const line = String(sdp || '').match(/^a=fingerprint:sha-256\s+([\d:A-Fa-f]+)$/mu)?.[1]
23
+ const line = sdp.match(/^a=fingerprint:sha-256\s+([\d:A-Fa-f]+)$/mu)?.[1]
24
24
  return normalizeDtlsFingerprint(line)
25
25
  }
@@ -19,7 +19,7 @@ const consumers = new Map()
19
19
  * @returns {void}
20
20
  */
21
21
  export function registerMailboxConsumer(app, handler) {
22
- consumers.set(String(app), { handler })
22
+ consumers.set(app, { handler })
23
23
  }
24
24
 
25
25
  /**
@@ -27,7 +27,7 @@ export function registerMailboxConsumer(app, handler) {
27
27
  * @returns {void}
28
28
  */
29
29
  export function unregisterMailboxConsumer(app) {
30
- consumers.delete(String(app))
30
+ consumers.delete(app)
31
31
  }
32
32
 
33
33
  /**
@@ -25,7 +25,7 @@ import {
25
25
  async function resolveRemoteNodeHashForPeer(username, peerId) {
26
26
  if (!peerId) return null
27
27
  const entry = activeLinkRoster().find(row => row.peerId === peerId)
28
- const remote = entry?.remoteNodeHash?.trim().toLowerCase()
28
+ const remote = normalizeHex64(entry?.remoteNodeHash)
29
29
  return remote || null
30
30
  }
31
31
 
@@ -76,7 +76,7 @@ export async function deliverOrStoreMailboxPut(username, options) {
76
76
  fromNodeHash: options.record?.fromNodeHash || nodeHash,
77
77
  }
78
78
  const stored = await storeMailboxRecord(record)
79
- const toNodeHash = options.toNodeHash?.trim().toLowerCase()
79
+ const toNodeHash = normalizeHex64(options.toNodeHash)
80
80
  const delivered = toNodeHash && isMailboxRecordWithinSizeLimit(record)
81
81
  ? await requireTrustGraphProvider(DEFAULT_TRUST_GRAPH_OWNER).sendToNode(username, toNodeHash, 'mailbox_put', { nodeHash, record })
82
82
  : false
@@ -116,7 +116,7 @@ export async function ingestMailboxPut(wireContext, put, peerId = '') {
116
116
  if (!record?.envelope || !record?.toPubKeyHash) return
117
117
  const fromNode = normalizeHex64(put.nodeHash)
118
118
  if (!fromNode || !takeIncomingMailboxPutSlot(fromNode)) return
119
- const username = String(wireContext.replicaUsername || '').trim()
119
+ const username = wireContext.replicaUsername || ''
120
120
  if (!username) return
121
121
  if (peerId) {
122
122
  const remote = await resolveRemoteNodeHashForPeer(username, peerId)
@@ -162,7 +162,7 @@ export async function respondMailboxWant(want, sendGive, peerId) {
162
162
  export async function ingestMailboxGive(wireContext, give) {
163
163
  const records = (give.records || []).filter(isDeliverableMailboxRecord)
164
164
  if (!records.length) return 0
165
- const username = String(wireContext.replicaUsername || '').trim()
165
+ const username = wireContext.replicaUsername || ''
166
166
  if (!username) return 0
167
167
  const { dispatchMailboxRecordsToConsumers } = await import('./consumer_registry.mjs')
168
168
  const { deleteMailboxRecords } = await import('./store.mjs')
package/mailbox/parse.mjs CHANGED
@@ -64,7 +64,7 @@ export function parseMailboxGive(payload) {
64
64
  return { ok: false, code: shape.code, field: `records[${i}].${shape.field}` }
65
65
  if (!record.envelope || typeof record.envelope !== 'object')
66
66
  return { ok: false, code: 'required', field: `records[${i}].envelope` }
67
- const app = String(record.app || '').trim()
67
+ const app = String(record.app || '')
68
68
  if (!app)
69
69
  return { ok: false, code: 'required', field: `records[${i}].app` }
70
70
  records.push(record)
package/mailbox/prune.mjs CHANGED
@@ -32,8 +32,8 @@ export function mailboxRecordBytes(record) {
32
32
  */
33
33
  export function mailboxBucketKey(record) {
34
34
  const to = normalizeHex64(record.toPubKeyHash) || ''
35
- const from = record.fromNodeHash?.trim().toLowerCase()
36
- || record.envelope?.sender?.trim().toLowerCase()
35
+ const from = normalizeHex64(record.fromNodeHash)
36
+ || normalizeHex64(record.envelope?.sender)
37
37
  || 'unknown'
38
38
  return `${to}\0${from}`
39
39
  }
package/mailbox/rate.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Mailbox 入站 put 限速(按来源节点,节点级单例)。
3
3
  */
4
+ import { normalizeHex64 } from '../core/hexIds.mjs'
4
5
 
5
6
  const DEFAULT_WINDOW_MS = 60_000
6
7
  const DEFAULT_MAX_PUTS = 20
@@ -14,21 +15,13 @@ const inboundByKey = new Map()
14
15
  * @param {object} [limits] 可选限额
15
16
  * @returns {{ windowMs: number, maxPuts: number }} 生效限额
16
17
  */
17
- export function resolveMailboxRateLimits(limits = {}) {
18
+ function resolveMailboxRateLimits(limits = {}) {
18
19
  return {
19
20
  windowMs: Math.max(1000, Number(limits.windowMs) || DEFAULT_WINDOW_MS),
20
21
  maxPuts: Math.max(1, Math.min(256, Number(limits.maxPuts) || DEFAULT_MAX_PUTS)),
21
22
  }
22
23
  }
23
24
 
24
- /**
25
- * @param {string} fromNodeHash 来源节点
26
- * @returns {string} 限速键
27
- */
28
- export function mailboxRateKey(fromNodeHash) {
29
- return String(fromNodeHash || '').trim()
30
- }
31
-
32
25
  /**
33
26
  * @param {number} now 当前时间戳
34
27
  * @returns {void}
@@ -69,7 +62,7 @@ function evictLruIfNeeded(key) {
69
62
  */
70
63
  export function takeIncomingMailboxPutSlot(fromNodeHash, limits) {
71
64
  const { windowMs, maxPuts } = resolveMailboxRateLimits(limits)
72
- const key = mailboxRateKey(fromNodeHash)
65
+ const key = normalizeHex64(fromNodeHash)
73
66
  const now = Date.now()
74
67
  if (inboundByKey.size >= MAX_KEYS) sweepExpiredEntries(now)
75
68
  evictLruIfNeeded(key)