@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
@@ -6,6 +6,7 @@ const DEFAULT_GROUP = '239.255.42.99'
6
6
 
7
7
  /**
8
8
  * 轻量 multicast 发现插件:不做完整 DNS-SD,只复用 mDNS 的 LAN multicast 发现思路。
9
+ * 引用计数为 0 时关闭 UDP socket,避免 shutdown 后句柄拖住进程。
9
10
  *
10
11
  * @param {{ port?: number, group?: string }} [options] 组播端口与组地址
11
12
  * @returns {import('./index.mjs').DiscoveryProvider} mDNS 发现提供者
@@ -13,14 +14,24 @@ const DEFAULT_GROUP = '239.255.42.99'
13
14
  export function createMdnsDiscoveryProvider(options = {}) {
14
15
  const port = Number(options.port) || DEFAULT_PORT
15
16
  const group = String(options.group || DEFAULT_GROUP)
16
- const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true })
17
+ /** @type {import('node:dgram').Socket | null} */
18
+ let socket = null
17
19
  let bound = false
18
20
  let bindPromise = null
21
+ let refs = 0
19
22
  /** @type {Map<string, Set<Function>>} */
20
23
  const advertListeners = new Map()
21
24
  /** @type {Map<string, Set<Function>>} */
22
25
  const signalListeners = new Map()
23
26
 
