@steve02081504/fount-p2p 0.0.36 → 0.0.38
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/AGENTS.md +3 -3
- package/discovery/adverts.mjs +16 -8
- package/discovery/nostr/census.mjs +281 -0
- package/discovery/nostr/census_math.mjs +54 -0
- package/discovery/nostr/constants.mjs +43 -0
- package/discovery/nostr/index.mjs +612 -0
- package/discovery/nostr/relays.mjs +1071 -0
- package/discovery/nostr/selection.mjs +224 -0
- package/discovery/nostr/session.mjs +584 -0
- package/docs/evfs.md +15 -6
- package/docs/nostr_relay_discovery.md +92 -0
- package/files/chunk/responder.mjs +2 -2
- package/files/evfs.mjs +2 -2
- package/files/fed/fetch_shared.mjs +3 -1
- package/files/fed/responder.mjs +3 -2
- package/files/fetch_fanout.mjs +26 -5
- package/files/manifest/fetch.mjs +73 -38
- package/files/manifest/pending.mjs +21 -7
- package/files/manifest/servicer_registry.mjs +51 -0
- package/governance/branch.mjs +1 -1
- package/governance/join_pow.mjs +2 -2
- package/index.mjs +11 -4
- package/link/handshake.mjs +135 -23
- package/link/providers/link_id_pipe.mjs +1 -1
- package/link/providers/{nostr.mjs → nostr/index.mjs} +19 -14
- package/mailbox/deliver_or_store.mjs +3 -4
- package/mailbox/store.mjs +1 -1
- package/node/feature_config.mjs +30 -0
- package/node/instance.mjs +21 -0
- package/node/reputation_store.mjs +1 -2
- package/node/storage.mjs +23 -0
- package/package.json +10 -4
- package/pages/app.mjs +172 -0
- package/pages/index.html +36 -0
- package/reputation/engine.mjs +1 -2
- package/transport/runtime_bootstrap.mjs +39 -3
- package/utils/fetch_wait.mjs +12 -7
- package/discovery/nostr.mjs +0 -1073
package/discovery/nostr.mjs
DELETED
|
@@ -1,1073 +0,0 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto'
|
|
2
|
-
|
|
3
|
-
import { schnorr } from '@noble/curves/secp256k1.js'
|
|
4
|
-
import WebSocket from 'ws'
|
|
5
|
-
|
|
6
|
-
import { base64ToBytes, hexToBytes, bytesToBase64, bytesToHex } from '../core/bytes_codec.mjs'
|
|
7
|
-
import { isHex64 } from '../core/hexIds.mjs'
|
|
8
|
-
import { sha256Hex } from '../crypto/crypto.mjs'
|
|
9
|
-
import { getNodeTransportSettings } from '../node/identity.mjs'
|
|
10
|
-
import { getSignalingRuntimeConfig } from '../node/instance.mjs'
|
|
11
|
-
import { nodeDebug, shortHash } from '../node/log.mjs'
|
|
12
|
-
|
|
13
|
-
import { noteAdvertPeerHints } from './advert_peer_hints.mjs'
|
|
14
|
-
import { ingestEncryptedAdvert } from './adverts.mjs'
|
|
15
|
-
import {
|
|
16
|
-
encryptSignalPacket,
|
|
17
|
-
groupRendezvousKey,
|
|
18
|
-
networkRendezvousKey,
|
|
19
|
-
nodeRendezvousKey,
|
|
20
|
-
} from './internal/signal_crypto.mjs'
|
|
21
|
-
import { noteDiscoveryPeerClue } from './peer_clue.mjs'
|
|
22
|
-
|
|
23
|
-
/** 默认 Nostr 中继 URL 列表。 */
|
|
24
|
-
export const DEFAULT_RELAY_URLS = [
|
|
25
|
-
'wss://relay.damus.io',
|
|
26
|
-
'wss://nos.lol',
|
|
27
|
-
'wss://relay.nostr.band',
|
|
28
|
-
'wss://relay.nostr.com',
|
|
29
|
-
'wss://nostr.bitcoiner.social',
|
|
30
|
-
'wss://nostr.mom',
|
|
31
|
-
'wss://relay.snort.social',
|
|
32
|
-
]
|
|
33
|
-
|
|
34
|
-
/** 单中继 WebSocket 首连超时(短超时 + 并行,避免串行 10s×N)。 */
|
|
35
|
-
export const NOSTR_CONNECT_TIMEOUT_MS = 2_000
|
|
36
|
-
/** 先 close,超时未 CLOSED 再 terminate(给对端优雅关闭的窗口)。 */
|
|
37
|
-
export const NOSTR_CLOSE_GRACE_MS = 1_000
|
|
38
|
-
|
|
39
|
-
/** 单 relay 等待 EVENT OK 回执超时。 */
|
|
40
|
-
export const NOSTR_PUBLISH_OK_TIMEOUT_MS = 3_000
|
|
41
|
-
/** 共享 relay 会话断线后重连间隔。 */
|
|
42
|
-
export const NOSTR_RECONNECT_DELAY_MS = 500
|
|
43
|
-
/** 无 sub/publish 工作时共享 relay 空闲回收延迟(给连续 send 复用窗口)。 */
|
|
44
|
-
export const NOSTR_IDLE_DROP_MS = 2_000
|
|
45
|
-
|
|
46
|
-
/** Nostr network advert 事件 kind(addressable,可存储)。 */
|
|
47
|
-
export const NOSTR_ADVERT_KIND = 30787
|
|
48
|
-
/** Nostr signal 事件 kind(ephemeral,实时转发)。 */
|
|
49
|
-
export const NOSTR_SIGNAL_KIND = 20787
|
|
50
|
-
|
|
51
|
-
/** 打广告用的话题 tag(hashtag,NIP-01),公开可被搜索聚合。 */
|
|
52
|
-
const NOSTR_TOPIC_TAG = ['t', 'fount']
|
|
53
|
-
|
|
54
|
-
const ADVERT_TTL_MS = 10 * 60_000
|
|
55
|
-
|
|
56
|
-
/** @type {Map<string, number>} 网络域 nodeHash → lastSeenAt */
|
|
57
|
-
const visibleByHash = new Map()
|
|
58
|
-
/** @type {Map<string, Map<string, number>>} roomSecret → (nodeHash → lastSeenAt) */
|
|
59
|
-
const visibleByGroup = new Map()
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* @param {Map<string, number>} pool 可见池
|
|
63
|
-
* @param {number} now 当前时间
|
|
64
|
-
* @param {number} ttlMs TTL
|
|
65
|
-
* @returns {string[]} 未过期 nodeHash
|
|
66
|
-
*/
|
|
67
|
-
function listPoolHashes(pool, now, ttlMs) {
|
|
68
|
-
/** @type {string[]} */
|
|
69
|
-
const out = []
|
|
70
|
-
for (const [hash, seenAt] of pool)
|
|
71
|
-
if (now - seenAt <= ttlMs) out.push(hash)
|
|
72
|
-
else pool.delete(hash)
|
|
73
|
-
return out
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* 写入网络域可见池(非群)。
|
|
78
|
-
* @param {string} nodeHash 节点 hash
|
|
79
|
-
* @param {number} [now=Date.now()] 当前时间
|
|
80
|
-
* @returns {void}
|
|
81
|
-
*/
|
|
82
|
-
export function noteNostrVisibleNode(nodeHash, now = Date.now()) {
|
|
83
|
-
const hash = isHex64(nodeHash)
|
|
84
|
-
if (!hash) return
|
|
85
|
-
visibleByHash.set(hash, now)
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* 写入群域可见池(与网络域隔离)。
|
|
90
|
-
* @param {string} roomSecret 房间密钥
|
|
91
|
-
* @param {string} nodeHash 节点 hash
|
|
92
|
-
* @param {number} [now=Date.now()] 当前时间
|
|
93
|
-
* @returns {void}
|
|
94
|
-
*/
|
|
95
|
-
export function noteNostrGroupVisibleNode(roomSecret, nodeHash, now = Date.now()) {
|
|
96
|
-
const hash = isHex64(nodeHash)
|
|
97
|
-
if (!roomSecret || !hash) return
|
|
98
|
-
let pool = visibleByGroup.get(roomSecret)
|
|
99
|
-
if (!pool) {
|
|
100
|
-
pool = new Map()
|
|
101
|
-
visibleByGroup.set(roomSecret, pool)
|
|
102
|
-
}
|
|
103
|
-
pool.set(hash, now)
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* @param {number} [now=Date.now()] 当前时间
|
|
108
|
-
* @param {number} [ttlMs=ADVERT_TTL_MS] TTL
|
|
109
|
-
* @returns {string[]} 网络域可见 nodeHash
|
|
110
|
-
*/
|
|
111
|
-
export function listNostrVisibleNodeHashes(now = Date.now(), ttlMs = ADVERT_TTL_MS) {
|
|
112
|
-
return listPoolHashes(visibleByHash, now, ttlMs)
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* @param {string} roomSecret 房间密钥
|
|
117
|
-
* @param {number} [now=Date.now()] 当前时间
|
|
118
|
-
* @param {number} [ttlMs=ADVERT_TTL_MS] TTL
|
|
119
|
-
* @returns {string[]} 该群可见 nodeHash
|
|
120
|
-
*/
|
|
121
|
-
export function listNostrGroupVisibleNodeHashes(roomSecret, now = Date.now(), ttlMs = ADVERT_TTL_MS) {
|
|
122
|
-
const pool = visibleByGroup.get(roomSecret)
|
|
123
|
-
if (!pool) return []
|
|
124
|
-
const out = listPoolHashes(pool, now, ttlMs)
|
|
125
|
-
if (!pool.size) visibleByGroup.delete(roomSecret)
|
|
126
|
-
return out
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* 解密并验签后写入 Nostr 可见池;伪造 body.nodeHash 无效。
|
|
131
|
-
* @param {string} rendezvousKey rendezvous 键
|
|
132
|
-
* @param {Uint8Array} bytes 加密 advert
|
|
133
|
-
* @param {{ roomSecret?: string, skipNodeHash?: string, meta?: object }} [options] 群池 / 本机回环过滤 / meta
|
|
134
|
-
* @returns {Promise<string | null>} 验签通过的 nodeHash
|
|
135
|
-
*/
|
|
136
|
-
export async function acceptNostrAdvert(rendezvousKey, bytes, options = {}) {
|
|
137
|
-
const ingested = await ingestEncryptedAdvert(rendezvousKey, bytes)
|
|
138
|
-
if (!ingested) return null
|
|
139
|
-
const hash = ingested.verifiedNodeHash
|
|
140
|
-
const skipHash = isHex64(options.skipNodeHash)
|
|
141
|
-
if (skipHash && hash === skipHash) return hash
|
|
142
|
-
const { roomSecret } = options
|
|
143
|
-
let firstSeen = true
|
|
144
|
-
if (roomSecret) {
|
|
145
|
-
firstSeen = !visibleByGroup.get(roomSecret)?.has(hash)
|
|
146
|
-
noteNostrGroupVisibleNode(roomSecret, hash)
|
|
147
|
-
}
|
|
148
|
-
else {
|
|
149
|
-
firstSeen = !visibleByHash.has(hash)
|
|
150
|
-
noteNostrVisibleNode(hash)
|
|
151
|
-
}
|
|
152
|
-
if (firstSeen) {
|
|
153
|
-
noteDiscoveryPeerClue(hash)
|
|
154
|
-
nodeDebug('p2p:nostr peer visible', { peer: shortHash(hash), group: !!roomSecret })
|
|
155
|
-
}
|
|
156
|
-
noteAdvertPeerHints(hash, ingested.body, options.meta || {})
|
|
157
|
-
return hash
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** @returns {void} 测试用 */
|
|
161
|
-
export function clearNostrVisibleNodes() {
|
|
162
|
-
visibleByHash.clear()
|
|
163
|
-
visibleByGroup.clear()
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* 关掉 WebSocket:已连上则先 close,grace 内未 CLOSED 再 terminate。
|
|
168
|
-
* @param {import('ws').WebSocket} ws 连接
|
|
169
|
-
* @returns {void}
|
|
170
|
-
*/
|
|
171
|
-
function dropWebSocket(ws) {
|
|
172
|
-
if (ws.readyState === WebSocket.CLOSED) return
|
|
173
|
-
if (ws.readyState === WebSocket.CONNECTING) {
|
|
174
|
-
try { ws.terminate() } catch { /* ignore */ }
|
|
175
|
-
return
|
|
176
|
-
}
|
|
177
|
-
const timer = setTimeout(() => {
|
|
178
|
-
if (ws.readyState !== WebSocket.CLOSED)
|
|
179
|
-
try { ws.terminate() } catch { /* ignore */ }
|
|
180
|
-
}, NOSTR_CLOSE_GRACE_MS)
|
|
181
|
-
timer.unref()
|
|
182
|
-
ws.once('close', () => clearTimeout(timer))
|
|
183
|
-
try {
|
|
184
|
-
ws.close()
|
|
185
|
-
}
|
|
186
|
-
catch {
|
|
187
|
-
clearTimeout(timer)
|
|
188
|
-
try { ws.terminate() } catch { /* ignore */ }
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* @param {string[] | undefined | null} urls 原始列表
|
|
194
|
-
* @returns {string[]} 清洗后的列表
|
|
195
|
-
*/
|
|
196
|
-
function dedupeRelayUrls(urls) {
|
|
197
|
-
const seen = new Set()
|
|
198
|
-
return (urls || [])
|
|
199
|
-
.map(url => String(url || ''))
|
|
200
|
-
.filter(url => url && !seen.has(url) && (seen.add(url), true))
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* @param {string[] | undefined | null} userRelayUrls 用户自定义中继列表
|
|
205
|
-
* @returns {string[]} 合并后的中继 URL 列表
|
|
206
|
-
*/
|
|
207
|
-
export function mergeSignalingRelayUrls(userRelayUrls) {
|
|
208
|
-
const merged = dedupeRelayUrls([...DEFAULT_RELAY_URLS, ...userRelayUrls || []])
|
|
209
|
-
return merged.length ? merged : [...DEFAULT_RELAY_URLS]
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* 当前可用 nostr 中继:nostr 通道配置 relay(替换默认),否则节点默认 + 公共中继。
|
|
214
|
-
* @returns {string[]} relay URL 列表
|
|
215
|
-
*/
|
|
216
|
-
export function resolveNostrRelayUrls() {
|
|
217
|
-
const relay = getSignalingRuntimeConfig().channels?.nostr?.relay
|
|
218
|
-
return relay?.length ? relay : mergeSignalingRelayUrls(getNodeTransportSettings().relayUrls)
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* @param {number} kind 事件 kind
|
|
223
|
-
* @param {string[][]} tags 事件标签
|
|
224
|
-
* @param {string} content 事件内容
|
|
225
|
-
* @param {Uint8Array} secretKey Schnorr 私钥
|
|
226
|
-
* @returns {Promise<object>} 已签名的 Nostr 事件对象
|
|
227
|
-
*/
|
|
228
|
-
async function signNostrEvent(kind, tags, content, secretKey) {
|
|
229
|
-
const pubkey = bytesToHex(schnorr.getPublicKey(secretKey))
|
|
230
|
-
const created_at = Math.floor(Date.now() / 1000)
|
|
231
|
-
const serialized = JSON.stringify([0, pubkey, created_at, kind, tags, content])
|
|
232
|
-
const id = sha256Hex(serialized)
|
|
233
|
-
const sig = bytesToHex(await schnorr.sign(hexToBytes(id), secretKey))
|
|
234
|
-
return { id, pubkey, created_at, kind, tags, content, sig }
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* @param {string} relayUrl 中继 URL
|
|
239
|
-
* @param {number} [timeoutMs] 超时毫秒
|
|
240
|
-
* @param {AbortSignal} [signal] 取消信号
|
|
241
|
-
* @returns {Promise<import('ws').WebSocket>} 已打开的 WebSocket
|
|
242
|
-
*/
|
|
243
|
-
function connectRelay(relayUrl, timeoutMs = NOSTR_CONNECT_TIMEOUT_MS, signal) {
|
|
244
|
-
return new Promise((resolve, reject) => {
|
|
245
|
-
if (signal?.aborted) {
|
|
246
|
-
reject(new Error('nostr: aborted'))
|
|
247
|
-
return
|
|
248
|
-
}
|
|
249
|
-
const ws = new WebSocket(relayUrl)
|
|
250
|
-
let settled = false
|
|
251
|
-
/**
|
|
252
|
-
* @param {Error} error 失败原因
|
|
253
|
-
* @returns {void}
|
|
254
|
-
*/
|
|
255
|
-
const fail = error => {
|
|
256
|
-
if (settled) return
|
|
257
|
-
settled = true
|
|
258
|
-
clearTimeout(timer)
|
|
259
|
-
signal?.removeEventListener('abort', onAbort)
|
|
260
|
-
dropWebSocket(ws)
|
|
261
|
-
reject(error)
|
|
262
|
-
}
|
|
263
|
-
/**
|
|
264
|
-
* 收到 abort 信号时失败
|
|
265
|
-
* @returns {void}
|
|
266
|
-
*/
|
|
267
|
-
const onAbort = () => fail(new Error('nostr: aborted'))
|
|
268
|
-
const timer = setTimeout(() => fail(new Error(`nostr: connect timeout for ${relayUrl}`)), timeoutMs)
|
|
269
|
-
timer.unref()
|
|
270
|
-
signal?.addEventListener('abort', onAbort, { once: true })
|
|
271
|
-
ws.once('open', () => {
|
|
272
|
-
if (settled) return
|
|
273
|
-
settled = true
|
|
274
|
-
clearTimeout(timer)
|
|
275
|
-
signal?.removeEventListener('abort', onAbort)
|
|
276
|
-
resolve(ws)
|
|
277
|
-
})
|
|
278
|
-
ws.once('error', () => {
|
|
279
|
-
fail(new Error(`nostr: websocket error for ${relayUrl}`))
|
|
280
|
-
})
|
|
281
|
-
})
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* @param {import('ws').WebSocket} ws 已连接 relay
|
|
286
|
-
* @param {string} relayUrl 中继 URL
|
|
287
|
-
* @param {object} event 待发布事件
|
|
288
|
-
* @param {AbortSignal} [signal] 取消信号
|
|
289
|
-
* @param {() => boolean} [isCurrent] 该尝试是否仍是 publishRequest 的当前尝试(断线重发后过期)
|
|
290
|
-
* @returns {Promise<boolean>} relay 是否接受 EVENT
|
|
291
|
-
*/
|
|
292
|
-
function publishEventOnRelay(ws, relayUrl, event, signal, isCurrent) {
|
|
293
|
-
return new Promise((resolve, reject) => {
|
|
294
|
-
if (signal?.aborted) {
|
|
295
|
-
reject(new Error('nostr: aborted'))
|
|
296
|
-
return
|
|
297
|
-
}
|
|
298
|
-
let settled = false
|
|
299
|
-
/**
|
|
300
|
-
* @param {boolean} ok relay 是否接受
|
|
301
|
-
* @param {Error | null} [error] 失败原因
|
|
302
|
-
* @returns {void}
|
|
303
|
-
*/
|
|
304
|
-
const finish = (ok, error = null) => {
|
|
305
|
-
if (settled) return
|
|
306
|
-
settled = true
|
|
307
|
-
clearTimeout(timer)
|
|
308
|
-
signal?.removeEventListener('abort', onAbort)
|
|
309
|
-
ws.off('message', onMessage)
|
|
310
|
-
// 过期的 in-flight 尝试:只清理自身监听器/定时器,不结算,避免影响重发后的当前尝试。
|
|
311
|
-
if (!isCurrent()) return
|
|
312
|
-
if (error) reject(error)
|
|
313
|
-
else resolve(ok)
|
|
314
|
-
}
|
|
315
|
-
/**
|
|
316
|
-
* 收到 abort 信号时以失败结束
|
|
317
|
-
* @returns {void}
|
|
318
|
-
*/
|
|
319
|
-
const onAbort = () => finish(false, new Error('nostr: aborted'))
|
|
320
|
-
/**
|
|
321
|
-
* @param {import('ws').RawData} data relay 消息
|
|
322
|
-
* @returns {void}
|
|
323
|
-
*/
|
|
324
|
-
const onMessage = data => {
|
|
325
|
-
let parsed
|
|
326
|
-
try { parsed = JSON.parse(String(data)) } catch { return }
|
|
327
|
-
if (parsed?.[0] !== 'OK' || parsed[1] !== event.id) return
|
|
328
|
-
const accepted = parsed[2] === true
|
|
329
|
-
if (!accepted)
|
|
330
|
-
nodeDebug('p2p:nostr publish rejected', {
|
|
331
|
-
url: relayUrl,
|
|
332
|
-
reason: String(parsed[3] || 'rejected'),
|
|
333
|
-
})
|
|
334
|
-
finish(accepted)
|
|
335
|
-
}
|
|
336
|
-
const timer = setTimeout(
|
|
337
|
-
() => finish(false, new Error(`nostr: publish ok timeout for ${relayUrl}`)),
|
|
338
|
-
NOSTR_PUBLISH_OK_TIMEOUT_MS,
|
|
339
|
-
)
|
|
340
|
-
timer.unref()
|
|
341
|
-
signal?.addEventListener('abort', onAbort, { once: true })
|
|
342
|
-
ws.on('message', onMessage)
|
|
343
|
-
try {
|
|
344
|
-
ws.send(JSON.stringify(['EVENT', event]))
|
|
345
|
-
}
|
|
346
|
-
catch (error) {
|
|
347
|
-
finish(false, error instanceof Error ? error : new Error(String(error)))
|
|
348
|
-
}
|
|
349
|
-
})
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
/**
|
|
353
|
-
* @param {string[]} relayUrls 中继 URL 列表
|
|
354
|
-
* @param {object} event 待发布事件
|
|
355
|
-
* @param {AbortSignal} [signal] 取消信号
|
|
356
|
-
* @returns {Promise<void>}
|
|
357
|
-
*/
|
|
358
|
-
async function publishEvent(relayUrls, event, signal) {
|
|
359
|
-
const urls = dedupeRelayUrls(relayUrls)
|
|
360
|
-
if (!urls.length) throw new Error('nostr: no relay')
|
|
361
|
-
let published = false
|
|
362
|
-
let lastError = null
|
|
363
|
-
await Promise.allSettled(urls.map(async relayUrl => {
|
|
364
|
-
try {
|
|
365
|
-
if (await publishViaSharedRelay(relayUrl, event, signal)) published = true
|
|
366
|
-
}
|
|
367
|
-
catch (error) {
|
|
368
|
-
lastError = error
|
|
369
|
-
}
|
|
370
|
-
}))
|
|
371
|
-
if (!published) throw lastError || new Error('nostr: no relay accepted publish')
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* 一次共享 relay publish 请求:入队(pending)与已派发(inflight)时共用同一结构。
|
|
376
|
-
* attempt 标识每次实际发送尝试,断线重发会递增;旧的 in-flight 尝试据此自检为过期后自我清理,
|
|
377
|
-
* 不再结算当前尝试。
|
|
378
|
-
* @typedef {{
|
|
379
|
-
* event: object,
|
|
380
|
-
* signal: AbortSignal | undefined,
|
|
381
|
-
* attempt: number,
|
|
382
|
-
* onAbort: (() => void) | null,
|
|
383
|
-
* removeAbort: (() => void) | null,
|
|
384
|
-
* resolve: (ok: boolean) => void,
|
|
385
|
-
* reject: (err: Error) => void,
|
|
386
|
-
* }} NostrPublishRequest
|
|
387
|
-
*/
|
|
388
|
-
|
|
389
|
-
/**
|
|
390
|
-
* 共享 relay 会话:多 SUB / publish 复用同一 URL 的 WebSocket,避免 signal/presence/advert 各建一池、
|
|
391
|
-
* 以及每次 send 重开一条连接(内存泄漏)。
|
|
392
|
-
* 仍有活跃 sub 或待发 publish 时断线会自动重连并重发 REQ / 重发 EVENT。
|
|
393
|
-
* @typedef {{
|
|
394
|
-
* ws: import('ws').WebSocket | null,
|
|
395
|
-
* connecting: boolean,
|
|
396
|
-
* reconnectTimer: ReturnType<typeof setTimeout> | null,
|
|
397
|
-
* idleTimer: ReturnType<typeof setTimeout> | null,
|
|
398
|
-
* subs: Map<string, { filter: object, onEvent: (event: object, relayUrl: string) => void }>,
|
|
399
|
-
* pendingPublishes: Array<NostrPublishRequest>,
|
|
400
|
-
* inflightPublishes: Array<NostrPublishRequest>,
|
|
401
|
-
* }} SharedRelaySession
|
|
402
|
-
*/
|
|
403
|
-
|
|
404
|
-
/** @type {Map<string, SharedRelaySession>} */
|
|
405
|
-
const sharedRelaySessions = new Map()
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* 会话是否仍有需要连接的工作(sub 或待发 publish)。
|
|
409
|
-
* @param {SharedRelaySession} session 会话
|
|
410
|
-
* @returns {boolean} 是否有活跃工作
|
|
411
|
-
*/
|
|
412
|
-
function hasPendingWork(session) {
|
|
413
|
-
return session.subs.size || session.pendingPublishes.length || session.inflightPublishes.length
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
/**
|
|
417
|
-
* 取消会话的空闲回收定时器。
|
|
418
|
-
* @param {SharedRelaySession} session 会话
|
|
419
|
-
* @returns {void}
|
|
420
|
-
*/
|
|
421
|
-
function clearIdleDrop(session) {
|
|
422
|
-
if (!session.idleTimer) return
|
|
423
|
-
clearTimeout(session.idleTimer)
|
|
424
|
-
session.idleTimer = null
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
/**
|
|
428
|
-
* 会话无 sub 时,待 publish 清空后延迟回收空闲 socket(给连续 send 复用窗口)。
|
|
429
|
-
* @param {string} relayUrl 中继 URL
|
|
430
|
-
* @param {SharedRelaySession} session 会话
|
|
431
|
-
* @returns {void}
|
|
432
|
-
*/
|
|
433
|
-
function scheduleIdleDrop(relayUrl, session) {
|
|
434
|
-
if (session.subs.size || session.idleTimer) return
|
|
435
|
-
session.idleTimer = setTimeout(() => {
|
|
436
|
-
session.idleTimer = null
|
|
437
|
-
if (!isLiveSharedSession(relayUrl, session) || hasPendingWork(session)) return
|
|
438
|
-
sharedRelaySessions.delete(relayUrl)
|
|
439
|
-
clearSharedRelayReconnect(session)
|
|
440
|
-
if (session.ws) dropWebSocket(session.ws)
|
|
441
|
-
session.ws = null
|
|
442
|
-
}, NOSTR_IDLE_DROP_MS)
|
|
443
|
-
session.idleTimer.unref?.()
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
/**
|
|
447
|
-
* @param {string} relayUrl 中继 URL
|
|
448
|
-
* @param {SharedRelaySession} session 会话
|
|
449
|
-
* @returns {boolean} session 是否仍是该 URL 的活动会话
|
|
450
|
-
*/
|
|
451
|
-
function isLiveSharedSession(relayUrl, session) {
|
|
452
|
-
return sharedRelaySessions.get(relayUrl) === session
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
/**
|
|
456
|
-
* @param {SharedRelaySession} session 会话
|
|
457
|
-
* @returns {void}
|
|
458
|
-
*/
|
|
459
|
-
function clearSharedRelayReconnect(session) {
|
|
460
|
-
if (!session.reconnectTimer) return
|
|
461
|
-
clearTimeout(session.reconnectTimer)
|
|
462
|
-
session.reconnectTimer = null
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
/**
|
|
466
|
-
* @param {string} relayUrl 中继 URL
|
|
467
|
-
* @param {SharedRelaySession} session 会话
|
|
468
|
-
* @param {import('ws').WebSocket} ws 已打开连接
|
|
469
|
-
* @returns {void}
|
|
470
|
-
*/
|
|
471
|
-
function attachSharedRelaySocket(relayUrl, session, ws) {
|
|
472
|
-
session.ws = ws
|
|
473
|
-
nodeDebug('p2p:nostr relay up', { url: relayUrl })
|
|
474
|
-
ws.on('message', data => {
|
|
475
|
-
let parsed
|
|
476
|
-
try { parsed = JSON.parse(String(data)) } catch { return }
|
|
477
|
-
if (parsed?.[0] !== 'EVENT') return
|
|
478
|
-
const subId = String(parsed[1] || '')
|
|
479
|
-
const nostrEvent = parsed[2]
|
|
480
|
-
const sub = session.subs.get(subId)
|
|
481
|
-
if (!sub || !nostrEvent) return
|
|
482
|
-
try { sub.onEvent(nostrEvent, relayUrl) } catch { /* ignore */ }
|
|
483
|
-
})
|
|
484
|
-
ws.once('close', () => {
|
|
485
|
-
if (!isLiveSharedSession(relayUrl, session)) return
|
|
486
|
-
session.ws = null
|
|
487
|
-
// 尚未确认的 inflight publish 重新入队,重连后由 flushPendingPublishes 重发。
|
|
488
|
-
if (session.inflightPublishes.length) {
|
|
489
|
-
session.pendingPublishes.push(...session.inflightPublishes)
|
|
490
|
-
for (const publishRequest of session.inflightPublishes)
|
|
491
|
-
attachQueuedAbort(session, publishRequest)
|
|
492
|
-
session.inflightPublishes = []
|
|
493
|
-
}
|
|
494
|
-
if (!hasPendingWork(session)) {
|
|
495
|
-
sharedRelaySessions.delete(relayUrl)
|
|
496
|
-
return
|
|
497
|
-
}
|
|
498
|
-
scheduleSharedRelayConnect(relayUrl, session, NOSTR_RECONNECT_DELAY_MS)
|
|
499
|
-
})
|
|
500
|
-
for (const [subId, sub] of session.subs)
|
|
501
|
-
try { ws.send(JSON.stringify(['REQ', subId, sub.filter])) } catch { /* ignore */ }
|
|
502
|
-
flushPendingPublishes(relayUrl, session, ws)
|
|
503
|
-
if (!hasPendingWork(session)) scheduleIdleDrop(relayUrl, session)
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
/**
|
|
507
|
-
* @param {string} relayUrl 中继 URL
|
|
508
|
-
* @param {SharedRelaySession} session 会话
|
|
509
|
-
* @param {number} [delayMs=0] 延迟毫秒(断线重连用)
|
|
510
|
-
* @returns {void}
|
|
511
|
-
*/
|
|
512
|
-
function scheduleSharedRelayConnect(relayUrl, session, delayMs = 0) {
|
|
513
|
-
if (!isLiveSharedSession(relayUrl, session) || session.connecting || session.ws) return
|
|
514
|
-
clearSharedRelayReconnect(session)
|
|
515
|
-
/** 启动/恢复共享会话连接 */
|
|
516
|
-
const start = () => {
|
|
517
|
-
session.reconnectTimer = null
|
|
518
|
-
if (!isLiveSharedSession(relayUrl, session) || session.connecting || session.ws) return
|
|
519
|
-
if (!hasPendingWork(session)) {
|
|
520
|
-
sharedRelaySessions.delete(relayUrl)
|
|
521
|
-
return
|
|
522
|
-
}
|
|
523
|
-
session.connecting = true
|
|
524
|
-
void connectRelay(relayUrl, NOSTR_CONNECT_TIMEOUT_MS).then(ws => {
|
|
525
|
-
session.connecting = false
|
|
526
|
-
if (!isLiveSharedSession(relayUrl, session) || !hasPendingWork(session)) {
|
|
527
|
-
sharedRelaySessions.delete(relayUrl)
|
|
528
|
-
dropWebSocket(ws)
|
|
529
|
-
return
|
|
530
|
-
}
|
|
531
|
-
attachSharedRelaySocket(relayUrl, session, ws)
|
|
532
|
-
}).catch(error => {
|
|
533
|
-
session.connecting = false
|
|
534
|
-
if (!isLiveSharedSession(relayUrl, session)) return
|
|
535
|
-
nodeDebug('p2p:nostr relay fail', {
|
|
536
|
-
url: relayUrl,
|
|
537
|
-
err: String(error?.message || error),
|
|
538
|
-
})
|
|
539
|
-
if (!hasPendingWork(session)) {
|
|
540
|
-
sharedRelaySessions.delete(relayUrl)
|
|
541
|
-
return
|
|
542
|
-
}
|
|
543
|
-
scheduleSharedRelayConnect(relayUrl, session, NOSTR_RECONNECT_DELAY_MS)
|
|
544
|
-
})
|
|
545
|
-
}
|
|
546
|
-
if (delayMs <= 0) {
|
|
547
|
-
// 让同一 tick 内的 registerSharedRelaySub 先写入 subs,再决定是否连接。
|
|
548
|
-
queueMicrotask(start)
|
|
549
|
-
return
|
|
550
|
-
}
|
|
551
|
-
nodeDebug('p2p:nostr relay reconnect', { url: relayUrl, delayMs })
|
|
552
|
-
session.reconnectTimer = setTimeout(start, delayMs)
|
|
553
|
-
session.reconnectTimer.unref?.()
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
/**
|
|
557
|
-
* @param {string} relayUrl 中继 URL
|
|
558
|
-
* @returns {SharedRelaySession} 共享会话
|
|
559
|
-
*/
|
|
560
|
-
function acquireSharedRelay(relayUrl) {
|
|
561
|
-
const existing = sharedRelaySessions.get(relayUrl)
|
|
562
|
-
if (existing) return existing
|
|
563
|
-
/** @type {SharedRelaySession} */
|
|
564
|
-
const session = {
|
|
565
|
-
ws: null,
|
|
566
|
-
connecting: false,
|
|
567
|
-
reconnectTimer: null,
|
|
568
|
-
idleTimer: null,
|
|
569
|
-
subs: new Map(),
|
|
570
|
-
pendingPublishes: [],
|
|
571
|
-
inflightPublishes: [],
|
|
572
|
-
}
|
|
573
|
-
sharedRelaySessions.set(relayUrl, session)
|
|
574
|
-
scheduleSharedRelayConnect(relayUrl, session)
|
|
575
|
-
return session
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
/**
|
|
579
|
-
* @param {string} relayUrl 中继 URL
|
|
580
|
-
* @param {string} subscriptionId 订阅 id
|
|
581
|
-
* @returns {void}
|
|
582
|
-
*/
|
|
583
|
-
function releaseSharedRelaySub(relayUrl, subscriptionId) {
|
|
584
|
-
const session = sharedRelaySessions.get(relayUrl)
|
|
585
|
-
if (!session) return
|
|
586
|
-
session.subs.delete(subscriptionId)
|
|
587
|
-
const { ws } = session
|
|
588
|
-
if (ws?.readyState === WebSocket.OPEN)
|
|
589
|
-
try { ws.send(JSON.stringify(['CLOSE', subscriptionId])) } catch { /* ignore */ }
|
|
590
|
-
if (hasPendingWork(session)) return
|
|
591
|
-
sharedRelaySessions.delete(relayUrl)
|
|
592
|
-
clearSharedRelayReconnect(session)
|
|
593
|
-
clearIdleDrop(session)
|
|
594
|
-
if (ws) dropWebSocket(ws)
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
/**
|
|
598
|
-
* 在已打开的共享连接上登记 REQ;连接中则等 attach 时统一重放。
|
|
599
|
-
* @param {SharedRelaySession} session 会话
|
|
600
|
-
* @param {string} subscriptionId 订阅 id
|
|
601
|
-
* @param {object} filter Nostr filter
|
|
602
|
-
* @param {(event: object, relayUrl: string) => void} onEvent 事件回调
|
|
603
|
-
* @returns {void}
|
|
604
|
-
*/
|
|
605
|
-
function registerSharedRelaySub(session, subscriptionId, filter, onEvent) {
|
|
606
|
-
session.subs.set(subscriptionId, { filter, onEvent })
|
|
607
|
-
clearIdleDrop(session)
|
|
608
|
-
const { ws } = session
|
|
609
|
-
if (ws?.readyState !== WebSocket.OPEN) return
|
|
610
|
-
try { ws.send(JSON.stringify(['REQ', subscriptionId, filter])) } catch { /* ignore */ }
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
/**
|
|
614
|
-
* 将已入队的 publish 逐个派发到已打开 socket 上;复用 publishEventOnRelay 等 OK 回执。
|
|
615
|
-
* 每个 publish 独立携带自己的 message/abort 监听,socket 共享安全。
|
|
616
|
-
* @param {string} relayUrl 中继 URL
|
|
617
|
-
* @param {SharedRelaySession} session 会话
|
|
618
|
-
* @param {import('ws').WebSocket} socket 已打开连接
|
|
619
|
-
* @returns {void}
|
|
620
|
-
*/
|
|
621
|
-
function flushPendingPublishes(relayUrl, session, socket) {
|
|
622
|
-
const pending = session.pendingPublishes
|
|
623
|
-
session.pendingPublishes = []
|
|
624
|
-
for (const publishRequest of pending) {
|
|
625
|
-
publishRequest.removeAbort?.()
|
|
626
|
-
publishRequest.onAbort = null
|
|
627
|
-
publishRequest.removeAbort = null
|
|
628
|
-
const attempt = ++publishRequest.attempt
|
|
629
|
-
session.inflightPublishes.push(publishRequest)
|
|
630
|
-
void publishEventOnRelay(socket, relayUrl, publishRequest.event, publishRequest.signal, () => publishRequest.attempt === attempt)
|
|
631
|
-
.then(
|
|
632
|
-
ok => settleInflightPublish(relayUrl, session, publishRequest, attempt, () => publishRequest.resolve(ok)),
|
|
633
|
-
error => settleInflightPublish(relayUrl, session, publishRequest, attempt, () => publishRequest.reject(error)),
|
|
634
|
-
)
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
/**
|
|
639
|
-
* 为队列中的 publish 挂上 abort 处理:先移除监听器(释放 signal 对回调/event/session 的引用),
|
|
640
|
-
* 再从队列删除并 reject。
|
|
641
|
-
* @param {SharedRelaySession} session 会话
|
|
642
|
-
* @param {SharedRelaySession['pendingPublishes'][number]} publishRequest 待发请求
|
|
643
|
-
* @returns {void}
|
|
644
|
-
*/
|
|
645
|
-
function attachQueuedAbort(session, publishRequest) {
|
|
646
|
-
const { signal, reject } = publishRequest
|
|
647
|
-
/**
|
|
648
|
-
* 处理 abort 事件
|
|
649
|
-
*/
|
|
650
|
-
publishRequest.onAbort = () => {
|
|
651
|
-
publishRequest.removeAbort?.()
|
|
652
|
-
const pendingIndex = session.pendingPublishes.indexOf(publishRequest)
|
|
653
|
-
if (pendingIndex < 0) return
|
|
654
|
-
session.pendingPublishes.splice(pendingIndex, 1)
|
|
655
|
-
reject(new Error('nostr: aborted'))
|
|
656
|
-
}
|
|
657
|
-
/**
|
|
658
|
-
* 移除 abort 监听器
|
|
659
|
-
*/
|
|
660
|
-
publishRequest.removeAbort = () => {
|
|
661
|
-
if (signal) signal.removeEventListener('abort', publishRequest.onAbort)
|
|
662
|
-
}
|
|
663
|
-
if (signal) signal.addEventListener('abort', publishRequest.onAbort, { once: true })
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
/**
|
|
667
|
-
* publish 在 socket 上得到 OK / 失败 / abort 后结算:从 inflight 移除并 settle。
|
|
668
|
-
* 仅结算仍与当前 attempt 匹配的请求:断线重发后 attempt 已递增,旧尝试的结算在此短路,
|
|
669
|
-
* 避免提前结算重发后的当前尝试。已被 requeue(断线重发)的请求不在 inflight,跳过结算。
|
|
670
|
-
* @param {string} relayUrl 中继 URL
|
|
671
|
-
* @param {SharedRelaySession} session 会话
|
|
672
|
-
* @param {SharedRelaySession['inflightPublishes'][number]} publishRequest 已发送请求
|
|
673
|
-
* @param {number} attempt 本次发送的 attempt 标识
|
|
674
|
-
* @param {() => void} settle 结算回调(resolve/reject)
|
|
675
|
-
* @returns {void}
|
|
676
|
-
*/
|
|
677
|
-
function settleInflightPublish(relayUrl, session, publishRequest, attempt, settle) {
|
|
678
|
-
if (publishRequest.attempt !== attempt) return
|
|
679
|
-
const inflightIndex = session.inflightPublishes.indexOf(publishRequest)
|
|
680
|
-
if (inflightIndex < 0) return
|
|
681
|
-
session.inflightPublishes.splice(inflightIndex, 1)
|
|
682
|
-
settle()
|
|
683
|
-
if (!hasPendingWork(session)) scheduleIdleDrop(relayUrl, session)
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
/**
|
|
687
|
-
* 通过共享 relay 会话发布 EVENT:复用已打开 socket,避免每次 send 重开一条连接(内存泄漏)。
|
|
688
|
-
* 无现成连接时入队并触发连接,连上后由 flushPendingPublishes 统一派发。
|
|
689
|
-
* @param {string} relayUrl 中继 URL
|
|
690
|
-
* @param {object} event 待发布事件
|
|
691
|
-
* @param {AbortSignal} [signal] 取消信号
|
|
692
|
-
* @returns {Promise<boolean>} relay 是否接受 EVENT
|
|
693
|
-
*/
|
|
694
|
-
function publishViaSharedRelay(relayUrl, event, signal) {
|
|
695
|
-
return new Promise((resolve, reject) => {
|
|
696
|
-
if (signal?.aborted) {
|
|
697
|
-
reject(new Error('nostr: aborted'))
|
|
698
|
-
return
|
|
699
|
-
}
|
|
700
|
-
const session = acquireSharedRelay(relayUrl)
|
|
701
|
-
clearIdleDrop(session)
|
|
702
|
-
/** @type {SharedRelaySession['pendingPublishes'][number]} */
|
|
703
|
-
const publishRequest = { event, signal, attempt: 0, resolve, reject, onAbort: null, removeAbort: null }
|
|
704
|
-
attachQueuedAbort(session, publishRequest)
|
|
705
|
-
session.pendingPublishes.push(publishRequest)
|
|
706
|
-
if (session.ws?.readyState === WebSocket.OPEN)
|
|
707
|
-
flushPendingPublishes(relayUrl, session, session.ws)
|
|
708
|
-
else
|
|
709
|
-
scheduleSharedRelayConnect(relayUrl, session)
|
|
710
|
-
})
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
/**
|
|
714
|
-
* 内部:按 rendezvous 键订阅 Nostr kind(topic 不导出)。多订阅共享每 URL 一条连接。
|
|
715
|
-
* @param {string[]} relayUrls 中继 URL 列表
|
|
716
|
-
* @param {{ kind: number, rendezvousKey: string, tagX: string, onPayload: (bytes: Uint8Array, meta: { relayUrl: string, event: object }) => void | Promise<void>, addressable?: boolean }} options 订阅选项
|
|
717
|
-
* @returns {() => void} 取消订阅
|
|
718
|
-
*/
|
|
719
|
-
function subscribeNostrKind(relayUrls, options) {
|
|
720
|
-
const { kind, rendezvousKey, tagX, onPayload, addressable = false } = options
|
|
721
|
-
const subscriptionId = randomBytes(8).toString('hex')
|
|
722
|
-
const filter = { kinds: [kind], '#t': [rendezvousKey], '#x': [tagX] }
|
|
723
|
-
if (addressable) filter['#d'] = [rendezvousKey]
|
|
724
|
-
/**
|
|
725
|
-
* @param {object} nostrEvent Nostr EVENT
|
|
726
|
-
* @param {string} relayUrl 来源中继
|
|
727
|
-
* @returns {void}
|
|
728
|
-
*/
|
|
729
|
-
const onEvent = (nostrEvent, relayUrl) => {
|
|
730
|
-
if (nostrEvent?.kind !== kind) return
|
|
731
|
-
try {
|
|
732
|
-
const result = onPayload(base64ToBytes(nostrEvent.content), { relayUrl, event: nostrEvent })
|
|
733
|
-
if (result?.then) void result.catch(() => { })
|
|
734
|
-
}
|
|
735
|
-
catch { /* ignore */ }
|
|
736
|
-
}
|
|
737
|
-
const urls = dedupeRelayUrls(relayUrls)
|
|
738
|
-
for (const relayUrl of urls)
|
|
739
|
-
registerSharedRelaySub(acquireSharedRelay(relayUrl), subscriptionId, filter, onEvent)
|
|
740
|
-
return () => {
|
|
741
|
-
for (const relayUrl of urls) releaseSharedRelaySub(relayUrl, subscriptionId)
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
/**
|
|
746
|
-
* 创建 Nostr discovery provider(list+connect;topic 仅内部)。
|
|
747
|
-
* @param {{ relayUrls?: string[] | null, getRelayUrls?: () => string[] | null | undefined, localNodeHash?: string }} [options] 中继配置与本机 hash
|
|
748
|
-
* @returns {import('./index.mjs').DiscoveryProvider} Nostr discovery provider
|
|
749
|
-
*/
|
|
750
|
-
export function createNostrDiscoveryProvider(options = {}) {
|
|
751
|
-
/**
|
|
752
|
-
* @returns {string[]} 去重后的中继 URL 列表
|
|
753
|
-
*/
|
|
754
|
-
const resolveRelayUrls = () => {
|
|
755
|
-
if (options.getRelayUrls) {
|
|
756
|
-
const urls = options.getRelayUrls()
|
|
757
|
-
return dedupeRelayUrls(urls == null ? DEFAULT_RELAY_URLS : urls)
|
|
758
|
-
}
|
|
759
|
-
if (options.relayUrls === undefined || options.relayUrls === null)
|
|
760
|
-
return dedupeRelayUrls(DEFAULT_RELAY_URLS)
|
|
761
|
-
return dedupeRelayUrls(options.relayUrls)
|
|
762
|
-
}
|
|
763
|
-
const secretKey = randomBytes(32)
|
|
764
|
-
/** @type {string | null} */
|
|
765
|
-
let selfNodeHash = isHex64(options.localNodeHash)
|
|
766
|
-
const NETWORK_SUB_KEY = 'network'
|
|
767
|
-
/**
|
|
768
|
-
* @typedef {{ stop: () => void, held: boolean, listeners: Set<(bytes: Uint8Array, meta: object) => void> }} AdvertSubEntry
|
|
769
|
-
*/
|
|
770
|
-
/** @type {Map<string, AdvertSubEntry>} */
|
|
771
|
-
const advertSubs = new Map()
|
|
772
|
-
/** @type {Map<string, () => void>} */
|
|
773
|
-
const nodeSignalSubs = new Map()
|
|
774
|
-
|
|
775
|
-
/**
|
|
776
|
-
* @param {string | undefined | null} nodeHash 本机 hash
|
|
777
|
-
* @returns {void}
|
|
778
|
-
*/
|
|
779
|
-
function noteSelfNodeHash(nodeHash) {
|
|
780
|
-
const hash = isHex64(nodeHash)
|
|
781
|
-
if (hash) selfNodeHash = hash
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
/**
|
|
785
|
-
* @param {string} key 订阅键
|
|
786
|
-
* @param {AdvertSubEntry} entry 条目
|
|
787
|
-
* @returns {void}
|
|
788
|
-
*/
|
|
789
|
-
function releaseAdvertEntryIfIdle(key, entry) {
|
|
790
|
-
if (entry.held || entry.listeners.size) return
|
|
791
|
-
try { entry.stop() } catch { /* ignore */ }
|
|
792
|
-
advertSubs.delete(key)
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
/**
|
|
796
|
-
* pool/connect 永久 hold;watch listener 归零且无 hold 时拆 REQ。
|
|
797
|
-
* @param {string} key 订阅键
|
|
798
|
-
* @param {{ rendezvousKey: string, roomSecret?: string }} bind advert 绑定
|
|
799
|
-
* @param {(bytes: Uint8Array, meta: object) => void} [listener] 额外监听
|
|
800
|
-
* @returns {() => void} 取消 listener;无 listener 时 no-op(hold 至 dispose)
|
|
801
|
-
*/
|
|
802
|
-
function ensureAdvertSubscription(key, bind, listener) {
|
|
803
|
-
if (!key) return () => { }
|
|
804
|
-
let entry = advertSubs.get(key)
|
|
805
|
-
if (!entry) {
|
|
806
|
-
/** @type {AdvertSubEntry} */
|
|
807
|
-
const created = {
|
|
808
|
-
/**
|
|
809
|
-
* @returns {void}
|
|
810
|
-
*/
|
|
811
|
-
stop: () => { }, held: false, listeners: new Set()
|
|
812
|
-
}
|
|
813
|
-
created.stop = subscribeNostrKind(resolveRelayUrls(), {
|
|
814
|
-
kind: NOSTR_ADVERT_KIND,
|
|
815
|
-
rendezvousKey: bind.rendezvousKey,
|
|
816
|
-
tagX: 'advert',
|
|
817
|
-
addressable: true,
|
|
818
|
-
/**
|
|
819
|
-
* @param {Uint8Array} bytes 加密 advert 载荷
|
|
820
|
-
* @param {object} meta relay 元数据
|
|
821
|
-
* @returns {Promise<void>}
|
|
822
|
-
*/
|
|
823
|
-
async onPayload(bytes, meta) {
|
|
824
|
-
await acceptNostrAdvert(bind.rendezvousKey, bytes, {
|
|
825
|
-
roomSecret: bind.roomSecret,
|
|
826
|
-
skipNodeHash: selfNodeHash || undefined,
|
|
827
|
-
meta,
|
|
828
|
-
})
|
|
829
|
-
for (const fn of created.listeners)
|
|
830
|
-
try { fn(bytes, meta) } catch { /* ignore */ }
|
|
831
|
-
},
|
|
832
|
-
})
|
|
833
|
-
entry = created
|
|
834
|
-
advertSubs.set(key, entry)
|
|
835
|
-
}
|
|
836
|
-
if (!listener) {
|
|
837
|
-
entry.held = true
|
|
838
|
-
return () => { }
|
|
839
|
-
}
|
|
840
|
-
entry.listeners.add(listener)
|
|
841
|
-
return () => {
|
|
842
|
-
entry.listeners.delete(listener)
|
|
843
|
-
releaseAdvertEntryIfIdle(key, entry)
|
|
844
|
-
}
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
/**
|
|
848
|
-
* @returns {() => void} no-op(network hold 至 dispose)
|
|
849
|
-
*/
|
|
850
|
-
function ensureNetworkAdvertSubscription() {
|
|
851
|
-
return ensureAdvertSubscription(NETWORK_SUB_KEY, {
|
|
852
|
-
rendezvousKey: networkRendezvousKey(),
|
|
853
|
-
})
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
/**
|
|
857
|
-
* @param {string} roomSecret 房间密钥
|
|
858
|
-
* @param {(bytes: Uint8Array, meta: object) => void} [listener] 额外 advert 监听
|
|
859
|
-
* @returns {() => void} 取消 listener
|
|
860
|
-
*/
|
|
861
|
-
function ensureGroupSubscription(roomSecret, listener) {
|
|
862
|
-
if (!roomSecret) return () => { }
|
|
863
|
-
return ensureAdvertSubscription('group:' + roomSecret, {
|
|
864
|
-
rendezvousKey: groupRendezvousKey(roomSecret),
|
|
865
|
-
roomSecret,
|
|
866
|
-
}, listener)
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
/**
|
|
870
|
-
* @param {string} nodeHash 目标
|
|
871
|
-
* @param {(bytes: Uint8Array, meta: object) => void} [listener] 额外 advert 监听
|
|
872
|
-
* @returns {() => void} 取消 listener
|
|
873
|
-
*/
|
|
874
|
-
function ensureNodeAdvertSubscription(nodeHash, listener) {
|
|
875
|
-
const hash = isHex64(nodeHash)
|
|
876
|
-
if (!hash) return () => { }
|
|
877
|
-
return ensureAdvertSubscription('node:' + hash, {
|
|
878
|
-
rendezvousKey: nodeRendezvousKey(hash),
|
|
879
|
-
}, listener)
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
return {
|
|
883
|
-
id: 'nostr',
|
|
884
|
-
priority: 100,
|
|
885
|
-
caps: { canDiscover: true, canSignal: true, canRelay: false },
|
|
886
|
-
/**
|
|
887
|
-
* @param {{ limit?: number, roomSecret?: string }} [options] 扫描选项
|
|
888
|
-
* @returns {Promise<string[]>} 可见 nodeHash;带 roomSecret 时仅返回该群池
|
|
889
|
-
*/
|
|
890
|
-
async listVisibleNodeHashes(options = {}) {
|
|
891
|
-
const limit = Math.max(1, Number(options.limit) || 64)
|
|
892
|
-
if (options.roomSecret) {
|
|
893
|
-
ensureGroupSubscription(options.roomSecret)
|
|
894
|
-
return listNostrGroupVisibleNodeHashes(options.roomSecret)
|
|
895
|
-
.filter(hash => hash !== selfNodeHash)
|
|
896
|
-
.slice(0, limit)
|
|
897
|
-
}
|
|
898
|
-
ensureNetworkAdvertSubscription()
|
|
899
|
-
return listNostrVisibleNodeHashes()
|
|
900
|
-
.filter(hash => hash !== selfNodeHash)
|
|
901
|
-
.slice(0, limit)
|
|
902
|
-
},
|
|
903
|
-
/**
|
|
904
|
-
* 挂上对该节点的内部 advert 订阅(建链由 registry dialer / ensureLinkToNode 完成)。
|
|
905
|
-
* @param {string} nodeHash 目标
|
|
906
|
-
* @returns {Promise<boolean>} 是否已准备
|
|
907
|
-
*/
|
|
908
|
-
async connectToNode(nodeHash) {
|
|
909
|
-
const hash = isHex64(nodeHash)
|
|
910
|
-
if (!hash) return false
|
|
911
|
-
ensureNodeAdvertSubscription(hash)
|
|
912
|
-
return true
|
|
913
|
-
},
|
|
914
|
-
/**
|
|
915
|
-
* @param {() => Promise<object | null>} getBeacon 本机 advert body 工厂
|
|
916
|
-
* @returns {Promise<() => void>} 停止函数
|
|
917
|
-
*/
|
|
918
|
-
async startPresence(getBeacon) {
|
|
919
|
-
const rendezvousKey = networkRendezvousKey()
|
|
920
|
-
const abortController = new AbortController()
|
|
921
|
-
ensureNetworkAdvertSubscription()
|
|
922
|
-
/**
|
|
923
|
-
* @returns {Promise<void>}
|
|
924
|
-
*/
|
|
925
|
-
const publish = async () => {
|
|
926
|
-
if (abortController.signal.aborted) return
|
|
927
|
-
const beacon = await getBeacon?.()
|
|
928
|
-
if (!beacon?.nodeHash) return
|
|
929
|
-
noteSelfNodeHash(beacon.nodeHash)
|
|
930
|
-
const advertBody = beacon.advertBody || beacon.body || beacon
|
|
931
|
-
const bytes = encryptSignalPacket(rendezvousKey, { type: 'advert', body: advertBody })
|
|
932
|
-
const event = await signNostrEvent(
|
|
933
|
-
NOSTR_ADVERT_KIND,
|
|
934
|
-
[NOSTR_TOPIC_TAG, ['t', rendezvousKey], ['x', 'advert'], ['d', rendezvousKey]],
|
|
935
|
-
bytesToBase64(bytes),
|
|
936
|
-
secretKey,
|
|
937
|
-
)
|
|
938
|
-
await publishEvent(resolveRelayUrls(), event, abortController.signal)
|
|
939
|
-
nodeDebug('p2p:nostr presence published', { self: shortHash(beacon.nodeHash) })
|
|
940
|
-
}
|
|
941
|
-
void publish().catch(error => {
|
|
942
|
-
nodeDebug('p2p:nostr presence publish fail', { err: String(error?.message || error) })
|
|
943
|
-
})
|
|
944
|
-
const timer = setInterval(() => {
|
|
945
|
-
void publish().catch(error => {
|
|
946
|
-
nodeDebug('p2p:nostr presence publish fail', { err: String(error?.message || error) })
|
|
947
|
-
})
|
|
948
|
-
}, 5 * 60_000)
|
|
949
|
-
timer.unref?.()
|
|
950
|
-
return () => {
|
|
951
|
-
abortController.abort()
|
|
952
|
-
clearInterval(timer)
|
|
953
|
-
}
|
|
954
|
-
},
|
|
955
|
-
/**
|
|
956
|
-
* @param {string} toNodeHash 目标 nodeHash
|
|
957
|
-
* @param {Uint8Array} bytes 加密信令
|
|
958
|
-
* @returns {Promise<void>}
|
|
959
|
-
*/
|
|
960
|
-
async sendNodeSignal(toNodeHash, bytes) {
|
|
961
|
-
const hash = isHex64(toNodeHash)
|
|
962
|
-
if (!hash) throw new Error('nostr: invalid nodeHash')
|
|
963
|
-
const rendezvousKey = nodeRendezvousKey(hash)
|
|
964
|
-
const event = await signNostrEvent(
|
|
965
|
-
NOSTR_SIGNAL_KIND,
|
|
966
|
-
[NOSTR_TOPIC_TAG, ['t', rendezvousKey], ['x', 'signal'], ['p', hash]],
|
|
967
|
-
bytesToBase64(bytes),
|
|
968
|
-
secretKey,
|
|
969
|
-
)
|
|
970
|
-
await publishEvent(resolveRelayUrls(), event)
|
|
971
|
-
},
|
|
972
|
-
/**
|
|
973
|
-
* @param {string} localNodeHash 本机 nodeHash
|
|
974
|
-
* @param {(bytes: Uint8Array) => void} onSignal 信令回调
|
|
975
|
-
* @returns {Promise<() => void>} 取消函数
|
|
976
|
-
*/
|
|
977
|
-
async listenNodeSignals(localNodeHash, onSignal) {
|
|
978
|
-
const hash = isHex64(localNodeHash)
|
|
979
|
-
if (!hash) throw new Error('nostr: invalid nodeHash')
|
|
980
|
-
noteSelfNodeHash(hash)
|
|
981
|
-
const rendezvousKey = nodeRendezvousKey(hash)
|
|
982
|
-
const existing = nodeSignalSubs.get(hash)
|
|
983
|
-
if (existing) existing()
|
|
984
|
-
nodeDebug('p2p:nostr signal listen', { self: shortHash(hash), relays: resolveRelayUrls().length })
|
|
985
|
-
const stop = subscribeNostrKind(resolveRelayUrls(), {
|
|
986
|
-
kind: NOSTR_SIGNAL_KIND,
|
|
987
|
-
rendezvousKey,
|
|
988
|
-
tagX: 'signal',
|
|
989
|
-
onPayload: onSignal,
|
|
990
|
-
})
|
|
991
|
-
nodeSignalSubs.set(hash, stop)
|
|
992
|
-
return () => {
|
|
993
|
-
stop()
|
|
994
|
-
nodeSignalSubs.delete(hash)
|
|
995
|
-
}
|
|
996
|
-
},
|
|
997
|
-
/**
|
|
998
|
-
* @param {string} nodeHash 目标 nodeHash
|
|
999
|
-
* @param {(bytes: Uint8Array, meta: object) => void} onAdvert advert 回调
|
|
1000
|
-
* @returns {Promise<() => void>} 取消函数
|
|
1001
|
-
*/
|
|
1002
|
-
async watchNodeAdvert(nodeHash, onAdvert) {
|
|
1003
|
-
const hash = isHex64(nodeHash)
|
|
1004
|
-
if (!hash) throw new Error('nostr: invalid nodeHash')
|
|
1005
|
-
return ensureNodeAdvertSubscription(hash, onAdvert)
|
|
1006
|
-
},
|
|
1007
|
-
/**
|
|
1008
|
-
* @param {string} roomSecret 房间密钥
|
|
1009
|
-
* @param {() => Promise<object | null>} getBeacon advert 工厂
|
|
1010
|
-
* @returns {Promise<() => void>} 停止群 presence 广播
|
|
1011
|
-
*/
|
|
1012
|
-
async startGroupPresence(roomSecret, getBeacon) {
|
|
1013
|
-
const rendezvousKey = groupRendezvousKey(roomSecret)
|
|
1014
|
-
const abortController = new AbortController()
|
|
1015
|
-
ensureGroupSubscription(roomSecret)
|
|
1016
|
-
/**
|
|
1017
|
-
* @returns {Promise<void>}
|
|
1018
|
-
*/
|
|
1019
|
-
const publish = async () => {
|
|
1020
|
-
if (abortController.signal.aborted) return
|
|
1021
|
-
const beacon = await getBeacon?.()
|
|
1022
|
-
if (!beacon?.nodeHash) return
|
|
1023
|
-
noteSelfNodeHash(beacon.nodeHash)
|
|
1024
|
-
const advertBody = beacon.advertBody || beacon.body || beacon
|
|
1025
|
-
const bytes = encryptSignalPacket(rendezvousKey, { type: 'advert', body: advertBody })
|
|
1026
|
-
const event = await signNostrEvent(
|
|
1027
|
-
NOSTR_ADVERT_KIND,
|
|
1028
|
-
[NOSTR_TOPIC_TAG, ['t', rendezvousKey], ['x', 'advert'], ['d', rendezvousKey]],
|
|
1029
|
-
bytesToBase64(bytes),
|
|
1030
|
-
secretKey,
|
|
1031
|
-
)
|
|
1032
|
-
await publishEvent(resolveRelayUrls(), event, abortController.signal)
|
|
1033
|
-
}
|
|
1034
|
-
void publish().catch(() => { })
|
|
1035
|
-
const timer = setInterval(() => { void publish().catch(() => { }) }, 5 * 60_000)
|
|
1036
|
-
timer.unref?.()
|
|
1037
|
-
return () => {
|
|
1038
|
-
abortController.abort()
|
|
1039
|
-
clearInterval(timer)
|
|
1040
|
-
}
|
|
1041
|
-
},
|
|
1042
|
-
/**
|
|
1043
|
-
* @param {string} roomSecret 房间密钥
|
|
1044
|
-
* @param {(bytes: Uint8Array, meta: object) => void} onAdvert 回调
|
|
1045
|
-
* @returns {Promise<() => void>} 取消群 advert 监听
|
|
1046
|
-
*/
|
|
1047
|
-
async watchGroupAdverts(roomSecret, onAdvert) {
|
|
1048
|
-
return ensureGroupSubscription(roomSecret, onAdvert)
|
|
1049
|
-
},
|
|
1050
|
-
/**
|
|
1051
|
-
* 供 advert 解析路径写入可见 hash。
|
|
1052
|
-
* @param {string} nodeHash 节点 hash
|
|
1053
|
-
* @param {{ roomSecret?: string }} [options] 带 roomSecret 时写入群池
|
|
1054
|
-
* @returns {void}
|
|
1055
|
-
*/
|
|
1056
|
-
noteVisibleNode(nodeHash, options = {}) {
|
|
1057
|
-
if (options.roomSecret) noteNostrGroupVisibleNode(options.roomSecret, nodeHash)
|
|
1058
|
-
else noteNostrVisibleNode(nodeHash)
|
|
1059
|
-
},
|
|
1060
|
-
/**
|
|
1061
|
-
* 停止全部内部订阅(reload / unregister 时调用)。
|
|
1062
|
-
* @returns {void}
|
|
1063
|
-
*/
|
|
1064
|
-
dispose() {
|
|
1065
|
-
for (const entry of advertSubs.values())
|
|
1066
|
-
try { entry.stop() } catch { /* ignore */ }
|
|
1067
|
-
advertSubs.clear()
|
|
1068
|
-
for (const stop of nodeSignalSubs.values())
|
|
1069
|
-
try { stop() } catch { /* ignore */ }
|
|
1070
|
-
nodeSignalSubs.clear()
|
|
1071
|
-
},
|
|
1072
|
-
}
|
|
1073
|
-
}
|