@steve02081504/fount-p2p 0.0.13 → 0.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/deno.json +4 -1
- package/discovery/advert_peer_hints.mjs +8 -2
- package/discovery/adverts.mjs +5 -0
- package/discovery/bt/index.mjs +4 -1
- package/discovery/lan.mjs +73 -14
- package/discovery/lan_interfaces.mjs +78 -0
- package/discovery/lan_peer_hints.mjs +22 -5
- package/discovery/nostr.mjs +384 -136
- package/discovery/peer_clue.mjs +23 -0
- package/link/handshake.mjs +15 -7
- package/link/providers/lan_tcp.mjs +62 -33
- package/link/providers/webrtc.mjs +15 -1
- package/link/rtc.mjs +14 -5
- package/package.json +1 -1
- package/transport/link_registry.mjs +37 -1
- package/transport/runtime_bootstrap.mjs +4 -1
package/discovery/nostr.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
|
|
|
8
8
|
import { sha256Hex } from '../crypto/crypto.mjs'
|
|
9
9
|
import { nodeDebug, shortHash } from '../node/log.mjs'
|
|
10
10
|
|
|
11
|
+
import { noteAdvertPeerHints } from './advert_peer_hints.mjs'
|
|
11
12
|
import { ingestEncryptedAdvert } from './adverts.mjs'
|
|
12
13
|
import {
|
|
13
14
|
encryptSignalPacket,
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
networkRendezvousKey,
|
|
16
17
|
nodeRendezvousKey,
|
|
17
18
|
} from './internal/signal_crypto.mjs'
|
|
19
|
+
import { noteDiscoveryPeerClue } from './peer_clue.mjs'
|
|
18
20
|
|
|
19
21
|
/** 默认 Nostr 中继 URL 列表。 */
|
|
20
22
|
export const DEFAULT_RELAY_URLS = [
|
|
@@ -28,10 +30,15 @@ export const NOSTR_CONNECT_TIMEOUT_MS = 2_000
|
|
|
28
30
|
/** 先 close,超时未 CLOSED 再 terminate(给对端优雅关闭的窗口)。 */
|
|
29
31
|
export const NOSTR_CLOSE_GRACE_MS = 1_000
|
|
30
32
|
|
|
31
|
-
/**
|
|
32
|
-
export const
|
|
33
|
-
/**
|
|
34
|
-
export const
|
|
33
|
+
/** 单 relay 等待 EVENT OK 回执超时。 */
|
|
34
|
+
export const NOSTR_PUBLISH_OK_TIMEOUT_MS = 3_000
|
|
35
|
+
/** 共享 relay 会话断线后重连间隔。 */
|
|
36
|
+
export const NOSTR_RECONNECT_DELAY_MS = 500
|
|
37
|
+
|
|
38
|
+
/** Nostr network advert 事件 kind(addressable,可存储)。 */
|
|
39
|
+
export const NOSTR_ADVERT_KIND = 30787
|
|
40
|
+
/** Nostr signal 事件 kind(ephemeral,实时转发)。 */
|
|
41
|
+
export const NOSTR_SIGNAL_KIND = 20787
|
|
35
42
|
|
|
36
43
|
const ADVERT_TTL_MS = 10 * 60_000
|
|
37
44
|
|
|
@@ -52,7 +59,6 @@ function listPoolHashes(pool, now, ttlMs) {
|
|
|
52
59
|
for (const [hash, seenAt] of pool)
|
|
53
60
|
if (now - seenAt <= ttlMs) out.push(hash)
|
|
54
61
|
else pool.delete(hash)
|
|
55
|
-
|
|
56
62
|
return out
|
|
57
63
|
}
|
|
58
64
|
|
|
@@ -115,13 +121,15 @@ export function listNostrGroupVisibleNodeHashes(roomSecret, now = Date.now(), tt
|
|
|
115
121
|
* 解密并验签后写入 Nostr 可见池;伪造 body.nodeHash 无效。
|
|
116
122
|
* @param {string} rendezvousKey rendezvous 键
|
|
117
123
|
* @param {Uint8Array} bytes 加密 advert
|
|
118
|
-
* @param {{ roomSecret?: string }} [options]
|
|
124
|
+
* @param {{ roomSecret?: string, skipNodeHash?: string, meta?: object }} [options] 群池 / 本机回环过滤 / meta
|
|
119
125
|
* @returns {Promise<string | null>} 验签通过的 nodeHash
|
|
120
126
|
*/
|
|
121
127
|
export async function acceptNostrAdvert(rendezvousKey, bytes, options = {}) {
|
|
122
128
|
const ingested = await ingestEncryptedAdvert(rendezvousKey, bytes)
|
|
123
129
|
if (!ingested) return null
|
|
124
130
|
const hash = ingested.verifiedNodeHash
|
|
131
|
+
const skipHash = options.skipNodeHash ? normalizeHex64(options.skipNodeHash) : null
|
|
132
|
+
if (skipHash && hash === skipHash) return hash
|
|
125
133
|
const roomSecret = options.roomSecret
|
|
126
134
|
let firstSeen = true
|
|
127
135
|
if (roomSecret) {
|
|
@@ -133,8 +141,11 @@ export async function acceptNostrAdvert(rendezvousKey, bytes, options = {}) {
|
|
|
133
141
|
firstSeen = !visibleByHash.has(hash)
|
|
134
142
|
noteNostrVisibleNode(hash)
|
|
135
143
|
}
|
|
136
|
-
if (firstSeen)
|
|
144
|
+
if (firstSeen) {
|
|
145
|
+
noteDiscoveryPeerClue(hash)
|
|
137
146
|
nodeDebug('p2p:nostr peer visible', { peer: shortHash(hash), group: !!roomSecret })
|
|
147
|
+
}
|
|
148
|
+
noteAdvertPeerHints(hash, ingested.body, options.meta || {})
|
|
138
149
|
return hash
|
|
139
150
|
}
|
|
140
151
|
|
|
@@ -252,6 +263,70 @@ function connectRelay(relayUrl, timeoutMs = NOSTR_CONNECT_TIMEOUT_MS, signal) {
|
|
|
252
263
|
})
|
|
253
264
|
}
|
|
254
265
|
|
|
266
|
+
/**
|
|
267
|
+
* @param {import('ws').WebSocket} ws 已连接 relay
|
|
268
|
+
* @param {string} relayUrl 中继 URL
|
|
269
|
+
* @param {object} event 待发布事件
|
|
270
|
+
* @param {AbortSignal} [signal] 取消信号
|
|
271
|
+
* @returns {Promise<boolean>} relay 是否接受 EVENT
|
|
272
|
+
*/
|
|
273
|
+
function publishEventOnRelay(ws, relayUrl, event, signal) {
|
|
274
|
+
return new Promise((resolve, reject) => {
|
|
275
|
+
if (signal?.aborted) {
|
|
276
|
+
reject(new Error('nostr: aborted'))
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
let settled = false
|
|
280
|
+
/**
|
|
281
|
+
* @param {boolean} ok relay 是否接受
|
|
282
|
+
* @param {Error | null} [error] 失败原因
|
|
283
|
+
* @returns {void}
|
|
284
|
+
*/
|
|
285
|
+
const finish = (ok, error = null) => {
|
|
286
|
+
if (settled) return
|
|
287
|
+
settled = true
|
|
288
|
+
clearTimeout(timer)
|
|
289
|
+
signal?.removeEventListener('abort', onAbort)
|
|
290
|
+
ws.off('message', onMessage)
|
|
291
|
+
if (error) reject(error)
|
|
292
|
+
else resolve(ok)
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* @returns {void}
|
|
296
|
+
*/
|
|
297
|
+
const onAbort = () => finish(false, new Error('nostr: aborted'))
|
|
298
|
+
/**
|
|
299
|
+
* @param {import('ws').RawData} data relay 消息
|
|
300
|
+
* @returns {void}
|
|
301
|
+
*/
|
|
302
|
+
const onMessage = data => {
|
|
303
|
+
let parsed
|
|
304
|
+
try { parsed = JSON.parse(String(data)) } catch { return }
|
|
305
|
+
if (parsed?.[0] !== 'OK' || parsed[1] !== event.id) return
|
|
306
|
+
const accepted = parsed[2] === true
|
|
307
|
+
if (!accepted)
|
|
308
|
+
nodeDebug('p2p:nostr publish rejected', {
|
|
309
|
+
url: relayUrl,
|
|
310
|
+
reason: String(parsed[3] || 'rejected'),
|
|
311
|
+
})
|
|
312
|
+
finish(accepted)
|
|
313
|
+
}
|
|
314
|
+
const timer = setTimeout(
|
|
315
|
+
() => finish(false, new Error(`nostr: publish ok timeout for ${relayUrl}`)),
|
|
316
|
+
NOSTR_PUBLISH_OK_TIMEOUT_MS,
|
|
317
|
+
)
|
|
318
|
+
timer.unref()
|
|
319
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
320
|
+
ws.on('message', onMessage)
|
|
321
|
+
try {
|
|
322
|
+
ws.send(JSON.stringify(['EVENT', event]))
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
finish(false, error instanceof Error ? error : new Error(String(error)))
|
|
326
|
+
}
|
|
327
|
+
})
|
|
328
|
+
}
|
|
329
|
+
|
|
255
330
|
/**
|
|
256
331
|
* @param {string[]} relayUrls 中继 URL 列表
|
|
257
332
|
* @param {object} event 待发布事件
|
|
@@ -267,8 +342,8 @@ async function publishEvent(relayUrls, event, signal) {
|
|
|
267
342
|
const ws = await connectRelay(relayUrl, NOSTR_CONNECT_TIMEOUT_MS, signal)
|
|
268
343
|
try {
|
|
269
344
|
if (signal?.aborted) throw new Error('nostr: aborted')
|
|
270
|
-
ws
|
|
271
|
-
published = true
|
|
345
|
+
const ok = await publishEventOnRelay(ws, relayUrl, event, signal)
|
|
346
|
+
if (ok) published = true
|
|
272
347
|
}
|
|
273
348
|
catch (error) {
|
|
274
349
|
lastError = error
|
|
@@ -282,67 +357,210 @@ async function publishEvent(relayUrls, event, signal) {
|
|
|
282
357
|
}
|
|
283
358
|
|
|
284
359
|
/**
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
* @
|
|
288
|
-
*
|
|
360
|
+
* 共享 relay 会话:多 SUB 复用同一 URL 的 WebSocket,避免 signal/presence/advert 各建一池。
|
|
361
|
+
* 仍有活跃 sub 时断线会自动重连并重发 REQ。
|
|
362
|
+
* @typedef {{
|
|
363
|
+
* ws: import('ws').WebSocket | null,
|
|
364
|
+
* connecting: boolean,
|
|
365
|
+
* reconnectTimer: ReturnType<typeof setTimeout> | null,
|
|
366
|
+
* subs: Map<string, { filter: object, onEvent: (event: object, relayUrl: string) => void }>,
|
|
367
|
+
* }} SharedRelaySession
|
|
368
|
+
*/
|
|
369
|
+
|
|
370
|
+
/** @type {Map<string, SharedRelaySession>} */
|
|
371
|
+
const sharedRelaySessions = new Map()
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* @param {string} relayUrl 中继 URL
|
|
375
|
+
* @param {SharedRelaySession} session 会话
|
|
376
|
+
* @returns {boolean} session 是否仍是该 URL 的活动会话
|
|
377
|
+
*/
|
|
378
|
+
function isLiveSharedSession(relayUrl, session) {
|
|
379
|
+
return sharedRelaySessions.get(relayUrl) === session
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* @param {SharedRelaySession} session 会话
|
|
384
|
+
* @returns {void}
|
|
385
|
+
*/
|
|
386
|
+
function clearSharedRelayReconnect(session) {
|
|
387
|
+
if (!session.reconnectTimer) return
|
|
388
|
+
clearTimeout(session.reconnectTimer)
|
|
389
|
+
session.reconnectTimer = null
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* @param {string} relayUrl 中继 URL
|
|
394
|
+
* @param {SharedRelaySession} session 会话
|
|
395
|
+
* @param {import('ws').WebSocket} ws 已打开连接
|
|
289
396
|
* @returns {void}
|
|
290
397
|
*/
|
|
291
|
-
function
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
398
|
+
function attachSharedRelaySocket(relayUrl, session, ws) {
|
|
399
|
+
session.ws = ws
|
|
400
|
+
nodeDebug('p2p:nostr relay up', { url: relayUrl })
|
|
401
|
+
ws.on('message', data => {
|
|
402
|
+
let parsed
|
|
403
|
+
try { parsed = JSON.parse(String(data)) } catch { return }
|
|
404
|
+
if (parsed?.[0] !== 'EVENT') return
|
|
405
|
+
const subId = String(parsed[1] || '')
|
|
406
|
+
const nostrEvent = parsed[2]
|
|
407
|
+
const sub = session.subs.get(subId)
|
|
408
|
+
if (!sub || !nostrEvent) return
|
|
409
|
+
try { sub.onEvent(nostrEvent, relayUrl) } catch { /* ignore */ }
|
|
410
|
+
})
|
|
411
|
+
ws.once('close', () => {
|
|
412
|
+
if (!isLiveSharedSession(relayUrl, session)) return
|
|
413
|
+
session.ws = null
|
|
414
|
+
if (!session.subs.size) {
|
|
415
|
+
sharedRelaySessions.delete(relayUrl)
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
scheduleSharedRelayConnect(relayUrl, session, NOSTR_RECONNECT_DELAY_MS)
|
|
419
|
+
})
|
|
420
|
+
for (const [subId, sub] of session.subs)
|
|
421
|
+
try { ws.send(JSON.stringify(['REQ', subId, sub.filter])) } catch { /* ignore */ }
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* @param {string} relayUrl 中继 URL
|
|
426
|
+
* @param {SharedRelaySession} session 会话
|
|
427
|
+
* @param {number} [delayMs=0] 延迟毫秒(断线重连用)
|
|
428
|
+
* @returns {void}
|
|
429
|
+
*/
|
|
430
|
+
function scheduleSharedRelayConnect(relayUrl, session, delayMs = 0) {
|
|
431
|
+
if (!isLiveSharedSession(relayUrl, session) || session.connecting || session.ws) return
|
|
432
|
+
clearSharedRelayReconnect(session)
|
|
433
|
+
/**
|
|
434
|
+
* @returns {void}
|
|
435
|
+
*/
|
|
436
|
+
const start = () => {
|
|
437
|
+
session.reconnectTimer = null
|
|
438
|
+
if (!isLiveSharedSession(relayUrl, session) || session.connecting || session.ws) return
|
|
439
|
+
if (!session.subs.size) {
|
|
440
|
+
sharedRelaySessions.delete(relayUrl)
|
|
441
|
+
return
|
|
442
|
+
}
|
|
443
|
+
session.connecting = true
|
|
444
|
+
void connectRelay(relayUrl, NOSTR_CONNECT_TIMEOUT_MS).then(ws => {
|
|
445
|
+
session.connecting = false
|
|
446
|
+
if (!isLiveSharedSession(relayUrl, session) || !session.subs.size) {
|
|
447
|
+
if (!session.subs.size && isLiveSharedSession(relayUrl, session))
|
|
448
|
+
sharedRelaySessions.delete(relayUrl)
|
|
295
449
|
dropWebSocket(ws)
|
|
296
450
|
return
|
|
297
451
|
}
|
|
298
|
-
|
|
299
|
-
nodeDebug('p2p:nostr relay up', { url: relayUrl })
|
|
300
|
-
onOpen(ws, relayUrl)
|
|
452
|
+
attachSharedRelaySocket(relayUrl, session, ws)
|
|
301
453
|
}).catch(error => {
|
|
454
|
+
session.connecting = false
|
|
455
|
+
if (!isLiveSharedSession(relayUrl, session)) return
|
|
302
456
|
nodeDebug('p2p:nostr relay fail', {
|
|
303
457
|
url: relayUrl,
|
|
304
458
|
err: String(error?.message || error),
|
|
305
459
|
})
|
|
460
|
+
if (!session.subs.size) {
|
|
461
|
+
sharedRelaySessions.delete(relayUrl)
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
scheduleSharedRelayConnect(relayUrl, session, NOSTR_RECONNECT_DELAY_MS)
|
|
306
465
|
})
|
|
466
|
+
}
|
|
467
|
+
if (delayMs <= 0) {
|
|
468
|
+
// 让同一 tick 内的 registerSharedRelaySub 先写入 subs,再决定是否连接。
|
|
469
|
+
queueMicrotask(start)
|
|
470
|
+
return
|
|
471
|
+
}
|
|
472
|
+
nodeDebug('p2p:nostr relay reconnect', { url: relayUrl, delayMs })
|
|
473
|
+
session.reconnectTimer = setTimeout(start, delayMs)
|
|
474
|
+
session.reconnectTimer.unref?.()
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* @param {string} relayUrl 中继 URL
|
|
479
|
+
* @returns {SharedRelaySession} 共享会话
|
|
480
|
+
*/
|
|
481
|
+
function acquireSharedRelay(relayUrl) {
|
|
482
|
+
const existing = sharedRelaySessions.get(relayUrl)
|
|
483
|
+
if (existing) return existing
|
|
484
|
+
/** @type {SharedRelaySession} */
|
|
485
|
+
const session = {
|
|
486
|
+
ws: null,
|
|
487
|
+
connecting: false,
|
|
488
|
+
reconnectTimer: null,
|
|
489
|
+
subs: new Map(),
|
|
490
|
+
}
|
|
491
|
+
sharedRelaySessions.set(relayUrl, session)
|
|
492
|
+
scheduleSharedRelayConnect(relayUrl, session)
|
|
493
|
+
return session
|
|
307
494
|
}
|
|
308
495
|
|
|
309
496
|
/**
|
|
310
|
-
*
|
|
497
|
+
* @param {string} relayUrl 中继 URL
|
|
498
|
+
* @param {string} subscriptionId 订阅 id
|
|
499
|
+
* @returns {void}
|
|
500
|
+
*/
|
|
501
|
+
function releaseSharedRelaySub(relayUrl, subscriptionId) {
|
|
502
|
+
const session = sharedRelaySessions.get(relayUrl)
|
|
503
|
+
if (!session) return
|
|
504
|
+
session.subs.delete(subscriptionId)
|
|
505
|
+
const ws = session.ws
|
|
506
|
+
if (ws?.readyState === WebSocket.OPEN)
|
|
507
|
+
try { ws.send(JSON.stringify(['CLOSE', subscriptionId])) } catch { /* ignore */ }
|
|
508
|
+
if (session.subs.size) return
|
|
509
|
+
sharedRelaySessions.delete(relayUrl)
|
|
510
|
+
clearSharedRelayReconnect(session)
|
|
511
|
+
if (ws) dropWebSocket(ws)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* 在已打开的共享连接上登记 REQ;连接中则等 attach 时统一重放。
|
|
516
|
+
* @param {SharedRelaySession} session 会话
|
|
517
|
+
* @param {string} subscriptionId 订阅 id
|
|
518
|
+
* @param {object} filter Nostr filter
|
|
519
|
+
* @param {(event: object, relayUrl: string) => void} onEvent 事件回调
|
|
520
|
+
* @returns {void}
|
|
521
|
+
*/
|
|
522
|
+
function registerSharedRelaySub(session, subscriptionId, filter, onEvent) {
|
|
523
|
+
session.subs.set(subscriptionId, { filter, onEvent })
|
|
524
|
+
const ws = session.ws
|
|
525
|
+
if (ws?.readyState !== WebSocket.OPEN) return
|
|
526
|
+
try { ws.send(JSON.stringify(['REQ', subscriptionId, filter])) } catch { /* ignore */ }
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* 内部:按 rendezvous 键订阅 Nostr kind(topic 不导出)。多订阅共享每 URL 一条连接。
|
|
311
531
|
* @param {string[]} relayUrls 中继 URL 列表
|
|
312
|
-
* @param {{ kind: number, rendezvousKey: string, tagX: string, onPayload: (bytes: Uint8Array, meta: { relayUrl: string, event: object }) => void | Promise<void
|
|
532
|
+
* @param {{ kind: number, rendezvousKey: string, tagX: string, onPayload: (bytes: Uint8Array, meta: { relayUrl: string, event: object }) => void | Promise<void>, addressable?: boolean }} options 订阅选项
|
|
313
533
|
* @returns {() => void} 取消订阅
|
|
314
534
|
*/
|
|
315
535
|
function subscribeNostrKind(relayUrls, options) {
|
|
316
|
-
const { kind, rendezvousKey, tagX, onPayload } = options
|
|
317
|
-
const abortController = new AbortController()
|
|
318
|
-
/** @type {import('ws').WebSocket[]} */
|
|
319
|
-
const sockets = []
|
|
536
|
+
const { kind, rendezvousKey, tagX, onPayload, addressable = false } = options
|
|
320
537
|
const subscriptionId = randomBytes(8).toString('hex')
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
|
|
538
|
+
const filter = { kinds: [kind], '#t': [rendezvousKey], '#x': [tagX] }
|
|
539
|
+
if (addressable) filter['#d'] = [rendezvousKey]
|
|
540
|
+
/**
|
|
541
|
+
* @param {object} nostrEvent Nostr EVENT
|
|
542
|
+
* @param {string} relayUrl 来源中继
|
|
543
|
+
* @returns {void}
|
|
544
|
+
*/
|
|
545
|
+
const onEvent = (nostrEvent, relayUrl) => {
|
|
546
|
+
if (nostrEvent?.kind !== kind) return
|
|
547
|
+
try {
|
|
548
|
+
const result = onPayload(base64ToBytes(nostrEvent.content), { relayUrl, event: nostrEvent })
|
|
549
|
+
if (result?.then) void result.catch(() => { })
|
|
550
|
+
}
|
|
551
|
+
catch { /* ignore */ }
|
|
552
|
+
}
|
|
553
|
+
const urls = dedupeRelayUrls(relayUrls)
|
|
554
|
+
for (const relayUrl of urls)
|
|
555
|
+
registerSharedRelaySub(acquireSharedRelay(relayUrl), subscriptionId, filter, onEvent)
|
|
337
556
|
return () => {
|
|
338
|
-
|
|
339
|
-
for (const ws of sockets) dropWebSocket(ws)
|
|
557
|
+
for (const relayUrl of urls) releaseSharedRelaySub(relayUrl, subscriptionId)
|
|
340
558
|
}
|
|
341
559
|
}
|
|
342
560
|
|
|
343
561
|
/**
|
|
344
562
|
* 创建 Nostr discovery provider(list+connect;topic 仅内部)。
|
|
345
|
-
* @param {{ relayUrls?: string[] | null, getRelayUrls?: () => string[] | null | undefined }} [options]
|
|
563
|
+
* @param {{ relayUrls?: string[] | null, getRelayUrls?: () => string[] | null | undefined, localNodeHash?: string }} [options] 中继配置与本机 hash
|
|
346
564
|
* @returns {import('./index.mjs').DiscoveryProvider} Nostr discovery provider
|
|
347
565
|
*/
|
|
348
566
|
export function createNostrDiscoveryProvider(options = {}) {
|
|
@@ -359,55 +577,124 @@ export function createNostrDiscoveryProvider(options = {}) {
|
|
|
359
577
|
return dedupeRelayUrls(options.relayUrls)
|
|
360
578
|
}
|
|
361
579
|
const secretKey = randomBytes(32)
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
const
|
|
366
|
-
/**
|
|
367
|
-
|
|
580
|
+
const seededSelf = normalizeHex64(options.localNodeHash)
|
|
581
|
+
/** @type {string | null} */
|
|
582
|
+
let selfNodeHash = isHex64(seededSelf) ? seededSelf : null
|
|
583
|
+
const NETWORK_SUB_KEY = 'network'
|
|
584
|
+
/**
|
|
585
|
+
* @typedef {{ stop: () => void, held: boolean, listeners: Set<(bytes: Uint8Array, meta: object) => void> }} AdvertSubEntry
|
|
586
|
+
*/
|
|
587
|
+
/** @type {Map<string, AdvertSubEntry>} */
|
|
588
|
+
const advertSubs = new Map()
|
|
368
589
|
/** @type {Map<string, () => void>} */
|
|
369
590
|
const nodeSignalSubs = new Map()
|
|
370
591
|
|
|
371
592
|
/**
|
|
593
|
+
* @param {string | undefined | null} nodeHash 本机 hash
|
|
372
594
|
* @returns {void}
|
|
373
595
|
*/
|
|
596
|
+
function noteSelfNodeHash(nodeHash) {
|
|
597
|
+
const hash = normalizeHex64(nodeHash)
|
|
598
|
+
if (isHex64(hash)) selfNodeHash = hash
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* @param {string} key 订阅键
|
|
603
|
+
* @param {AdvertSubEntry} entry 条目
|
|
604
|
+
* @returns {void}
|
|
605
|
+
*/
|
|
606
|
+
function releaseAdvertEntryIfIdle(key, entry) {
|
|
607
|
+
if (entry.held || entry.listeners.size) return
|
|
608
|
+
try { entry.stop() } catch { /* ignore */ }
|
|
609
|
+
advertSubs.delete(key)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* pool/connect 永久 hold;watch listener 归零且无 hold 时拆 REQ。
|
|
614
|
+
* @param {string} key 订阅键
|
|
615
|
+
* @param {{ rendezvousKey: string, roomSecret?: string }} bind advert 绑定
|
|
616
|
+
* @param {(bytes: Uint8Array, meta: object) => void} [listener] 额外监听
|
|
617
|
+
* @returns {() => void} 取消 listener;无 listener 时 no-op(hold 至 dispose)
|
|
618
|
+
*/
|
|
619
|
+
function ensureAdvertSubscription(key, bind, listener) {
|
|
620
|
+
if (!key) return () => { }
|
|
621
|
+
let entry = advertSubs.get(key)
|
|
622
|
+
if (!entry) {
|
|
623
|
+
/** @type {AdvertSubEntry} */
|
|
624
|
+
const created = {
|
|
625
|
+
/**
|
|
626
|
+
* @returns {void}
|
|
627
|
+
*/
|
|
628
|
+
stop: () => { }, held: false, listeners: new Set()
|
|
629
|
+
}
|
|
630
|
+
created.stop = subscribeNostrKind(resolveRelayUrls(), {
|
|
631
|
+
kind: NOSTR_ADVERT_KIND,
|
|
632
|
+
rendezvousKey: bind.rendezvousKey,
|
|
633
|
+
tagX: 'advert',
|
|
634
|
+
addressable: true,
|
|
635
|
+
/**
|
|
636
|
+
* @param {Uint8Array} bytes 加密 advert 载荷
|
|
637
|
+
* @param {object} meta relay 元数据
|
|
638
|
+
* @returns {Promise<void>}
|
|
639
|
+
*/
|
|
640
|
+
async onPayload(bytes, meta) {
|
|
641
|
+
await acceptNostrAdvert(bind.rendezvousKey, bytes, {
|
|
642
|
+
roomSecret: bind.roomSecret,
|
|
643
|
+
skipNodeHash: selfNodeHash || undefined,
|
|
644
|
+
meta,
|
|
645
|
+
})
|
|
646
|
+
for (const fn of created.listeners)
|
|
647
|
+
try { fn(bytes, meta) } catch { /* ignore */ }
|
|
648
|
+
},
|
|
649
|
+
})
|
|
650
|
+
entry = created
|
|
651
|
+
advertSubs.set(key, entry)
|
|
652
|
+
}
|
|
653
|
+
if (!listener) {
|
|
654
|
+
entry.held = true
|
|
655
|
+
return () => { }
|
|
656
|
+
}
|
|
657
|
+
entry.listeners.add(listener)
|
|
658
|
+
return () => {
|
|
659
|
+
entry.listeners.delete(listener)
|
|
660
|
+
releaseAdvertEntryIfIdle(key, entry)
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* @returns {() => void} no-op(network hold 至 dispose)
|
|
666
|
+
*/
|
|
374
667
|
function ensureNetworkAdvertSubscription() {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
stopNetworkAdvertSub = subscribeNostrKind(resolveRelayUrls(), {
|
|
378
|
-
kind: NOSTR_ADVERT_KIND,
|
|
379
|
-
rendezvousKey,
|
|
380
|
-
tagX: 'advert',
|
|
381
|
-
/**
|
|
382
|
-
* @param {Uint8Array} bytes 加密 advert 载荷
|
|
383
|
-
* @returns {Promise<void>}
|
|
384
|
-
*/
|
|
385
|
-
async onPayload(bytes) {
|
|
386
|
-
await acceptNostrAdvert(rendezvousKey, bytes)
|
|
387
|
-
},
|
|
668
|
+
return ensureAdvertSubscription(NETWORK_SUB_KEY, {
|
|
669
|
+
rendezvousKey: networkRendezvousKey(),
|
|
388
670
|
})
|
|
389
671
|
}
|
|
390
672
|
|
|
391
673
|
/**
|
|
392
674
|
* @param {string} roomSecret 房间密钥
|
|
393
|
-
* @
|
|
675
|
+
* @param {(bytes: Uint8Array, meta: object) => void} [listener] 额外 advert 监听
|
|
676
|
+
* @returns {() => void} 取消 listener
|
|
394
677
|
*/
|
|
395
|
-
function ensureGroupSubscription(roomSecret) {
|
|
678
|
+
function ensureGroupSubscription(roomSecret, listener) {
|
|
396
679
|
const key = String(roomSecret || '')
|
|
397
|
-
if (!key
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
680
|
+
if (!key) return () => { }
|
|
681
|
+
return ensureAdvertSubscription('group:' + key, {
|
|
682
|
+
rendezvousKey: groupRendezvousKey(key),
|
|
683
|
+
roomSecret: key,
|
|
684
|
+
}, listener)
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* @param {string} nodeHash 目标
|
|
689
|
+
* @param {(bytes: Uint8Array, meta: object) => void} [listener] 额外 advert 监听
|
|
690
|
+
* @returns {() => void} 取消 listener
|
|
691
|
+
*/
|
|
692
|
+
function ensureNodeAdvertSubscription(nodeHash, listener) {
|
|
693
|
+
const hash = normalizeHex64(nodeHash)
|
|
694
|
+
if (!isHex64(hash)) return () => { }
|
|
695
|
+
return ensureAdvertSubscription('node:' + hash, {
|
|
696
|
+
rendezvousKey: nodeRendezvousKey(hash),
|
|
697
|
+
}, listener)
|
|
411
698
|
}
|
|
412
699
|
|
|
413
700
|
return {
|
|
@@ -422,10 +709,14 @@ export function createNostrDiscoveryProvider(options = {}) {
|
|
|
422
709
|
const limit = Math.max(1, Number(options.limit) || 64)
|
|
423
710
|
if (options.roomSecret) {
|
|
424
711
|
ensureGroupSubscription(options.roomSecret)
|
|
425
|
-
return listNostrGroupVisibleNodeHashes(options.roomSecret)
|
|
712
|
+
return listNostrGroupVisibleNodeHashes(options.roomSecret)
|
|
713
|
+
.filter(hash => hash !== selfNodeHash)
|
|
714
|
+
.slice(0, limit)
|
|
426
715
|
}
|
|
427
716
|
ensureNetworkAdvertSubscription()
|
|
428
|
-
return listNostrVisibleNodeHashes()
|
|
717
|
+
return listNostrVisibleNodeHashes()
|
|
718
|
+
.filter(hash => hash !== selfNodeHash)
|
|
719
|
+
.slice(0, limit)
|
|
429
720
|
},
|
|
430
721
|
/**
|
|
431
722
|
* 挂上对该节点的内部 advert 订阅(建链由 registry dialer / ensureLinkToNode 完成)。
|
|
@@ -435,21 +726,7 @@ export function createNostrDiscoveryProvider(options = {}) {
|
|
|
435
726
|
async connectToNode(nodeHash) {
|
|
436
727
|
const hash = normalizeHex64(nodeHash)
|
|
437
728
|
if (!isHex64(hash)) return false
|
|
438
|
-
|
|
439
|
-
const rendezvousKey = nodeRendezvousKey(hash)
|
|
440
|
-
nodeAdvertSubs.set(hash, subscribeNostrKind(resolveRelayUrls(), {
|
|
441
|
-
kind: NOSTR_ADVERT_KIND,
|
|
442
|
-
rendezvousKey,
|
|
443
|
-
tagX: 'advert',
|
|
444
|
-
/**
|
|
445
|
-
* @param {Uint8Array} bytes 加密 advert 载荷
|
|
446
|
-
* @returns {Promise<void>}
|
|
447
|
-
*/
|
|
448
|
-
async onPayload(bytes) {
|
|
449
|
-
await acceptNostrAdvert(rendezvousKey, bytes)
|
|
450
|
-
},
|
|
451
|
-
}))
|
|
452
|
-
}
|
|
729
|
+
ensureNodeAdvertSubscription(hash)
|
|
453
730
|
return true
|
|
454
731
|
},
|
|
455
732
|
/**
|
|
@@ -467,12 +744,12 @@ export function createNostrDiscoveryProvider(options = {}) {
|
|
|
467
744
|
if (abortController.signal.aborted) return
|
|
468
745
|
const beacon = await getBeacon?.()
|
|
469
746
|
if (!beacon?.nodeHash) return
|
|
470
|
-
|
|
747
|
+
noteSelfNodeHash(beacon.nodeHash)
|
|
471
748
|
const advertBody = beacon.advertBody || beacon.body || beacon
|
|
472
749
|
const bytes = encryptSignalPacket(rendezvousKey, { type: 'advert', body: advertBody })
|
|
473
750
|
const event = await signNostrEvent(
|
|
474
751
|
NOSTR_ADVERT_KIND,
|
|
475
|
-
[['t', rendezvousKey], ['x', 'advert']],
|
|
752
|
+
[['t', rendezvousKey], ['x', 'advert'], ['d', rendezvousKey]],
|
|
476
753
|
bytesToBase64(bytes),
|
|
477
754
|
secretKey,
|
|
478
755
|
)
|
|
@@ -518,6 +795,7 @@ export function createNostrDiscoveryProvider(options = {}) {
|
|
|
518
795
|
async listenNodeSignals(localNodeHash, onSignal) {
|
|
519
796
|
const hash = normalizeHex64(localNodeHash)
|
|
520
797
|
if (!isHex64(hash)) throw new Error('nostr: invalid nodeHash')
|
|
798
|
+
noteSelfNodeHash(hash)
|
|
521
799
|
const rendezvousKey = nodeRendezvousKey(hash)
|
|
522
800
|
const existing = nodeSignalSubs.get(hash)
|
|
523
801
|
if (existing) existing()
|
|
@@ -542,19 +820,7 @@ export function createNostrDiscoveryProvider(options = {}) {
|
|
|
542
820
|
async watchNodeAdvert(nodeHash, onAdvert) {
|
|
543
821
|
const hash = normalizeHex64(nodeHash)
|
|
544
822
|
if (!isHex64(hash)) throw new Error('nostr: invalid nodeHash')
|
|
545
|
-
|
|
546
|
-
const stop = subscribeNostrKind(resolveRelayUrls(), {
|
|
547
|
-
kind: NOSTR_ADVERT_KIND,
|
|
548
|
-
rendezvousKey,
|
|
549
|
-
tagX: 'advert',
|
|
550
|
-
/**
|
|
551
|
-
* @param {Uint8Array} bytes 加密 advert 载荷
|
|
552
|
-
* @param {object} meta relay 元数据
|
|
553
|
-
* @returns {void}
|
|
554
|
-
*/
|
|
555
|
-
onPayload: (bytes, meta) => onAdvert(bytes, meta),
|
|
556
|
-
})
|
|
557
|
-
return stop
|
|
823
|
+
return ensureNodeAdvertSubscription(hash, onAdvert)
|
|
558
824
|
},
|
|
559
825
|
/**
|
|
560
826
|
* @param {string} roomSecret 房间密钥
|
|
@@ -573,12 +839,12 @@ export function createNostrDiscoveryProvider(options = {}) {
|
|
|
573
839
|
if (abortController.signal.aborted) return
|
|
574
840
|
const beacon = await getBeacon?.()
|
|
575
841
|
if (!beacon?.nodeHash) return
|
|
576
|
-
|
|
842
|
+
noteSelfNodeHash(beacon.nodeHash)
|
|
577
843
|
const advertBody = beacon.advertBody || beacon.body || beacon
|
|
578
844
|
const bytes = encryptSignalPacket(rendezvousKey, { type: 'advert', body: advertBody })
|
|
579
845
|
const event = await signNostrEvent(
|
|
580
846
|
NOSTR_ADVERT_KIND,
|
|
581
|
-
[['t', rendezvousKey], ['x', 'advert']],
|
|
847
|
+
[['t', rendezvousKey], ['x', 'advert'], ['d', rendezvousKey]],
|
|
582
848
|
bytesToBase64(bytes),
|
|
583
849
|
secretKey,
|
|
584
850
|
)
|
|
@@ -598,20 +864,7 @@ export function createNostrDiscoveryProvider(options = {}) {
|
|
|
598
864
|
* @returns {Promise<() => void>} 取消群 advert 监听
|
|
599
865
|
*/
|
|
600
866
|
async watchGroupAdverts(roomSecret, onAdvert) {
|
|
601
|
-
|
|
602
|
-
const rendezvousKey = groupRendezvousKey(key)
|
|
603
|
-
ensureGroupSubscription(key)
|
|
604
|
-
return subscribeNostrKind(resolveRelayUrls(), {
|
|
605
|
-
kind: NOSTR_ADVERT_KIND,
|
|
606
|
-
rendezvousKey,
|
|
607
|
-
tagX: 'advert',
|
|
608
|
-
/**
|
|
609
|
-
* @param {Uint8Array} bytes 加密 advert 载荷
|
|
610
|
-
* @param {object} meta relay 元数据
|
|
611
|
-
* @returns {void}
|
|
612
|
-
*/
|
|
613
|
-
onPayload: (bytes, meta) => onAdvert(bytes, meta),
|
|
614
|
-
})
|
|
867
|
+
return ensureGroupSubscription(roomSecret, onAdvert)
|
|
615
868
|
},
|
|
616
869
|
/**
|
|
617
870
|
* 供 advert 解析路径写入可见 hash。
|
|
@@ -628,14 +881,9 @@ export function createNostrDiscoveryProvider(options = {}) {
|
|
|
628
881
|
* @returns {void}
|
|
629
882
|
*/
|
|
630
883
|
dispose() {
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
try { stop() } catch { /* ignore */ }
|
|
635
|
-
groupSubs.clear()
|
|
636
|
-
for (const stop of nodeAdvertSubs.values())
|
|
637
|
-
try { stop() } catch { /* ignore */ }
|
|
638
|
-
nodeAdvertSubs.clear()
|
|
884
|
+
for (const entry of advertSubs.values())
|
|
885
|
+
try { entry.stop() } catch { /* ignore */ }
|
|
886
|
+
advertSubs.clear()
|
|
639
887
|
for (const stop of nodeSignalSubs.values())
|
|
640
888
|
try { stop() } catch { /* ignore */ }
|
|
641
889
|
nodeSignalSubs.clear()
|