@steve02081504/fount-p2p 0.0.12 → 0.0.14
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 +41 -10
- package/core/composite_key.mjs +5 -5
- package/crypto/crypto.mjs +3 -3
- package/crypto/key.mjs +12 -12
- package/dag/storage.mjs +36 -36
- package/discovery/advert_peer_hints.mjs +9 -3
- package/discovery/adverts.mjs +115 -0
- package/discovery/bt/index.mjs +153 -112
- package/discovery/bt/probe_child.mjs +2 -1
- package/discovery/bt/runtime.mjs +2 -9
- package/discovery/index.mjs +267 -62
- package/discovery/internal/signal_crypto.mjs +109 -0
- package/discovery/lan.mjs +279 -0
- package/discovery/lan_interfaces.mjs +78 -0
- package/discovery/lan_peer_hints.mjs +22 -5
- package/discovery/nostr.mjs +474 -65
- package/federation/chunk_fetch_pending.mjs +10 -13
- package/federation/manifest_fetch_pending.mjs +1 -3
- package/files/assemble.mjs +14 -14
- package/files/assemble_stream.mjs +6 -6
- package/files/chunk_responder.mjs +29 -20
- package/files/evfs.mjs +29 -28
- package/files/manifest_fetch.mjs +16 -3
- package/files/public_manifest.mjs +11 -11
- package/files/transfer_key_registry.mjs +2 -2
- package/index.mjs +86 -11
- package/infra/cli.mjs +62 -0
- package/infra/debug_log.mjs +56 -0
- package/infra/default_node_dir.mjs +22 -0
- package/infra/priority.mjs +56 -0
- package/infra/service.mjs +140 -0
- package/infra/tunables.json +5 -0
- package/link/frame.mjs +9 -16
- package/link/handshake.mjs +25 -18
- package/link/pipe.mjs +8 -8
- package/link/providers/ble_gatt.mjs +6 -6
- package/link/providers/lan_tcp.mjs +62 -33
- package/link/providers/webrtc.mjs +3 -1
- package/link/rtc.mjs +3 -3
- package/mailbox/consumer_registry.mjs +2 -2
- package/mailbox/deliver_or_store.mjs +5 -5
- package/mailbox/wire.mjs +28 -24
- package/node/entity_store.mjs +6 -6
- package/node/instance.mjs +46 -27
- package/node/local_data_revision.mjs +26 -0
- package/node/log.mjs +56 -0
- package/node/network.mjs +37 -5
- package/node/reputation_store.mjs +4 -4
- package/node/reputation_sync.mjs +318 -0
- package/node/routing_profile.mjs +21 -0
- package/node/signaling_config.mjs +30 -11
- package/overlay/index.mjs +22 -1
- package/package.json +13 -2
- package/rooms/scoped_link.mjs +17 -166
- package/transport/advert_ingest.mjs +11 -14
- package/transport/group_link_set.mjs +149 -99
- package/transport/link_registry.mjs +211 -60
- package/transport/mesh_keepalive.mjs +217 -0
- package/transport/node_scope.mjs +289 -0
- package/transport/offer_answer.mjs +45 -48
- package/transport/peer_pool.mjs +116 -14
- package/transport/remote_user_room.mjs +6 -9
- package/transport/{rtc_mdns_filter.mjs → rtc_ice_local_hostname.mjs} +13 -13
- package/transport/runtime_bootstrap.mjs +170 -108
- package/transport/tunables.json +11 -0
- package/transport/tunables.mjs +18 -0
- package/transport/user_room.mjs +83 -158
- package/trust_graph/build.mjs +0 -2
- package/trust_graph/cache.mjs +12 -3
- package/trust_graph/registry.mjs +6 -6
- package/trust_graph/send.mjs +0 -3
- package/utils/async_mutex.mjs +4 -4
- package/utils/emit_safe.mjs +3 -3
- package/utils/json_io.mjs +12 -12
- package/utils/map_pool.mjs +5 -5
- package/wire/part_ingress.mjs +32 -28
- package/wire/part_query.mjs +26 -20
- package/discovery/mdns.mjs +0 -197
- package/transport/signal_crypto.mjs +0 -104
package/discovery/nostr.mjs
CHANGED
|
@@ -4,7 +4,18 @@ import { schnorr } from '@noble/curves/secp256k1.js'
|
|
|
4
4
|
import WebSocket from 'ws'
|
|
5
5
|
|
|
6
6
|
import { base64ToBytes, hexToBytes, bytesToBase64, bytesToHex } from '../core/bytes_codec.mjs'
|
|
7
|
+
import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
|
|
7
8
|
import { sha256Hex } from '../crypto/crypto.mjs'
|
|
9
|
+
import { nodeDebug, shortHash } from '../node/log.mjs'
|
|
10
|
+
|
|
11
|
+
import { ingestEncryptedAdvert } from './adverts.mjs'
|
|
12
|
+
import { noteAdvertPeerHints } from './advert_peer_hints.mjs'
|
|
13
|
+
import {
|
|
14
|
+
encryptSignalPacket,
|
|
15
|
+
groupRendezvousKey,
|
|
16
|
+
networkRendezvousKey,
|
|
17
|
+
nodeRendezvousKey,
|
|
18
|
+
} from './internal/signal_crypto.mjs'
|
|
8
19
|
|
|
9
20
|
/** 默认 Nostr 中继 URL 列表。 */
|
|
10
21
|
export const DEFAULT_RELAY_URLS = [
|
|
@@ -18,14 +29,128 @@ export const NOSTR_CONNECT_TIMEOUT_MS = 2_000
|
|
|
18
29
|
/** 先 close,超时未 CLOSED 再 terminate(给对端优雅关闭的窗口)。 */
|
|
19
30
|
export const NOSTR_CLOSE_GRACE_MS = 1_000
|
|
20
31
|
|
|
21
|
-
/**
|
|
22
|
-
export const
|
|
23
|
-
|
|
24
|
-
|
|
32
|
+
/** 单 relay 等待 EVENT OK 回执超时。 */
|
|
33
|
+
export const NOSTR_PUBLISH_OK_TIMEOUT_MS = 3_000
|
|
34
|
+
|
|
35
|
+
/** Nostr network advert 事件 kind(addressable,可存储)。 */
|
|
36
|
+
export const NOSTR_ADVERT_KIND = 30787
|
|
37
|
+
/** Nostr signal 事件 kind(ephemeral,实时转发)。 */
|
|
38
|
+
export const NOSTR_SIGNAL_KIND = 20787
|
|
39
|
+
|
|
40
|
+
const ADVERT_TTL_MS = 10 * 60_000
|
|
41
|
+
|
|
42
|
+
/** @type {Map<string, number>} 网络域 nodeHash → lastSeenAt */
|
|
43
|
+
const visibleByHash = new Map()
|
|
44
|
+
/** @type {Map<string, Map<string, number>>} roomSecret → (nodeHash → lastSeenAt) */
|
|
45
|
+
const visibleByGroup = new Map()
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {Map<string, number>} pool 可见池
|
|
49
|
+
* @param {number} now 当前时间
|
|
50
|
+
* @param {number} ttlMs TTL
|
|
51
|
+
* @returns {string[]} 未过期 nodeHash
|
|
52
|
+
*/
|
|
53
|
+
function listPoolHashes(pool, now, ttlMs) {
|
|
54
|
+
/** @type {string[]} */
|
|
55
|
+
const out = []
|
|
56
|
+
for (const [hash, seenAt] of pool)
|
|
57
|
+
if (now - seenAt <= ttlMs) out.push(hash)
|
|
58
|
+
else pool.delete(hash)
|
|
59
|
+
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 写入网络域可见池(非群)。
|
|
65
|
+
* @param {string} nodeHash 节点 hash
|
|
66
|
+
* @param {number} [now=Date.now()] 当前时间
|
|
67
|
+
* @returns {void}
|
|
68
|
+
*/
|
|
69
|
+
export function noteNostrVisibleNode(nodeHash, now = Date.now()) {
|
|
70
|
+
const hash = normalizeHex64(nodeHash)
|
|
71
|
+
if (!isHex64(hash)) return
|
|
72
|
+
visibleByHash.set(hash, now)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 写入群域可见池(与网络域隔离)。
|
|
77
|
+
* @param {string} roomSecret 房间密钥
|
|
78
|
+
* @param {string} nodeHash 节点 hash
|
|
79
|
+
* @param {number} [now=Date.now()] 当前时间
|
|
80
|
+
* @returns {void}
|
|
81
|
+
*/
|
|
82
|
+
export function noteNostrGroupVisibleNode(roomSecret, nodeHash, now = Date.now()) {
|
|
83
|
+
const key = String(roomSecret || '')
|
|
84
|
+
const hash = normalizeHex64(nodeHash)
|
|
85
|
+
if (!key || !isHex64(hash)) return
|
|
86
|
+
let pool = visibleByGroup.get(key)
|
|
87
|
+
if (!pool) {
|
|
88
|
+
pool = new Map()
|
|
89
|
+
visibleByGroup.set(key, pool)
|
|
90
|
+
}
|
|
91
|
+
pool.set(hash, now)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param {number} [now=Date.now()] 当前时间
|
|
96
|
+
* @param {number} [ttlMs=ADVERT_TTL_MS] TTL
|
|
97
|
+
* @returns {string[]} 网络域可见 nodeHash
|
|
98
|
+
*/
|
|
99
|
+
export function listNostrVisibleNodeHashes(now = Date.now(), ttlMs = ADVERT_TTL_MS) {
|
|
100
|
+
return listPoolHashes(visibleByHash, now, ttlMs)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {string} roomSecret 房间密钥
|
|
105
|
+
* @param {number} [now=Date.now()] 当前时间
|
|
106
|
+
* @param {number} [ttlMs=ADVERT_TTL_MS] TTL
|
|
107
|
+
* @returns {string[]} 该群可见 nodeHash
|
|
108
|
+
*/
|
|
109
|
+
export function listNostrGroupVisibleNodeHashes(roomSecret, now = Date.now(), ttlMs = ADVERT_TTL_MS) {
|
|
110
|
+
const key = String(roomSecret || '')
|
|
111
|
+
const pool = visibleByGroup.get(key)
|
|
112
|
+
if (!pool) return []
|
|
113
|
+
const out = listPoolHashes(pool, now, ttlMs)
|
|
114
|
+
if (!pool.size) visibleByGroup.delete(key)
|
|
115
|
+
return out
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 解密并验签后写入 Nostr 可见池;伪造 body.nodeHash 无效。
|
|
120
|
+
* @param {string} rendezvousKey rendezvous 键
|
|
121
|
+
* @param {Uint8Array} bytes 加密 advert
|
|
122
|
+
* @param {{ roomSecret?: string }} [options] 带 roomSecret 时写入群池
|
|
123
|
+
* @returns {Promise<string | null>} 验签通过的 nodeHash
|
|
124
|
+
*/
|
|
125
|
+
export async function acceptNostrAdvert(rendezvousKey, bytes, options = {}) {
|
|
126
|
+
const ingested = await ingestEncryptedAdvert(rendezvousKey, bytes)
|
|
127
|
+
if (!ingested) return null
|
|
128
|
+
const hash = ingested.verifiedNodeHash
|
|
129
|
+
const roomSecret = options.roomSecret
|
|
130
|
+
let firstSeen = true
|
|
131
|
+
if (roomSecret) {
|
|
132
|
+
const key = String(roomSecret || '')
|
|
133
|
+
firstSeen = !visibleByGroup.get(key)?.has(hash)
|
|
134
|
+
noteNostrGroupVisibleNode(key, hash)
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
firstSeen = !visibleByHash.has(hash)
|
|
138
|
+
noteNostrVisibleNode(hash)
|
|
139
|
+
}
|
|
140
|
+
if (firstSeen)
|
|
141
|
+
nodeDebug('p2p:nostr peer visible', { peer: shortHash(hash), group: !!roomSecret })
|
|
142
|
+
noteAdvertPeerHints(hash, ingested.body, options.meta || {})
|
|
143
|
+
return hash
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** @returns {void} 测试用 */
|
|
147
|
+
export function clearNostrVisibleNodes() {
|
|
148
|
+
visibleByHash.clear()
|
|
149
|
+
visibleByGroup.clear()
|
|
150
|
+
}
|
|
25
151
|
|
|
26
152
|
/**
|
|
27
153
|
* 关掉 WebSocket:已连上则先 close,grace 内未 CLOSED 再 terminate。
|
|
28
|
-
* 连接中直接 terminate(无握手可优雅收尾)。
|
|
29
154
|
* @param {import('ws').WebSocket} ws 连接
|
|
30
155
|
* @returns {void}
|
|
31
156
|
*/
|
|
@@ -39,7 +164,6 @@ function dropWebSocket(ws) {
|
|
|
39
164
|
if (ws.readyState !== WebSocket.CLOSED)
|
|
40
165
|
try { ws.terminate() } catch { /* ignore */ }
|
|
41
166
|
}, NOSTR_CLOSE_GRACE_MS)
|
|
42
|
-
// Grace is polite to the peer; must not pin the process after shutdown.
|
|
43
167
|
timer.unref()
|
|
44
168
|
ws.once('close', () => clearTimeout(timer))
|
|
45
169
|
try {
|
|
@@ -52,7 +176,6 @@ function dropWebSocket(ws) {
|
|
|
52
176
|
}
|
|
53
177
|
|
|
54
178
|
/**
|
|
55
|
-
* 去重并清洗中继 URL 列表。
|
|
56
179
|
* @param {string[] | undefined | null} urls 原始列表
|
|
57
180
|
* @returns {string[]} 清洗后的列表
|
|
58
181
|
*/
|
|
@@ -64,7 +187,6 @@ function dedupeRelayUrls(urls) {
|
|
|
64
187
|
}
|
|
65
188
|
|
|
66
189
|
/**
|
|
67
|
-
* 合并默认与用户配置的中继 URL(去重)。
|
|
68
190
|
* @param {string[] | undefined | null} userRelayUrls 用户自定义中继列表
|
|
69
191
|
* @returns {string[]} 合并后的中继 URL 列表
|
|
70
192
|
*/
|
|
@@ -74,7 +196,6 @@ export function mergeSignalingRelayUrls(userRelayUrls) {
|
|
|
74
196
|
}
|
|
75
197
|
|
|
76
198
|
/**
|
|
77
|
-
* 签名 Nostr 事件。
|
|
78
199
|
* @param {number} kind 事件 kind
|
|
79
200
|
* @param {string[][]} tags 事件标签
|
|
80
201
|
* @param {string} content 事件内容
|
|
@@ -91,7 +212,6 @@ async function signNostrEvent(kind, tags, content, secretKey) {
|
|
|
91
212
|
}
|
|
92
213
|
|
|
93
214
|
/**
|
|
94
|
-
* 连接 Nostr 中继 WebSocket(`ws` 包,支持 terminate)。
|
|
95
215
|
* @param {string} relayUrl 中继 URL
|
|
96
216
|
* @param {number} [timeoutMs] 超时毫秒
|
|
97
217
|
* @param {AbortSignal} [signal] 取消信号
|
|
@@ -138,13 +258,77 @@ function connectRelay(relayUrl, timeoutMs = NOSTR_CONNECT_TIMEOUT_MS, signal) {
|
|
|
138
258
|
}
|
|
139
259
|
|
|
140
260
|
/**
|
|
141
|
-
*
|
|
261
|
+
* @param {import('ws').WebSocket} ws 已连接 relay
|
|
262
|
+
* @param {string} relayUrl 中继 URL
|
|
263
|
+
* @param {object} event 待发布事件
|
|
264
|
+
* @param {AbortSignal} [signal] 取消信号
|
|
265
|
+
* @returns {Promise<boolean>} relay 是否接受 EVENT
|
|
266
|
+
*/
|
|
267
|
+
function publishEventOnRelay(ws, relayUrl, event, signal) {
|
|
268
|
+
return new Promise((resolve, reject) => {
|
|
269
|
+
if (signal?.aborted) {
|
|
270
|
+
reject(new Error('nostr: aborted'))
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
let settled = false
|
|
274
|
+
/**
|
|
275
|
+
* @param {boolean} ok relay 是否接受
|
|
276
|
+
* @param {Error | null} [error] 失败原因
|
|
277
|
+
* @returns {void}
|
|
278
|
+
*/
|
|
279
|
+
const finish = (ok, error = null) => {
|
|
280
|
+
if (settled) return
|
|
281
|
+
settled = true
|
|
282
|
+
clearTimeout(timer)
|
|
283
|
+
signal?.removeEventListener('abort', onAbort)
|
|
284
|
+
ws.off('message', onMessage)
|
|
285
|
+
if (error) reject(error)
|
|
286
|
+
else resolve(ok)
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* @returns {void}
|
|
290
|
+
*/
|
|
291
|
+
const onAbort = () => finish(false, new Error('nostr: aborted'))
|
|
292
|
+
/**
|
|
293
|
+
* @param {import('ws').RawData} data relay 消息
|
|
294
|
+
* @returns {void}
|
|
295
|
+
*/
|
|
296
|
+
const onMessage = data => {
|
|
297
|
+
let parsed
|
|
298
|
+
try { parsed = JSON.parse(String(data)) } catch { return }
|
|
299
|
+
if (parsed?.[0] !== 'OK' || parsed[1] !== event.id) return
|
|
300
|
+
const accepted = parsed[2] === true
|
|
301
|
+
if (!accepted)
|
|
302
|
+
nodeDebug('p2p:nostr publish rejected', {
|
|
303
|
+
url: relayUrl,
|
|
304
|
+
reason: String(parsed[3] || 'rejected'),
|
|
305
|
+
})
|
|
306
|
+
finish(accepted)
|
|
307
|
+
}
|
|
308
|
+
const timer = setTimeout(
|
|
309
|
+
() => finish(false, new Error(`nostr: publish ok timeout for ${relayUrl}`)),
|
|
310
|
+
NOSTR_PUBLISH_OK_TIMEOUT_MS,
|
|
311
|
+
)
|
|
312
|
+
timer.unref()
|
|
313
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
314
|
+
ws.on('message', onMessage)
|
|
315
|
+
try {
|
|
316
|
+
ws.send(JSON.stringify(['EVENT', event]))
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
finish(false, error instanceof Error ? error : new Error(String(error)))
|
|
320
|
+
}
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
142
325
|
* @param {string[]} relayUrls 中继 URL 列表
|
|
143
326
|
* @param {object} event 待发布事件
|
|
144
327
|
* @param {AbortSignal} [signal] 取消信号
|
|
145
328
|
* @returns {Promise<void>}
|
|
146
329
|
*/
|
|
147
330
|
async function publishEvent(relayUrls, event, signal) {
|
|
331
|
+
if (!relayUrls.length) throw new Error('nostr: no relay')
|
|
148
332
|
let published = false
|
|
149
333
|
let lastError = null
|
|
150
334
|
await Promise.allSettled(relayUrls.map(async relayUrl => {
|
|
@@ -152,8 +336,8 @@ async function publishEvent(relayUrls, event, signal) {
|
|
|
152
336
|
const ws = await connectRelay(relayUrl, NOSTR_CONNECT_TIMEOUT_MS, signal)
|
|
153
337
|
try {
|
|
154
338
|
if (signal?.aborted) throw new Error('nostr: aborted')
|
|
155
|
-
ws
|
|
156
|
-
published = true
|
|
339
|
+
const ok = await publishEventOnRelay(ws, relayUrl, event, signal)
|
|
340
|
+
if (ok) published = true
|
|
157
341
|
}
|
|
158
342
|
catch (error) {
|
|
159
343
|
lastError = error
|
|
@@ -167,7 +351,6 @@ async function publishEvent(relayUrls, event, signal) {
|
|
|
167
351
|
}
|
|
168
352
|
|
|
169
353
|
/**
|
|
170
|
-
* 并行渐进连接中继:立刻返回,连上后回调;abort 后 stop 接入并 drop 已开 socket。
|
|
171
354
|
* @param {string[]} relayUrls 中继列表
|
|
172
355
|
* @param {(ws: import('ws').WebSocket, relayUrl: string) => void} onOpen 连上回调
|
|
173
356
|
* @param {AbortSignal} signal 取消信号
|
|
@@ -182,18 +365,24 @@ function connectRelaysProgressive(relayUrls, onOpen, signal, sockets) {
|
|
|
182
365
|
return
|
|
183
366
|
}
|
|
184
367
|
sockets.push(ws)
|
|
368
|
+
nodeDebug('p2p:nostr relay up', { url: relayUrl })
|
|
185
369
|
onOpen(ws, relayUrl)
|
|
186
|
-
}).catch(
|
|
370
|
+
}).catch(error => {
|
|
371
|
+
nodeDebug('p2p:nostr relay fail', {
|
|
372
|
+
url: relayUrl,
|
|
373
|
+
err: String(error?.message || error),
|
|
374
|
+
})
|
|
375
|
+
})
|
|
187
376
|
}
|
|
188
377
|
|
|
189
378
|
/**
|
|
190
|
-
*
|
|
379
|
+
* 内部:按 rendezvous 键订阅 Nostr kind(topic 不导出)。
|
|
191
380
|
* @param {string[]} relayUrls 中继 URL 列表
|
|
192
|
-
* @param {{ kind: number,
|
|
381
|
+
* @param {{ kind: number, rendezvousKey: string, tagX: string, onPayload: (bytes: Uint8Array, meta: { relayUrl: string, event: object }) => void | Promise<void> }} options 订阅选项
|
|
193
382
|
* @returns {() => void} 取消订阅
|
|
194
383
|
*/
|
|
195
384
|
function subscribeNostrKind(relayUrls, options) {
|
|
196
|
-
const { kind,
|
|
385
|
+
const { kind, rendezvousKey, tagX, onPayload, addressable = false } = options
|
|
197
386
|
const abortController = new AbortController()
|
|
198
387
|
/** @type {import('ws').WebSocket[]} */
|
|
199
388
|
const sockets = []
|
|
@@ -206,10 +395,15 @@ function subscribeNostrKind(relayUrls, options) {
|
|
|
206
395
|
if (parsed?.[0] !== 'EVENT') return
|
|
207
396
|
const nostrEvent = parsed[2]
|
|
208
397
|
if (nostrEvent?.kind !== kind) return
|
|
209
|
-
try {
|
|
398
|
+
try {
|
|
399
|
+
const result = onPayload(base64ToBytes(nostrEvent.content), { relayUrl, event: nostrEvent })
|
|
400
|
+
if (result?.then) void result.catch(() => { })
|
|
401
|
+
}
|
|
210
402
|
catch { /* ignore */ }
|
|
211
403
|
})
|
|
212
|
-
|
|
404
|
+
const filter = { kinds: [kind], '#t': [rendezvousKey], '#x': [tagX] }
|
|
405
|
+
if (addressable) filter['#d'] = [rendezvousKey]
|
|
406
|
+
ws.send(JSON.stringify(['REQ', subscriptionId, filter]))
|
|
213
407
|
}, abortController.signal, sockets)
|
|
214
408
|
return () => {
|
|
215
409
|
abortController.abort()
|
|
@@ -218,92 +412,307 @@ function subscribeNostrKind(relayUrls, options) {
|
|
|
218
412
|
}
|
|
219
413
|
|
|
220
414
|
/**
|
|
221
|
-
* 创建 Nostr discovery provider
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
* 省略 / `undefined` → 默认公网中继;显式 `[]` 表示无中继(不再回填默认)。
|
|
225
|
-
* @param {{ relayUrls?: string[] | null }} [options] 可选最终中继 URL 列表
|
|
226
|
-
* @returns {import('./index.mjs').DiscoveryProvider} Nostr 发现提供者
|
|
415
|
+
* 创建 Nostr discovery provider(list+connect;topic 仅内部)。
|
|
416
|
+
* @param {{ relayUrls?: string[] | null, getRelayUrls?: () => string[] | null | undefined }} [options] 中继配置
|
|
417
|
+
* @returns {import('./index.mjs').DiscoveryProvider} Nostr discovery provider
|
|
227
418
|
*/
|
|
228
419
|
export function createNostrDiscoveryProvider(options = {}) {
|
|
229
|
-
|
|
420
|
+
/**
|
|
421
|
+
* @returns {string[]} 去重后的中继 URL 列表
|
|
422
|
+
*/
|
|
423
|
+
const resolveRelayUrls = () => {
|
|
424
|
+
if (typeof options.getRelayUrls === 'function') {
|
|
425
|
+
const urls = options.getRelayUrls()
|
|
426
|
+
return dedupeRelayUrls(urls == null ? DEFAULT_RELAY_URLS : urls)
|
|
427
|
+
}
|
|
428
|
+
if (options.relayUrls === undefined || options.relayUrls === null)
|
|
429
|
+
return dedupeRelayUrls(DEFAULT_RELAY_URLS)
|
|
430
|
+
return dedupeRelayUrls(options.relayUrls)
|
|
431
|
+
}
|
|
230
432
|
const secretKey = randomBytes(32)
|
|
433
|
+
/** @type {(() => void) | null} */
|
|
434
|
+
let stopNetworkAdvertSub = null
|
|
435
|
+
/** @type {Map<string, () => void>} */
|
|
436
|
+
const groupSubs = new Map()
|
|
437
|
+
/** @type {Map<string, () => void>} */
|
|
438
|
+
const nodeAdvertSubs = new Map()
|
|
439
|
+
/** @type {Map<string, () => void>} */
|
|
440
|
+
const nodeSignalSubs = new Map()
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* @returns {void}
|
|
444
|
+
*/
|
|
445
|
+
function ensureNetworkAdvertSubscription() {
|
|
446
|
+
if (stopNetworkAdvertSub) return
|
|
447
|
+
const rendezvousKey = networkRendezvousKey()
|
|
448
|
+
stopNetworkAdvertSub = subscribeNostrKind(resolveRelayUrls(), {
|
|
449
|
+
kind: NOSTR_ADVERT_KIND,
|
|
450
|
+
rendezvousKey,
|
|
451
|
+
tagX: 'advert',
|
|
452
|
+
addressable: true,
|
|
453
|
+
/**
|
|
454
|
+
* @param {Uint8Array} bytes 加密 advert 载荷
|
|
455
|
+
* @returns {Promise<void>}
|
|
456
|
+
*/
|
|
457
|
+
async onPayload(bytes) {
|
|
458
|
+
await acceptNostrAdvert(rendezvousKey, bytes)
|
|
459
|
+
},
|
|
460
|
+
})
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* @param {string} roomSecret 房间密钥
|
|
465
|
+
* @returns {void}
|
|
466
|
+
*/
|
|
467
|
+
function ensureGroupSubscription(roomSecret) {
|
|
468
|
+
const key = String(roomSecret || '')
|
|
469
|
+
if (!key || groupSubs.has(key)) return
|
|
470
|
+
const rendezvousKey = groupRendezvousKey(key)
|
|
471
|
+
groupSubs.set(key, subscribeNostrKind(resolveRelayUrls(), {
|
|
472
|
+
kind: NOSTR_ADVERT_KIND,
|
|
473
|
+
rendezvousKey,
|
|
474
|
+
tagX: 'advert',
|
|
475
|
+
addressable: true,
|
|
476
|
+
/**
|
|
477
|
+
* @param {Uint8Array} bytes 加密 advert 载荷
|
|
478
|
+
* @returns {Promise<void>}
|
|
479
|
+
*/
|
|
480
|
+
async onPayload(bytes) {
|
|
481
|
+
await acceptNostrAdvert(rendezvousKey, bytes, { roomSecret: key })
|
|
482
|
+
},
|
|
483
|
+
}))
|
|
484
|
+
}
|
|
485
|
+
|
|
231
486
|
return {
|
|
232
487
|
id: 'nostr',
|
|
233
488
|
priority: 100,
|
|
234
489
|
caps: { canDiscover: true, canSignal: true, canRelay: false },
|
|
235
490
|
/**
|
|
236
|
-
*
|
|
237
|
-
* @
|
|
238
|
-
|
|
239
|
-
|
|
491
|
+
* @param {{ limit?: number, roomSecret?: string }} [options] 扫描选项
|
|
492
|
+
* @returns {Promise<string[]>} 可见 nodeHash;带 roomSecret 时仅返回该群池
|
|
493
|
+
*/
|
|
494
|
+
async listVisibleNodeHashes(options = {}) {
|
|
495
|
+
const limit = Math.max(1, Number(options.limit) || 64)
|
|
496
|
+
if (options.roomSecret) {
|
|
497
|
+
ensureGroupSubscription(options.roomSecret)
|
|
498
|
+
return listNostrGroupVisibleNodeHashes(options.roomSecret).slice(0, limit)
|
|
499
|
+
}
|
|
500
|
+
ensureNetworkAdvertSubscription()
|
|
501
|
+
return listNostrVisibleNodeHashes().slice(0, limit)
|
|
502
|
+
},
|
|
503
|
+
/**
|
|
504
|
+
* 挂上对该节点的内部 advert 订阅(建链由 registry dialer / ensureLinkToNode 完成)。
|
|
505
|
+
* @param {string} nodeHash 目标
|
|
506
|
+
* @returns {Promise<boolean>} 是否已准备
|
|
507
|
+
*/
|
|
508
|
+
async connectToNode(nodeHash) {
|
|
509
|
+
const hash = normalizeHex64(nodeHash)
|
|
510
|
+
if (!isHex64(hash)) return false
|
|
511
|
+
if (!nodeAdvertSubs.has(hash)) {
|
|
512
|
+
const rendezvousKey = nodeRendezvousKey(hash)
|
|
513
|
+
nodeAdvertSubs.set(hash, subscribeNostrKind(resolveRelayUrls(), {
|
|
514
|
+
kind: NOSTR_ADVERT_KIND,
|
|
515
|
+
rendezvousKey,
|
|
516
|
+
tagX: 'advert',
|
|
517
|
+
addressable: true,
|
|
518
|
+
/**
|
|
519
|
+
* @param {Uint8Array} bytes 加密 advert 载荷
|
|
520
|
+
* @returns {Promise<void>}
|
|
521
|
+
*/
|
|
522
|
+
async onPayload(bytes) {
|
|
523
|
+
await acceptNostrAdvert(rendezvousKey, bytes)
|
|
524
|
+
},
|
|
525
|
+
}))
|
|
526
|
+
}
|
|
527
|
+
return true
|
|
528
|
+
},
|
|
529
|
+
/**
|
|
530
|
+
* @param {() => Promise<object | null>} getBeacon 本机 advert body 工厂
|
|
531
|
+
* @returns {Promise<() => void>} 停止函数
|
|
240
532
|
*/
|
|
241
|
-
async
|
|
533
|
+
async startPresence(getBeacon) {
|
|
534
|
+
const rendezvousKey = networkRendezvousKey()
|
|
242
535
|
const abortController = new AbortController()
|
|
536
|
+
ensureNetworkAdvertSubscription()
|
|
243
537
|
/**
|
|
244
|
-
* 向中继发布当前 advert。
|
|
245
538
|
* @returns {Promise<void>}
|
|
246
539
|
*/
|
|
247
540
|
const publish = async () => {
|
|
248
541
|
if (abortController.signal.aborted) return
|
|
542
|
+
const beacon = await getBeacon?.()
|
|
543
|
+
if (!beacon?.nodeHash) return
|
|
544
|
+
const advertBody = beacon.advertBody || beacon.body || beacon
|
|
545
|
+
const bytes = encryptSignalPacket(rendezvousKey, { type: 'advert', body: advertBody })
|
|
249
546
|
const event = await signNostrEvent(
|
|
250
547
|
NOSTR_ADVERT_KIND,
|
|
251
|
-
[['t',
|
|
548
|
+
[['t', rendezvousKey], ['x', 'advert'], ['d', rendezvousKey]],
|
|
252
549
|
bytesToBase64(bytes),
|
|
253
550
|
secretKey,
|
|
254
551
|
)
|
|
255
|
-
await publishEvent(
|
|
552
|
+
await publishEvent(resolveRelayUrls(), event, abortController.signal)
|
|
553
|
+
nodeDebug('p2p:nostr presence published', { self: shortHash(beacon.nodeHash) })
|
|
256
554
|
}
|
|
257
|
-
void publish().catch(
|
|
258
|
-
|
|
555
|
+
void publish().catch(error => {
|
|
556
|
+
nodeDebug('p2p:nostr presence publish fail', { err: String(error?.message || error) })
|
|
557
|
+
})
|
|
558
|
+
const timer = setInterval(() => {
|
|
559
|
+
void publish().catch(error => {
|
|
560
|
+
nodeDebug('p2p:nostr presence publish fail', { err: String(error?.message || error) })
|
|
561
|
+
})
|
|
562
|
+
}, 5 * 60_000)
|
|
563
|
+
timer.unref?.()
|
|
259
564
|
return () => {
|
|
260
565
|
abortController.abort()
|
|
261
566
|
clearInterval(timer)
|
|
262
567
|
}
|
|
263
568
|
},
|
|
264
569
|
/**
|
|
265
|
-
*
|
|
266
|
-
* @param {
|
|
267
|
-
* @param {Function} onAdvert advert 回调
|
|
268
|
-
* @returns {Promise<() => void>} 取消订阅函数
|
|
269
|
-
*/
|
|
270
|
-
async subscribe(topic, onAdvert) {
|
|
271
|
-
return subscribeNostrKind(relayUrls, {
|
|
272
|
-
kind: NOSTR_ADVERT_KIND,
|
|
273
|
-
topic,
|
|
274
|
-
tagX: 'advert',
|
|
275
|
-
onPayload: onAdvert,
|
|
276
|
-
})
|
|
277
|
-
},
|
|
278
|
-
/**
|
|
279
|
-
* 向中继发布 signal 事件(按需发送,仍等待至少一路成功)。
|
|
280
|
-
* @param {string} topic 信令 topic
|
|
281
|
-
* @param {string} to 目标节点标识
|
|
282
|
-
* @param {Uint8Array} bytes 信令载荷
|
|
570
|
+
* @param {string} toNodeHash 目标 nodeHash
|
|
571
|
+
* @param {Uint8Array} bytes 加密信令
|
|
283
572
|
* @returns {Promise<void>}
|
|
284
573
|
*/
|
|
285
|
-
async
|
|
574
|
+
async sendNodeSignal(toNodeHash, bytes) {
|
|
575
|
+
const hash = normalizeHex64(toNodeHash)
|
|
576
|
+
if (!isHex64(hash)) throw new Error('nostr: invalid nodeHash')
|
|
577
|
+
const rendezvousKey = nodeRendezvousKey(hash)
|
|
286
578
|
const event = await signNostrEvent(
|
|
287
579
|
NOSTR_SIGNAL_KIND,
|
|
288
|
-
[['t',
|
|
580
|
+
[['t', rendezvousKey], ['x', 'signal'], ['p', hash]],
|
|
289
581
|
bytesToBase64(bytes),
|
|
290
582
|
secretKey,
|
|
291
583
|
)
|
|
292
|
-
await publishEvent(
|
|
584
|
+
await publishEvent(resolveRelayUrls(), event)
|
|
293
585
|
},
|
|
294
586
|
/**
|
|
295
|
-
*
|
|
296
|
-
* @param {
|
|
297
|
-
* @
|
|
298
|
-
* @returns {Promise<() => void>} 取消订阅函数
|
|
587
|
+
* @param {string} localNodeHash 本机 nodeHash
|
|
588
|
+
* @param {(bytes: Uint8Array) => void} onSignal 信令回调
|
|
589
|
+
* @returns {Promise<() => void>} 取消函数
|
|
299
590
|
*/
|
|
300
|
-
async
|
|
301
|
-
|
|
591
|
+
async listenNodeSignals(localNodeHash, onSignal) {
|
|
592
|
+
const hash = normalizeHex64(localNodeHash)
|
|
593
|
+
if (!isHex64(hash)) throw new Error('nostr: invalid nodeHash')
|
|
594
|
+
const rendezvousKey = nodeRendezvousKey(hash)
|
|
595
|
+
const existing = nodeSignalSubs.get(hash)
|
|
596
|
+
if (existing) existing()
|
|
597
|
+
nodeDebug('p2p:nostr signal listen', { self: shortHash(hash), relays: resolveRelayUrls().length })
|
|
598
|
+
const stop = subscribeNostrKind(resolveRelayUrls(), {
|
|
302
599
|
kind: NOSTR_SIGNAL_KIND,
|
|
303
|
-
|
|
600
|
+
rendezvousKey,
|
|
304
601
|
tagX: 'signal',
|
|
305
602
|
onPayload: onSignal,
|
|
306
603
|
})
|
|
604
|
+
nodeSignalSubs.set(hash, stop)
|
|
605
|
+
return () => {
|
|
606
|
+
stop()
|
|
607
|
+
nodeSignalSubs.delete(hash)
|
|
608
|
+
}
|
|
609
|
+
},
|
|
610
|
+
/**
|
|
611
|
+
* @param {string} nodeHash 目标 nodeHash
|
|
612
|
+
* @param {(bytes: Uint8Array, meta: object) => void} onAdvert advert 回调
|
|
613
|
+
* @returns {Promise<() => void>} 取消函数
|
|
614
|
+
*/
|
|
615
|
+
async watchNodeAdvert(nodeHash, onAdvert) {
|
|
616
|
+
const hash = normalizeHex64(nodeHash)
|
|
617
|
+
if (!isHex64(hash)) throw new Error('nostr: invalid nodeHash')
|
|
618
|
+
const rendezvousKey = nodeRendezvousKey(hash)
|
|
619
|
+
const stop = subscribeNostrKind(resolveRelayUrls(), {
|
|
620
|
+
kind: NOSTR_ADVERT_KIND,
|
|
621
|
+
rendezvousKey,
|
|
622
|
+
tagX: 'advert',
|
|
623
|
+
addressable: true,
|
|
624
|
+
/**
|
|
625
|
+
* @param {Uint8Array} bytes 加密 advert 载荷
|
|
626
|
+
* @param {object} meta relay 元数据
|
|
627
|
+
* @returns {void}
|
|
628
|
+
*/
|
|
629
|
+
onPayload: (bytes, meta) => onAdvert(bytes, meta),
|
|
630
|
+
})
|
|
631
|
+
return stop
|
|
632
|
+
},
|
|
633
|
+
/**
|
|
634
|
+
* @param {string} roomSecret 房间密钥
|
|
635
|
+
* @param {() => Promise<object | null>} getBeacon advert 工厂
|
|
636
|
+
* @returns {Promise<() => void>} 停止群 presence 广播
|
|
637
|
+
*/
|
|
638
|
+
async startGroupPresence(roomSecret, getBeacon) {
|
|
639
|
+
const key = String(roomSecret || '')
|
|
640
|
+
const rendezvousKey = groupRendezvousKey(key)
|
|
641
|
+
const abortController = new AbortController()
|
|
642
|
+
ensureGroupSubscription(key)
|
|
643
|
+
/**
|
|
644
|
+
* @returns {Promise<void>}
|
|
645
|
+
*/
|
|
646
|
+
const publish = async () => {
|
|
647
|
+
if (abortController.signal.aborted) return
|
|
648
|
+
const beacon = await getBeacon?.()
|
|
649
|
+
if (!beacon?.nodeHash) return
|
|
650
|
+
const advertBody = beacon.advertBody || beacon.body || beacon
|
|
651
|
+
const bytes = encryptSignalPacket(rendezvousKey, { type: 'advert', body: advertBody })
|
|
652
|
+
const event = await signNostrEvent(
|
|
653
|
+
NOSTR_ADVERT_KIND,
|
|
654
|
+
[['t', rendezvousKey], ['x', 'advert'], ['d', rendezvousKey]],
|
|
655
|
+
bytesToBase64(bytes),
|
|
656
|
+
secretKey,
|
|
657
|
+
)
|
|
658
|
+
await publishEvent(resolveRelayUrls(), event, abortController.signal)
|
|
659
|
+
}
|
|
660
|
+
void publish().catch(() => { })
|
|
661
|
+
const timer = setInterval(() => { void publish().catch(() => { }) }, 5 * 60_000)
|
|
662
|
+
timer.unref?.()
|
|
663
|
+
return () => {
|
|
664
|
+
abortController.abort()
|
|
665
|
+
clearInterval(timer)
|
|
666
|
+
}
|
|
667
|
+
},
|
|
668
|
+
/**
|
|
669
|
+
* @param {string} roomSecret 房间密钥
|
|
670
|
+
* @param {(bytes: Uint8Array, meta: object) => void} onAdvert 回调
|
|
671
|
+
* @returns {Promise<() => void>} 取消群 advert 监听
|
|
672
|
+
*/
|
|
673
|
+
async watchGroupAdverts(roomSecret, onAdvert) {
|
|
674
|
+
const key = String(roomSecret || '')
|
|
675
|
+
const rendezvousKey = groupRendezvousKey(key)
|
|
676
|
+
ensureGroupSubscription(key)
|
|
677
|
+
return subscribeNostrKind(resolveRelayUrls(), {
|
|
678
|
+
kind: NOSTR_ADVERT_KIND,
|
|
679
|
+
rendezvousKey,
|
|
680
|
+
tagX: 'advert',
|
|
681
|
+
addressable: true,
|
|
682
|
+
/**
|
|
683
|
+
* @param {Uint8Array} bytes 加密 advert 载荷
|
|
684
|
+
* @param {object} meta relay 元数据
|
|
685
|
+
* @returns {void}
|
|
686
|
+
*/
|
|
687
|
+
onPayload: (bytes, meta) => onAdvert(bytes, meta),
|
|
688
|
+
})
|
|
689
|
+
},
|
|
690
|
+
/**
|
|
691
|
+
* 供 advert 解析路径写入可见 hash。
|
|
692
|
+
* @param {string} nodeHash 节点 hash
|
|
693
|
+
* @param {{ roomSecret?: string }} [options] 带 roomSecret 时写入群池
|
|
694
|
+
* @returns {void}
|
|
695
|
+
*/
|
|
696
|
+
noteVisibleNode(nodeHash, options = {}) {
|
|
697
|
+
if (options.roomSecret) noteNostrGroupVisibleNode(options.roomSecret, nodeHash)
|
|
698
|
+
else noteNostrVisibleNode(nodeHash)
|
|
699
|
+
},
|
|
700
|
+
/**
|
|
701
|
+
* 停止全部内部订阅(reload / unregister 时调用)。
|
|
702
|
+
* @returns {void}
|
|
703
|
+
*/
|
|
704
|
+
dispose() {
|
|
705
|
+
stopNetworkAdvertSub?.()
|
|
706
|
+
stopNetworkAdvertSub = null
|
|
707
|
+
for (const stop of groupSubs.values())
|
|
708
|
+
try { stop() } catch { /* ignore */ }
|
|
709
|
+
groupSubs.clear()
|
|
710
|
+
for (const stop of nodeAdvertSubs.values())
|
|
711
|
+
try { stop() } catch { /* ignore */ }
|
|
712
|
+
nodeAdvertSubs.clear()
|
|
713
|
+
for (const stop of nodeSignalSubs.values())
|
|
714
|
+
try { stop() } catch { /* ignore */ }
|
|
715
|
+
nodeSignalSubs.clear()
|
|
307
716
|
},
|
|
308
717
|
}
|
|
309
718
|
}
|