27
+ /**
28
+ * 懒创建或返回当前 UDP socket。
29
+ * @returns {import('node:dgram').Socket} 用于组播 bind/send 的 socket
30
+ */
31
+ function getSocket() {
32
+ return socket ||= dgram.createSocket({ type: 'udp4', reuseAddr: true })
33
+ }
34
+
24
35
  /**
25
36
  * 绑定 UDP socket 并注册组播消息处理器。
26
37
  * @returns {Promise<void>}
@@ -29,16 +40,24 @@ export function createMdnsDiscoveryProvider(options = {}) {
29
40
  if (bound) return
30
41
  if (!bindPromise)
31
42
  bindPromise = (async () => {
43
+ const sock = getSocket()
32
44
  await new Promise((resolve, reject) => {
33
- socket.once('error', reject)
34
- socket.bind(port, '0.0.0.0', () => {
35
- socket.off('error', reject)
36
- socket.addMembership(group)
37
- socket.setMulticastTTL(1)
45
+ sock.once('error', reject)
46
+ sock.bind(port, '0.0.0.0', () => {
47
+ sock.off('error', reject)
48
+ sock.addMembership(group)
49
+ sock.setMulticastTTL(1)
38
50
  resolve()
39
51
  })
40
52
  })
41
- socket.on('message', (raw, rinfo) => {
53
+ // acquire 已全部释放:关掉刚 bind 的 socket,避免泄漏句柄。
54
+ if (refs <= 0) {
55
+ try { sock.close() } catch { /* ignore */ }
56
+ if (socket === sock) socket = null
57
+ bound = false
58
+ return
59
+ }
60
+ sock.on('message', (raw, rinfo) => {
42
61
  let packet
43
62
  try { packet = JSON.parse(String(raw)) } catch { return }
44
63
  const listeners = packet.type === 'advert'
@@ -70,16 +89,34 @@ export function createMdnsDiscoveryProvider(options = {}) {
70
89
  */
71
90
  async function multicast(type, topic, bytes) {
72
91
  await ensureBound()
92
+ const sock = getSocket()
73
93
  const packet = Buffer.from(JSON.stringify({
74
94
  type,
75
95
  topic,
76
96
  data: Buffer.from(bytes).toString('base64'),
77
97
  }))
78
98
  await new Promise((resolve, reject) => {
79
- socket.send(packet, port, group, error => error ? reject(error) : resolve())
99
+ sock.send(packet, port, group, error => error ? reject(error) : resolve())
80
100
  })
81
101
  }
82
102
 
103
+ /**
104
+ * 占用 socket;归还后若无引用则关闭。
105
+ * @returns {() => void} 释放函数
106
+ */
107
+ function acquire() {
108
+ refs++
109
+ void ensureBound().catch(() => { })
110
+ return () => {
111
+ refs = Math.max(0, refs - 1)
112
+ if (refs > 0 || !socket) return
113
+ try { socket.close() } catch { /* ignore */ }
114
+ socket = null
115
+ bound = false
116
+ bindPromise = null
117
+ }
118
+ }
119
+
83
120
  /**
84
121
  * 向 topic bucket 注册监听器。
85
122
  * @param {Map<string, Set<Function>>} bucket topic → 监听器集合
@@ -90,7 +127,17 @@ export function createMdnsDiscoveryProvider(options = {}) {
90
127
  function addListener(bucket, topic, listener) {
91
128
  if (!bucket.has(topic)) bucket.set(topic, new Set())
92
129
  bucket.get(topic).add(listener)
93
- return () => bucket.get(topic)?.delete(listener)
130
+ const release = acquire()
131
+ return () => {
132
+ const set = bucket.get(topic)
133
+ if (!set) {
134
+ release()
135
+ return
136
+ }
137
+ set.delete(listener)
138
+ if (!set.size) bucket.delete(topic)
139
+ release()
140
+ }
94
141
  }
95
142
 
96
143
  return {
@@ -98,24 +145,27 @@ export function createMdnsDiscoveryProvider(options = {}) {
98
145
  priority: 10,
99
146
  caps: { canDiscover: true, canSignal: true, canRelay: false },
100
147
  /**
101
- * 周期性组播广播 advert
148
+ * 周期性组播广播 advert(首发后台,不阻塞调用方)。
102
149
  * @param {string} topic advert 主题
103
150
  * @param {Uint8Array} bytes advert 载荷
104
151
  * @returns {Promise<() => void>} 取消广播函数
105
152
  */
106
153
  async advertise(topic, bytes) {
107
- await multicast('advert', topic, bytes)
154
+ const release = acquire()
155
+ void multicast('advert', topic, bytes).catch(() => { })
108
156
  const timer = setInterval(() => { void multicast('advert', topic, bytes).catch(() => { }) }, 30_000)
109
- return () => clearInterval(timer)
157
+ return () => {
158
+ clearInterval(timer)
159
+ release()
160
+ }
110
161
  },
111
162
  /**
112
- * 订阅组播 advert
163
+ * 订阅组播 advert(立即返回;UDP bind 后台完成)。
113
164
  * @param {string} topic advert 主题
114
165
  * @param {Function} onAdvert advert 回调
115
166
  * @returns {Promise<() => void>} 取消订阅函数
116
167
  */
117
168
  async subscribe(topic, onAdvert) {
118
- await ensureBound()
119
169
  return addListener(advertListeners, topic, onAdvert)
120
170
  },
121
171
  /**
@@ -126,16 +176,21 @@ export function createMdnsDiscoveryProvider(options = {}) {
126
176
  * @returns {Promise<void>}
127
177
  */
128
178
  async sendSignal(topic, _to, bytes) {
129
- await multicast('signal', topic, bytes)
179
+ const release = acquire()
180
+ try {
181
+ await multicast('signal', topic, bytes)
182
+ }
183
+ finally {
184
+ release()
185
+ }
130
186
  },
131
187
  /**
132
- * 订阅组播信令。
188
+ * 订阅组播信令(立即返回;UDP bind 后台完成)。
133
189
  * @param {string} topic 信令 topic
134
190
  * @param {Function} onSignal 信令回调
135
191
  * @returns {Promise<() => void>} 取消订阅函数
136
192
  */
137
193
  async onSignal(topic, onSignal) {
138
- await ensureBound()
139
194
  return addListener(signalListeners, topic, onSignal)
140
195
  },
141
196
  }
@@ -1,7 +1,9 @@
1
1
  import { randomBytes } from 'node:crypto'
2
2
 
3
3
  import { schnorr } from '@noble/curves/secp256k1.js'
4
+ import WebSocket from 'ws'
4
5
 
6
+ import { base64ToBytes, hexToBytes, bytesToBase64, bytesToHex } from '../core/bytes_codec.mjs'
5
7
  import { sha256Hex } from '../crypto/crypto.mjs'
6
8
 
7
9
  /** 默认 Nostr 中继 URL 列表。 */
@@ -11,67 +13,64 @@ export const DEFAULT_RELAY_URLS = [
11
13
  'wss://relay.nostr.band',
12
14
  ]
13
15
 
16
+ /** 单中继 WebSocket 首连超时(短超时 + 并行,避免串行 10s×N)。 */
17
+ export const NOSTR_CONNECT_TIMEOUT_MS = 2_000
18
+ /** 先 close,超时未 CLOSED 再 terminate(给对端优雅关闭的窗口)。 */
19
+ export const NOSTR_CLOSE_GRACE_MS = 1_000
20
+
14
21
  /** Nostr advert 事件 kind。 */
15
22
  export const NOSTR_ADVERT_KIND = 27235
16
23
  /** Nostr signal 事件 kind。 */
17
24
  export const NOSTR_SIGNAL_KIND = 27236
18
25
 
19
26
  /**
20
- * 合并默认与用户配置的中继 URL(去重)。
21
- * @param {string[] | undefined | null} userRelayUrls 用户自定义中继列表
22
- * @returns {string[]} 合并后的中继 URL 列表
27
+ * 关掉 WebSocket:已连上则先 close,grace 内未 CLOSED 再 terminate。
28
+ * 连接中直接 terminate(无握手可优雅收尾)。
29
+ * @param {import('ws').WebSocket} ws 连接
30
+ * @returns {void}
23
31
  */
24
- export function mergeSignalingRelayUrls(userRelayUrls) {
25
- const seen = new Set()
26
- /** @type {string[]} */
27
- const merged = []
28
- for (const url of [...DEFAULT_RELAY_URLS, ...Array.isArray(userRelayUrls) ? userRelayUrls : []]) {
29
- const trimmed = String(url || '').trim()
30
- if (!trimmed || seen.has(trimmed)) continue
31
- seen.add(trimmed)
32
- merged.push(trimmed)
32
+ function dropWebSocket(ws) {
33
+ if (ws.readyState === WebSocket.CLOSED) return
34
+ if (ws.readyState === WebSocket.CONNECTING) {
35
+ try { ws.terminate() } catch { /* ignore */ }
36
+ return
37
+ }
38
+ const timer = setTimeout(() => {
39
+ if (ws.readyState !== WebSocket.CLOSED)
40
+ try { ws.terminate() } catch { /* ignore */ }
41
+ }, NOSTR_CLOSE_GRACE_MS)
42
+ // Grace is polite to the peer; must not pin the process after shutdown.
43
+ timer.unref()
44
+ ws.once('close', () => clearTimeout(timer))
45
+ try {
46
+ ws.close()
47
+ }
48
+ catch {
49
+ clearTimeout(timer)
50
+ try { ws.terminate() } catch { /* ignore */ }
33
51
  }
34
- return merged.length ? merged : [...DEFAULT_RELAY_URLS]
35
- }
36
-
37
- /**
38
- * 字节数组转十六进制字符串。
39
- * @param {Uint8Array} bytes 输入字节
40
- * @returns {string} 小写 hex 字符串
41
- */
42
- function bytesToHex(bytes) {
43
- return [...bytes].map(byte => byte.toString(16).padStart(2, '0')).join('')
44
- }
45
-
46
- /**
47
- * 十六进制字符串转字节数组。
48
- * @param {string} hex 输入 hex 字符串
49
- * @returns {Uint8Array} 解码后的字节
50
- */
51
- function hexToBytes(hex) {
52
- const normalized = String(hex || '').trim().toLowerCase()
53
- const out = new Uint8Array(Math.floor(normalized.length / 2))
54
- for (let index = 0; index < out.length; index++)
55
- out[index] = parseInt(normalized.slice(index * 2, index * 2 + 2), 16)
56
- return out
57
52
  }
58
53
 
59
54
  /**
60
- * 字节数组转 base64 字符串。
61
- * @param {Uint8Array} bytes 输入字节
62
- * @returns {string} base64 编码
55
+ * 去重并清洗中继 URL 列表。
56
+ * @param {string[] | undefined | null} urls 原始列表
57
+ * @returns {string[]} 清洗后的列表
63
58
  */
64
- function bytesToBase64(bytes) {
65
- return btoa(String.fromCharCode(...bytes))
59
+ function dedupeRelayUrls(urls) {
60
+ const seen = new Set()
61
+ return (urls || [])
62
+ .map(url => url.trim())
63
+ .filter(trimmed => trimmed && !seen.has(trimmed) && (seen.add(trimmed), true))
66
64
  }
67
65
 
68
66
  /**
69
- * base64 字符串转字节数组。
70
- * @param {string} base64 输入 base64 字符串
71
- * @returns {Uint8Array} 解码后的字节
67
+ * 合并默认与用户配置的中继 URL(去重)。
68
+ * @param {string[] | undefined | null} userRelayUrls 用户自定义中继列表
69
+ * @returns {string[]} 合并后的中继 URL 列表
72
70
  */
73
- function base64ToBytes(base64) {
74
- return Uint8Array.from(atob(base64).split('').map(ch => ch.charCodeAt(0)))
71
+ export function mergeSignalingRelayUrls(userRelayUrls) {
72
+ const merged = dedupeRelayUrls([...DEFAULT_RELAY_URLS, ...userRelayUrls || []])
73
+ return merged.length ? merged : [...DEFAULT_RELAY_URLS]
75
74
  }
76
75
 
77
76
  /**
@@ -92,40 +91,67 @@ async function signNostrEvent(kind, tags, content, secretKey) {
92
91
  }
93
92
 
94
93
  /**
95
- * 连接 Nostr 中继 WebSocket
94
+ * 连接 Nostr 中继 WebSocket(`ws` 包,支持 terminate)。
96
95
  * @param {string} relayUrl 中继 URL
97
- * @returns {Promise<WebSocket>} 已打开的 WebSocket
96
+ * @param {number} [timeoutMs] 超时毫秒
97
+ * @param {AbortSignal} [signal] 取消信号
98
+ * @returns {Promise<import('ws').WebSocket>} 已打开的 WebSocket
98
99
  */
99
- function connectRelay(relayUrl) {
100
+ function connectRelay(relayUrl, timeoutMs = NOSTR_CONNECT_TIMEOUT_MS, signal) {
100
101
  return new Promise((resolve, reject) => {
102
+ if (signal?.aborted) {
103
+ reject(new Error('nostr: aborted'))
104
+ return
105
+ }
101
106
  const ws = new WebSocket(relayUrl)
102
- const timer = setTimeout(() => {
103
- try { ws.close() } catch { /* ignore */ }
104
- reject(new Error(`nostr: connect timeout for ${relayUrl}`))
105
- }, 10_000)
106
- ws.addEventListener('open', () => {
107
+ let settled = false
108
+ /**
109
+ * @param {Error} error 失败原因
110
+ * @returns {void}
111
+ */
112
+ const fail = error => {
113
+ if (settled) return
114
+ settled = true
107
115
  clearTimeout(timer)
108
- resolve(ws)
109
- }, { once: true })
110
- ws.addEventListener('error', () => {
116
+ signal?.removeEventListener('abort', onAbort)
117
+ dropWebSocket(ws)
118
+ reject(error)
119
+ }
120
+ /**
121
+ * @returns {void}
122
+ */
123
+ const onAbort = () => fail(new Error('nostr: aborted'))
124
+ const timer = setTimeout(() => fail(new Error(`nostr: connect timeout for ${relayUrl}`)), timeoutMs)
125
+ timer.unref()
126
+ signal?.addEventListener('abort', onAbort, { once: true })
127
+ ws.once('open', () => {
128
+ if (settled) return
129
+ settled = true
111
130
  clearTimeout(timer)
112
- reject(new Error(`nostr: websocket error for ${relayUrl}`))
113
- }, { once: true })
131
+ signal?.removeEventListener('abort', onAbort)
132
+ resolve(ws)
133
+ })
134
+ ws.once('error', () => {
135
+ fail(new Error(`nostr: websocket error for ${relayUrl}`))
136
+ })
114
137
  })
115
138
  }
116
139
 
117
140
  /**
118
- * 向多个中继发布 Nostr 事件。
141
+ * 向多个中继并行发布 Nostr 事件(任一成功即可)。
119
142
  * @param {string[]} relayUrls 中继 URL 列表
120
143
  * @param {object} event 待发布事件
144
+ * @param {AbortSignal} [signal] 取消信号
121
145
  * @returns {Promise<void>}
122
146
  */
123
- async function publishEvent(relayUrls, event) {
147
+ async function publishEvent(relayUrls, event, signal) {
124
148
  let published = false
125
149
  let lastError = null
126
150
  await Promise.allSettled(relayUrls.map(async relayUrl => {
127
- const ws = await connectRelay(relayUrl)
151
+ if (signal?.aborted) throw new Error('nostr: aborted')
152
+ const ws = await connectRelay(relayUrl, NOSTR_CONNECT_TIMEOUT_MS, signal)
128
153
  try {
154
+ if (signal?.aborted) throw new Error('nostr: aborted')
129
155
  ws.send(JSON.stringify(['EVENT', event]))
130
156
  published = true
131
157
  }
@@ -134,91 +160,123 @@ async function publishEvent(relayUrls, event) {
134
160
  throw error
135
161
  }
136
162
  finally {
137
- setTimeout(() => { try { ws.close() } catch { /* ignore */ } }, 250)
163
+ dropWebSocket(ws)
138
164
  }
139
165
  }))
140
166
  if (!published) throw lastError || new Error('nostr: no relay accepted publish')
141
167
  }
142
168
 
169
+ /**
170
+ * 并行渐进连接中继:立刻返回,连上后回调;abort 后 stop 接入并 drop 已开 socket。
171
+ * @param {string[]} relayUrls 中继列表
172
+ * @param {(ws: import('ws').WebSocket, relayUrl: string) => void} onOpen 连上回调
173
+ * @param {AbortSignal} signal 取消信号
174
+ * @param {import('ws').WebSocket[]} sockets 已打开 socket 收集
175
+ * @returns {void}
176
+ */
177
+ function connectRelaysProgressive(relayUrls, onOpen, signal, sockets) {
178
+ for (const relayUrl of relayUrls)
179
+ void connectRelay(relayUrl, NOSTR_CONNECT_TIMEOUT_MS, signal).then(ws => {
180
+ if (signal.aborted) {
181
+ dropWebSocket(ws)
182
+ return
183
+ }
184
+ sockets.push(ws)
185
+ onOpen(ws, relayUrl)
186
+ }).catch(() => { /* 单路失败正常降级 */ })
187
+ }
188
+
189
+ /**
190
+ * 订阅指定 kind 的 Nostr 事件(渐进连中继,立即返回 cleanup)。
191
+ * @param {string[]} relayUrls 中继 URL 列表
192
+ * @param {{ kind: number, topic: string, tagX: string, onPayload: (bytes: Uint8Array, meta: { relayUrl: string, event: object }) => void }} options 订阅选项
193
+ * @returns {() => void} 取消订阅
194
+ */
195
+ function subscribeNostrKind(relayUrls, options) {
196
+ const { kind, topic, tagX, onPayload } = options
197
+ const abortController = new AbortController()
198
+ /** @type {import('ws').WebSocket[]} */
199
+ const sockets = []
200
+ const subscriptionId = randomBytes(8).toString('hex')
201
+ connectRelaysProgressive(relayUrls, (ws, relayUrl) => {
202
+ ws.on('message', data => {
203
+ if (abortController.signal.aborted) return
204
+ let parsed
205
+ try { parsed = JSON.parse(String(data)) } catch { return }
206
+ if (parsed?.[0] !== 'EVENT') return
207
+ const nostrEvent = parsed[2]
208
+ if (nostrEvent?.kind !== kind) return
209
+ try { onPayload(base64ToBytes(nostrEvent.content), { relayUrl, event: nostrEvent }) }
210
+ catch { /* ignore */ }
211
+ })
212
+ ws.send(JSON.stringify(['REQ', subscriptionId, { kinds: [kind], '#t': [topic], '#x': [tagX] }]))
213
+ }, abortController.signal, sockets)
214
+ return () => {
215
+ abortController.abort()
216
+ for (const ws of sockets) dropWebSocket(ws)
217
+ }
218
+ }
219
+
143
220
  /**
144
221
  * 创建 Nostr discovery provider。
145
- * @param {{ relayUrls?: string[] }} [options] 可选中继 URL 覆盖
222
+ * subscribe/onSignal/advertise 首调用不阻塞等公网中继;连上后渐进生效。
223
+ * `options.relayUrls` 为最终列表(调用方 `mergeSignalingRelayUrls`,或直接传入 `relayOverride`)。
224
+ * 省略 / `undefined` → 默认公网中继;显式 `[]` 表示无中继(不再回填默认)。
225
+ * @param {{ relayUrls?: string[] | null }} [options] 可选最终中继 URL 列表
146
226
  * @returns {import('./index.mjs').DiscoveryProvider} Nostr 发现提供者
147
227
  */
148
228
  export function createNostrDiscoveryProvider(options = {}) {
149
- const relayUrls = mergeSignalingRelayUrls(options.relayUrls)
229
+ const relayUrls = dedupeRelayUrls(options.relayUrls || DEFAULT_RELAY_URLS)
150
230
  const secretKey = randomBytes(32)
151
231
  return {
152
232
  id: 'nostr',
153
233
  priority: 100,
154
234
  caps: { canDiscover: true, canSignal: true, canRelay: false },
155
235
  /**
156
- * 周期性向中继发布 advert 事件。
236
+ * 周期性向中继发布 advert 事件(首发后台,不阻塞 ensureRuntime)。
157
237
  * @param {string} topic advert 主题
158
238
  * @param {Uint8Array} bytes advert 载荷
159
239
  * @returns {Promise<() => void>} 取消广播函数
160
240
  */
161
241
  async advertise(topic, bytes) {
162
- let closed = false
242
+ const abortController = new AbortController()
163
243
  /**
164
244
  * 向中继发布当前 advert。
165
245
  * @returns {Promise<void>}
166
246
  */
167
247
  const publish = async () => {
168
- if (closed) return
248
+ if (abortController.signal.aborted) return
169
249
  const event = await signNostrEvent(
170
250
  NOSTR_ADVERT_KIND,
171
251
  [['t', topic], ['x', 'advert']],
172
252
  bytesToBase64(bytes),
173
253
  secretKey,
174
254
  )
175
- await publishEvent(relayUrls, event)
255
+ await publishEvent(relayUrls, event, abortController.signal)
176
256
  }
177
- await publish()
257
+ void publish().catch(() => { })
178
258
  const timer = setInterval(() => { void publish().catch(() => { }) }, 5 * 60_000)
179
259
  return () => {
180
- closed = true
260
+ abortController.abort()
181
261
  clearInterval(timer)
182
262
  }
183
263
  },
184
264
  /**
185
- * 订阅中继上的 advert 事件。
265
+ * 订阅中继上的 advert 事件(并行渐进连中继,立即返回 cleanup)。
186
266
  * @param {string} topic advert 主题
187
267
  * @param {Function} onAdvert advert 回调
188
268
  * @returns {Promise<() => void>} 取消订阅函数
189
269
  */
190
270
  async subscribe(topic, onAdvert) {
191
- let closed = false
192
- const sockets = []
193
- const subscriptionId = randomBytes(8).toString('hex')
194
- let connected = 0
195
- for (const relayUrl of relayUrls) {
196
- let ws
197
- try { ws = await connectRelay(relayUrl) }
198
- catch { continue }
199
- connected++
200
- sockets.push(ws)
201
- ws.addEventListener('message', event => {
202
- if (closed) return
203
- let parsed
204
- try { parsed = JSON.parse(String(event.data)) } catch { return }
205
- if (!Array.isArray(parsed) || parsed[0] !== 'EVENT') return
206
- const nostrEvent = parsed[2]
207
- if (nostrEvent?.kind !== NOSTR_ADVERT_KIND) return
208
- try { onAdvert(base64ToBytes(String(nostrEvent.content || '')), { relayUrl, event: nostrEvent }) }
209
- catch { /* ignore */ }
210
- })
211
- ws.send(JSON.stringify(['REQ', subscriptionId, { kinds: [NOSTR_ADVERT_KIND], '#t': [topic], '#x': ['advert'] }]))
212
- }
213
- if (!connected) throw new Error('nostr: no relay available for advert subscribe')
214
- return () => {
215
- closed = true
216
- for (const ws of sockets)
217
- try { ws.close() } catch { /* ignore */ }
218
- }
271
+ return subscribeNostrKind(relayUrls, {
272
+ kind: NOSTR_ADVERT_KIND,
273
+ topic,
274
+ tagX: 'advert',
275
+ onPayload: onAdvert,
276
+ })
219
277
  },
220
278
  /**
221
- * 向中继发布 signal 事件。
279
+ * 向中继发布 signal 事件(按需发送,仍等待至少一路成功)。
222
280
  * @param {string} topic 信令 topic
223
281
  * @param {string} to 目标节点标识
224
282
  * @param {Uint8Array} bytes 信令载荷
@@ -234,40 +292,18 @@ export function createNostrDiscoveryProvider(options = {}) {
234
292
  await publishEvent(relayUrls, event)
235
293
  },
236
294
  /**
237
- * 订阅中继上的 signal 事件。
295
+ * 订阅中继上的 signal 事件(并行渐进连中继,立即返回 cleanup)。
238
296
  * @param {string} topic 信令 topic
239
297
  * @param {Function} onSignal 信令回调
240
298
  * @returns {Promise<() => void>} 取消订阅函数
241
299
  */
242
300
  async onSignal(topic, onSignal) {
243
- let closed = false
244
- const sockets = []
245
- const subscriptionId = randomBytes(8).toString('hex')
246
- let connected = 0
247
- for (const relayUrl of relayUrls) {
248
- let ws
249
- try { ws = await connectRelay(relayUrl) }
250
- catch { continue }
251
- connected++
252
- sockets.push(ws)
253
- ws.addEventListener('message', event => {
254
- if (closed) return
255
- let parsed
256
- try { parsed = JSON.parse(String(event.data)) } catch { return }
257
- if (!Array.isArray(parsed) || parsed[0] !== 'EVENT') return
258
- const nostrEvent = parsed[2]
259
- if (nostrEvent?.kind !== NOSTR_SIGNAL_KIND) return
260
- try { onSignal(base64ToBytes(String(nostrEvent.content || '')), { relayUrl, event: nostrEvent }) }
261
- catch { /* ignore */ }
262
- })
263
- ws.send(JSON.stringify(['REQ', subscriptionId, { kinds: [NOSTR_SIGNAL_KIND], '#t': [topic], '#x': ['signal'] }]))
264
- }
265
- if (!connected) throw new Error('nostr: no relay available for signal subscribe')
266
- return () => {
267
- closed = true
268
- for (const ws of sockets)
269
- try { ws.close() } catch { /* ignore */ }
270
- }
301
+ return subscribeNostrKind(relayUrls, {
302
+ kind: NOSTR_SIGNAL_KIND,
303
+ topic,
304
+ tagX: 'signal',
305
+ onPayload: onSignal,
306
+ })
271
307
  },
272
308
  }
273
309
  }
@@ -1,4 +1,4 @@
1
- import { b64ToU8 } from '../core/bytes_codec.mjs'
1
+ import { base64ToBytes } from '../core/bytes_codec.mjs'
2
2
  import { verifiedChunkBytes } from '../files/chunk_fetch_verify.mjs'
3
3
 
4
4
  /** @type {Map<string, { expectedHash: string, timer: ReturnType<typeof setTimeout>, resolve: (v: Uint8Array | null) => void, reject?: (e: Error) => void }>} */
@@ -109,14 +109,13 @@ export function resolveChunkFetchWait(key, expectedHash, bytes) {
109
109
  * @returns {boolean} 是否命中 pending
110
110
  */
111
111
  export function resolvePendingChunkFetch(payload) {
112
- const requestId = String(payload?.requestId || '')
112
+ const requestId = typeof payload?.requestId === 'string' ? payload.requestId : ''
113
113
  if (!requestId) return false
114
114
  const entry = pendingChunkFetches.get(requestId)
115
115
  if (!entry) return false
116
- if (payload?.dataB64) {
116
+ if (typeof payload?.dataBase64 === 'string') {
117
117
  try {
118
- const bytes = b64ToU8(String(payload.dataB64))
119
- return resolveChunkFetchWait(requestId, entry.expectedHash, bytes)
118
+ return resolveChunkFetchWait(requestId, entry.expectedHash, base64ToBytes(payload.dataBase64))
120
119
  }
121
120
  catch { /* keep waiting */ }
122
121
  return false
@@ -56,7 +56,7 @@ export function resolveEventTopologicalOrder(events, cache, options = {}) {
56
56
  const tipsHash = computeLocalTipsHash(tips)
57
57
  const eventCount = events.length
58
58
 
59
- if (cache && Array.isArray(cache.order) && cache.order.length) {
59
+ if (cache?.order?.length) {
60
60
  if (cache.tipsHash === tipsHash && cache.eventCount === eventCount) {
61
61
  const ok = cache.order.length === eventCount && cache.order.every(id => byId.has(id))
62
62
  if (ok) return cache.order
@@ -131,9 +131,8 @@ export function reduceEntityKeyRotate(state, event) {
131
131
  export function reduceEntityKeyRevoke(state, event) {
132
132
  const newGeneration = Number(event.content?.newGeneration)
133
133
  const activePubKeyHex = normalizeHex64(event.content?.activePubKeyHex || '')
134
- const revokeGenerations = Array.isArray(event.content?.revokeGenerations)
135
- ? event.content.revokeGenerations.map(g => Number(g)).filter(Number.isFinite)
136
- : []
134
+ const revokeGenerations = (event.content?.revokeGenerations || [])
135
+ .map(Number).filter(Number.isFinite)
137
136
  if (!Number.isFinite(newGeneration) || newGeneration < 0 || !isHex64(activePubKeyHex))
138
137
  return state
139
138
  state.entityKeyHistory = state.entityKeyHistory || []
@@ -181,7 +180,7 @@ export function foldEntityKeyHistoryFromEvents(events) {
181
180
  */
182
181
  export function entityKeyRevokeSignBytes(revokeBody) {
183
182
  const body = {
184
- revokeGenerations: (revokeBody.revokeGenerations || []).map(g => Number(g)),
183
+ revokeGenerations: (revokeBody.revokeGenerations || []).map(Number),
185
184
  newGeneration: Number(revokeBody.newGeneration),
186
185
  activePubKeyHex: normalizeHex64(revokeBody.activePubKeyHex || ''),
187
186
  entityHash: String(revokeBody.entityHash || '').trim().toLowerCase(),
@@ -1,5 +1,10 @@
1
- /** @type {Map<string, { fp: string, order: string[] }>} */
2
- const memoByKey = new Map()
1
+ import { createLruMap } from '../utils/lru.mjs'
2
+
3
+ /** 进程内拓扑序 memo 上限(每条目持有整份 order 数组) */
4
+ const MEMO_MAX = 64
5
+
6
+ /** @type {ReturnType<typeof createLruMap<string, { fp: string, order: string[] }>>} */
7
+ const memoByKey = createLruMap(MEMO_MAX)
3
8
 
4
9
  /**
5
10
  * 进程内拓扑序 memo;`resolveOrder` 可接入磁盘缓存等实现。
@@ -12,11 +17,13 @@ const memoByKey = new Map()
12
17
  export function resolveTopologicalOrderMemoCached(memoKey, fingerprint, resolveOrder, options = {}) {
13
18
  if (!options.force) {
14
19
  const cached = memoByKey.get(memoKey)
15
- if (cached?.fp === fingerprint && cached.order.length)
20
+ if (cached?.fp === fingerprint && cached.order.length) {
21
+ memoByKey.touch(memoKey, cached)
16
22
  return cached.order
23
+ }
17
24
  }
18
25
  const order = resolveOrder()
19
- memoByKey.set(memoKey, { fp: fingerprint, order })
26
+ memoByKey.touch(memoKey, { fp: fingerprint, order })
20
27
  return order
21
28
  }
22
29