@steve02081504/fount-p2p 0.0.11 → 0.0.13
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 +42 -11
- package/core/bytes_codec.mjs +65 -10
- package/core/composite_key.mjs +5 -5
- package/core/tcp_port.mjs +1 -1
- package/crypto/checkpoint_sign.mjs +2 -2
- package/crypto/crypto.mjs +3 -3
- package/crypto/key.mjs +12 -12
- package/dag/canonicalize_row.mjs +3 -3
- package/dag/index.mjs +2 -3
- package/dag/storage.mjs +36 -36
- package/discovery/advert_peer_hints.mjs +1 -1
- package/discovery/adverts.mjs +109 -0
- package/discovery/bt/index.mjs +154 -113
- package/discovery/bt/probe_child.mjs +2 -1
- package/discovery/bt/runtime.mjs +5 -12
- package/discovery/index.mjs +267 -62
- package/discovery/internal/signal_crypto.mjs +109 -0
- package/discovery/lan.mjs +228 -0
- package/discovery/nostr.mjs +430 -141
- package/federation/chunk_fetch_pending.mjs +14 -18
- package/federation/dag_order_cache.mjs +1 -1
- package/federation/entity_key_chain.mjs +3 -4
- package/federation/manifest_fetch_pending.mjs +1 -3
- package/federation/topo_order_memo.mjs +11 -4
- package/files/assemble.mjs +14 -14
- package/files/assemble_stream.mjs +6 -6
- package/files/chunk_fetch.mjs +2 -2
- package/files/chunk_responder.mjs +29 -20
- package/files/evfs.mjs +31 -30
- 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/channel_mux.mjs +10 -10
- package/link/frame.mjs +76 -131
- package/link/handshake.mjs +19 -20
- package/link/pipe.mjs +53 -79
- package/link/providers/ble_gatt.mjs +19 -31
- package/link/providers/lan_tcp.mjs +17 -32
- package/link/providers/link_id_pipe.mjs +46 -0
- package/link/providers/webrtc.mjs +42 -51
- package/link/rtc.mjs +24 -12
- package/mailbox/consumer_registry.mjs +2 -2
- package/mailbox/deliver_or_store.mjs +6 -6
- package/mailbox/wire.mjs +28 -24
- package/node/entity_store.mjs +6 -6
- package/node/identity.mjs +4 -4
- package/node/instance.mjs +46 -27
- package/node/local_data_revision.mjs +26 -0
- package/node/log.mjs +56 -0
- package/node/network.mjs +46 -14
- 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 +35 -14
- package/package.json +13 -2
- package/rooms/scoped_link.mjs +17 -182
- package/schemas/discovery.mjs +1 -2
- package/schemas/federation_pull.mjs +2 -2
- package/schemas/part_query.mjs +1 -1
- package/schemas/remote_event.mjs +1 -1
- package/transport/advert_ingest.mjs +17 -0
- package/transport/group_link_set.mjs +165 -134
- package/transport/ice_servers.mjs +2 -2
- package/transport/link_registry.mjs +283 -124
- package/transport/mesh_keepalive.mjs +217 -0
- package/transport/node_scope.mjs +289 -0
- package/transport/offer_answer.mjs +48 -47
- package/transport/peer_pool.mjs +169 -78
- package/transport/remote_user_room.mjs +18 -23
- package/transport/rtc_connection_budget.mjs +18 -4
- package/transport/{rtc_mdns_filter.mjs → rtc_ice_local_hostname.mjs} +13 -13
- package/transport/runtime_bootstrap.mjs +172 -104
- package/transport/tunables.json +11 -0
- package/transport/tunables.mjs +18 -0
- package/transport/user_room.mjs +93 -161
- 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 +1 -4
- package/utils/async_mutex.mjs +4 -4
- package/utils/emit_safe.mjs +11 -0
- package/utils/json_io.mjs +12 -12
- package/utils/map_pool.mjs +5 -5
- package/utils/shuffle.mjs +13 -0
- package/utils/ttl_map.mjs +29 -4
- package/wire/ingress.mjs +1 -1
- package/wire/part_ingress.mjs +38 -36
- package/wire/part_invoke.mjs +4 -4
- package/wire/part_query.mjs +28 -23
- package/wire/part_query.tunables.json +6 -1
- package/wire/part_query_cache.mjs +1 -1
- package/wire/volatile_signature.mjs +1 -1
- package/discovery/mdns.mjs +0 -191
- package/transport/signal_crypto.mjs +0 -82
package/discovery/nostr.mjs
CHANGED
|
@@ -3,7 +3,18 @@ import { randomBytes } from 'node:crypto'
|
|
|
3
3
|
import { schnorr } from '@noble/curves/secp256k1.js'
|
|
4
4
|
import WebSocket from 'ws'
|
|
5
5
|
|
|
6
|
+
import { base64ToBytes, hexToBytes, bytesToBase64, bytesToHex } from '../core/bytes_codec.mjs'
|
|
7
|
+
import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
|
|
6
8
|
import { sha256Hex } from '../crypto/crypto.mjs'
|
|
9
|
+
import { nodeDebug, shortHash } from '../node/log.mjs'
|
|
10
|
+
|
|
11
|
+
import { ingestEncryptedAdvert } from './adverts.mjs'
|
|
12
|
+
import {
|
|
13
|
+
encryptSignalPacket,
|
|
14
|
+
groupRendezvousKey,
|
|
15
|
+
networkRendezvousKey,
|
|
16
|
+
nodeRendezvousKey,
|
|
17
|
+
} from './internal/signal_crypto.mjs'
|
|
7
18
|
|
|
8
19
|
/** 默认 Nostr 中继 URL 列表。 */
|
|
9
20
|
export const DEFAULT_RELAY_URLS = [
|
|
@@ -22,9 +33,119 @@ export const NOSTR_ADVERT_KIND = 27235
|
|
|
22
33
|
/** Nostr signal 事件 kind。 */
|
|
23
34
|
export const NOSTR_SIGNAL_KIND = 27236
|
|
24
35
|
|
|
36
|
+
const ADVERT_TTL_MS = 10 * 60_000
|
|
37
|
+
|
|
38
|
+
/** @type {Map<string, number>} 网络域 nodeHash → lastSeenAt */
|
|
39
|
+
const visibleByHash = new Map()
|
|
40
|
+
/** @type {Map<string, Map<string, number>>} roomSecret → (nodeHash → lastSeenAt) */
|
|
41
|
+
const visibleByGroup = new Map()
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {Map<string, number>} pool 可见池
|
|
45
|
+
* @param {number} now 当前时间
|
|
46
|
+
* @param {number} ttlMs TTL
|
|
47
|
+
* @returns {string[]} 未过期 nodeHash
|
|
48
|
+
*/
|
|
49
|
+
function listPoolHashes(pool, now, ttlMs) {
|
|
50
|
+
/** @type {string[]} */
|
|
51
|
+
const out = []
|
|
52
|
+
for (const [hash, seenAt] of pool)
|
|
53
|
+
if (now - seenAt <= ttlMs) out.push(hash)
|
|
54
|
+
else pool.delete(hash)
|
|
55
|
+
|
|
56
|
+
return out
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 写入网络域可见池(非群)。
|
|
61
|
+
* @param {string} nodeHash 节点 hash
|
|
62
|
+
* @param {number} [now=Date.now()] 当前时间
|
|
63
|
+
* @returns {void}
|
|
64
|
+
*/
|
|
65
|
+
export function noteNostrVisibleNode(nodeHash, now = Date.now()) {
|
|
66
|
+
const hash = normalizeHex64(nodeHash)
|
|
67
|
+
if (!isHex64(hash)) return
|
|
68
|
+
visibleByHash.set(hash, now)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 写入群域可见池(与网络域隔离)。
|
|
73
|
+
* @param {string} roomSecret 房间密钥
|
|
74
|
+
* @param {string} nodeHash 节点 hash
|
|
75
|
+
* @param {number} [now=Date.now()] 当前时间
|
|
76
|
+
* @returns {void}
|
|
77
|
+
*/
|
|
78
|
+
export function noteNostrGroupVisibleNode(roomSecret, nodeHash, now = Date.now()) {
|
|
79
|
+
const key = String(roomSecret || '')
|
|
80
|
+
const hash = normalizeHex64(nodeHash)
|
|
81
|
+
if (!key || !isHex64(hash)) return
|
|
82
|
+
let pool = visibleByGroup.get(key)
|
|
83
|
+
if (!pool) {
|
|
84
|
+
pool = new Map()
|
|
85
|
+
visibleByGroup.set(key, pool)
|
|
86
|
+
}
|
|
87
|
+
pool.set(hash, now)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {number} [now=Date.now()] 当前时间
|
|
92
|
+
* @param {number} [ttlMs=ADVERT_TTL_MS] TTL
|
|
93
|
+
* @returns {string[]} 网络域可见 nodeHash
|
|
94
|
+
*/
|
|
95
|
+
export function listNostrVisibleNodeHashes(now = Date.now(), ttlMs = ADVERT_TTL_MS) {
|
|
96
|
+
return listPoolHashes(visibleByHash, now, ttlMs)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {string} roomSecret 房间密钥
|
|
101
|
+
* @param {number} [now=Date.now()] 当前时间
|
|
102
|
+
* @param {number} [ttlMs=ADVERT_TTL_MS] TTL
|
|
103
|
+
* @returns {string[]} 该群可见 nodeHash
|
|
104
|
+
*/
|
|
105
|
+
export function listNostrGroupVisibleNodeHashes(roomSecret, now = Date.now(), ttlMs = ADVERT_TTL_MS) {
|
|
106
|
+
const key = String(roomSecret || '')
|
|
107
|
+
const pool = visibleByGroup.get(key)
|
|
108
|
+
if (!pool) return []
|
|
109
|
+
const out = listPoolHashes(pool, now, ttlMs)
|
|
110
|
+
if (!pool.size) visibleByGroup.delete(key)
|
|
111
|
+
return out
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 解密并验签后写入 Nostr 可见池;伪造 body.nodeHash 无效。
|
|
116
|
+
* @param {string} rendezvousKey rendezvous 键
|
|
117
|
+
* @param {Uint8Array} bytes 加密 advert
|
|
118
|
+
* @param {{ roomSecret?: string }} [options] 带 roomSecret 时写入群池
|
|
119
|
+
* @returns {Promise<string | null>} 验签通过的 nodeHash
|
|
120
|
+
*/
|
|
121
|
+
export async function acceptNostrAdvert(rendezvousKey, bytes, options = {}) {
|
|
122
|
+
const ingested = await ingestEncryptedAdvert(rendezvousKey, bytes)
|
|
123
|
+
if (!ingested) return null
|
|
124
|
+
const hash = ingested.verifiedNodeHash
|
|
125
|
+
const roomSecret = options.roomSecret
|
|
126
|
+
let firstSeen = true
|
|
127
|
+
if (roomSecret) {
|
|
128
|
+
const key = String(roomSecret || '')
|
|
129
|
+
firstSeen = !visibleByGroup.get(key)?.has(hash)
|
|
130
|
+
noteNostrGroupVisibleNode(key, hash)
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
firstSeen = !visibleByHash.has(hash)
|
|
134
|
+
noteNostrVisibleNode(hash)
|
|
135
|
+
}
|
|
136
|
+
if (firstSeen)
|
|
137
|
+
nodeDebug('p2p:nostr peer visible', { peer: shortHash(hash), group: !!roomSecret })
|
|
138
|
+
return hash
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** @returns {void} 测试用 */
|
|
142
|
+
export function clearNostrVisibleNodes() {
|
|
143
|
+
visibleByHash.clear()
|
|
144
|
+
visibleByGroup.clear()
|
|
145
|
+
}
|
|
146
|
+
|
|
25
147
|
/**
|
|
26
148
|
* 关掉 WebSocket:已连上则先 close,grace 内未 CLOSED 再 terminate。
|
|
27
|
-
* 连接中直接 terminate(无握手可优雅收尾)。
|
|
28
149
|
* @param {import('ws').WebSocket} ws 连接
|
|
29
150
|
* @returns {void}
|
|
30
151
|
*/
|
|
@@ -38,7 +159,6 @@ function dropWebSocket(ws) {
|
|
|
38
159
|
if (ws.readyState !== WebSocket.CLOSED)
|
|
39
160
|
try { ws.terminate() } catch { /* ignore */ }
|
|
40
161
|
}, NOSTR_CLOSE_GRACE_MS)
|
|
41
|
-
// Grace is polite to the peer; must not pin the process after shutdown.
|
|
42
162
|
timer.unref()
|
|
43
163
|
ws.once('close', () => clearTimeout(timer))
|
|
44
164
|
try {
|
|
@@ -51,75 +171,26 @@ function dropWebSocket(ws) {
|
|
|
51
171
|
}
|
|
52
172
|
|
|
53
173
|
/**
|
|
54
|
-
* 去重并清洗中继 URL 列表。
|
|
55
174
|
* @param {string[] | undefined | null} urls 原始列表
|
|
56
175
|
* @returns {string[]} 清洗后的列表
|
|
57
176
|
*/
|
|
58
177
|
function dedupeRelayUrls(urls) {
|
|
59
178
|
const seen = new Set()
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const trimmed = String(url || '').trim()
|
|
64
|
-
if (!trimmed || seen.has(trimmed)) continue
|
|
65
|
-
seen.add(trimmed)
|
|
66
|
-
out.push(trimmed)
|
|
67
|
-
}
|
|
68
|
-
return out
|
|
179
|
+
return (urls || [])
|
|
180
|
+
.map(url => url.trim())
|
|
181
|
+
.filter(trimmed => trimmed && !seen.has(trimmed) && (seen.add(trimmed), true))
|
|
69
182
|
}
|
|
70
183
|
|
|
71
184
|
/**
|
|
72
|
-
* 合并默认与用户配置的中继 URL(去重)。
|
|
73
185
|
* @param {string[] | undefined | null} userRelayUrls 用户自定义中继列表
|
|
74
186
|
* @returns {string[]} 合并后的中继 URL 列表
|
|
75
187
|
*/
|
|
76
188
|
export function mergeSignalingRelayUrls(userRelayUrls) {
|
|
77
|
-
const merged = dedupeRelayUrls([...DEFAULT_RELAY_URLS, ...
|
|
189
|
+
const merged = dedupeRelayUrls([...DEFAULT_RELAY_URLS, ...userRelayUrls || []])
|
|
78
190
|
return merged.length ? merged : [...DEFAULT_RELAY_URLS]
|
|
79
191
|
}
|
|
80
192
|
|
|
81
193
|
/**
|
|
82
|
-
* 字节数组转十六进制字符串。
|
|
83
|
-
* @param {Uint8Array} bytes 输入字节
|
|
84
|
-
* @returns {string} 小写 hex 字符串
|
|
85
|
-
*/
|
|
86
|
-
function bytesToHex(bytes) {
|
|
87
|
-
return [...bytes].map(byte => byte.toString(16).padStart(2, '0')).join('')
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* 十六进制字符串转字节数组。
|
|
92
|
-
* @param {string} hex 输入 hex 字符串
|
|
93
|
-
* @returns {Uint8Array} 解码后的字节
|
|
94
|
-
*/
|
|
95
|
-
function hexToBytes(hex) {
|
|
96
|
-
const normalized = String(hex || '').trim().toLowerCase()
|
|
97
|
-
const out = new Uint8Array(Math.floor(normalized.length / 2))
|
|
98
|
-
for (let index = 0; index < out.length; index++)
|
|
99
|
-
out[index] = parseInt(normalized.slice(index * 2, index * 2 + 2), 16)
|
|
100
|
-
return out
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* 字节数组转 base64 字符串。
|
|
105
|
-
* @param {Uint8Array} bytes 输入字节
|
|
106
|
-
* @returns {string} base64 编码
|
|
107
|
-
*/
|
|
108
|
-
function bytesToBase64(bytes) {
|
|
109
|
-
return btoa(String.fromCharCode(...bytes))
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* base64 字符串转字节数组。
|
|
114
|
-
* @param {string} base64 输入 base64 字符串
|
|
115
|
-
* @returns {Uint8Array} 解码后的字节
|
|
116
|
-
*/
|
|
117
|
-
function base64ToBytes(base64) {
|
|
118
|
-
return Uint8Array.from(atob(base64).split('').map(ch => ch.charCodeAt(0)))
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* 签名 Nostr 事件。
|
|
123
194
|
* @param {number} kind 事件 kind
|
|
124
195
|
* @param {string[][]} tags 事件标签
|
|
125
196
|
* @param {string} content 事件内容
|
|
@@ -136,7 +207,6 @@ async function signNostrEvent(kind, tags, content, secretKey) {
|
|
|
136
207
|
}
|
|
137
208
|
|
|
138
209
|
/**
|
|
139
|
-
* 连接 Nostr 中继 WebSocket(`ws` 包,支持 terminate)。
|
|
140
210
|
* @param {string} relayUrl 中继 URL
|
|
141
211
|
* @param {number} [timeoutMs] 超时毫秒
|
|
142
212
|
* @param {AbortSignal} [signal] 取消信号
|
|
@@ -183,13 +253,13 @@ function connectRelay(relayUrl, timeoutMs = NOSTR_CONNECT_TIMEOUT_MS, signal) {
|
|
|
183
253
|
}
|
|
184
254
|
|
|
185
255
|
/**
|
|
186
|
-
* 向多个中继并行发布 Nostr 事件(任一成功即可)。
|
|
187
256
|
* @param {string[]} relayUrls 中继 URL 列表
|
|
188
257
|
* @param {object} event 待发布事件
|
|
189
258
|
* @param {AbortSignal} [signal] 取消信号
|
|
190
259
|
* @returns {Promise<void>}
|
|
191
260
|
*/
|
|
192
261
|
async function publishEvent(relayUrls, event, signal) {
|
|
262
|
+
if (!relayUrls.length) throw new Error('nostr: no relay')
|
|
193
263
|
let published = false
|
|
194
264
|
let lastError = null
|
|
195
265
|
await Promise.allSettled(relayUrls.map(async relayUrl => {
|
|
@@ -212,7 +282,6 @@ async function publishEvent(relayUrls, event, signal) {
|
|
|
212
282
|
}
|
|
213
283
|
|
|
214
284
|
/**
|
|
215
|
-
* 并行渐进连接中继:立刻返回,连上后回调;abort 后 stop 接入并 drop 已开 socket。
|
|
216
285
|
* @param {string[]} relayUrls 中继列表
|
|
217
286
|
* @param {(ws: import('ws').WebSocket, relayUrl: string) => void} onOpen 连上回调
|
|
218
287
|
* @param {AbortSignal} signal 取消信号
|
|
@@ -227,129 +296,349 @@ function connectRelaysProgressive(relayUrls, onOpen, signal, sockets) {
|
|
|
227
296
|
return
|
|
228
297
|
}
|
|
229
298
|
sockets.push(ws)
|
|
299
|
+
nodeDebug('p2p:nostr relay up', { url: relayUrl })
|
|
230
300
|
onOpen(ws, relayUrl)
|
|
231
|
-
}).catch(
|
|
301
|
+
}).catch(error => {
|
|
302
|
+
nodeDebug('p2p:nostr relay fail', {
|
|
303
|
+
url: relayUrl,
|
|
304
|
+
err: String(error?.message || error),
|
|
305
|
+
})
|
|
306
|
+
})
|
|
232
307
|
}
|
|
233
308
|
|
|
234
309
|
/**
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
|
|
240
|
-
|
|
310
|
+
* 内部:按 rendezvous 键订阅 Nostr kind(topic 不导出)。
|
|
311
|
+
* @param {string[]} relayUrls 中继 URL 列表
|
|
312
|
+
* @param {{ kind: number, rendezvousKey: string, tagX: string, onPayload: (bytes: Uint8Array, meta: { relayUrl: string, event: object }) => void | Promise<void> }} options 订阅选项
|
|
313
|
+
* @returns {() => void} 取消订阅
|
|
314
|
+
*/
|
|
315
|
+
function subscribeNostrKind(relayUrls, options) {
|
|
316
|
+
const { kind, rendezvousKey, tagX, onPayload } = options
|
|
317
|
+
const abortController = new AbortController()
|
|
318
|
+
/** @type {import('ws').WebSocket[]} */
|
|
319
|
+
const sockets = []
|
|
320
|
+
const subscriptionId = randomBytes(8).toString('hex')
|
|
321
|
+
connectRelaysProgressive(relayUrls, (ws, relayUrl) => {
|
|
322
|
+
ws.on('message', data => {
|
|
323
|
+
if (abortController.signal.aborted) return
|
|
324
|
+
let parsed
|
|
325
|
+
try { parsed = JSON.parse(String(data)) } catch { return }
|
|
326
|
+
if (parsed?.[0] !== 'EVENT') return
|
|
327
|
+
const nostrEvent = parsed[2]
|
|
328
|
+
if (nostrEvent?.kind !== kind) return
|
|
329
|
+
try {
|
|
330
|
+
const result = onPayload(base64ToBytes(nostrEvent.content), { relayUrl, event: nostrEvent })
|
|
331
|
+
if (result?.then) void result.catch(() => { })
|
|
332
|
+
}
|
|
333
|
+
catch { /* ignore */ }
|
|
334
|
+
})
|
|
335
|
+
ws.send(JSON.stringify(['REQ', subscriptionId, { kinds: [kind], '#t': [rendezvousKey], '#x': [tagX] }]))
|
|
336
|
+
}, abortController.signal, sockets)
|
|
337
|
+
return () => {
|
|
338
|
+
abortController.abort()
|
|
339
|
+
for (const ws of sockets) dropWebSocket(ws)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* 创建 Nostr discovery provider(list+connect;topic 仅内部)。
|
|
345
|
+
* @param {{ relayUrls?: string[] | null, getRelayUrls?: () => string[] | null | undefined }} [options] 中继配置
|
|
346
|
+
* @returns {import('./index.mjs').DiscoveryProvider} Nostr discovery provider
|
|
241
347
|
*/
|
|
242
348
|
export function createNostrDiscoveryProvider(options = {}) {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
349
|
+
/**
|
|
350
|
+
* @returns {string[]} 去重后的中继 URL 列表
|
|
351
|
+
*/
|
|
352
|
+
const resolveRelayUrls = () => {
|
|
353
|
+
if (typeof options.getRelayUrls === 'function') {
|
|
354
|
+
const urls = options.getRelayUrls()
|
|
355
|
+
return dedupeRelayUrls(urls == null ? DEFAULT_RELAY_URLS : urls)
|
|
356
|
+
}
|
|
357
|
+
if (options.relayUrls === undefined || options.relayUrls === null)
|
|
358
|
+
return dedupeRelayUrls(DEFAULT_RELAY_URLS)
|
|
359
|
+
return dedupeRelayUrls(options.relayUrls)
|
|
360
|
+
}
|
|
246
361
|
const secretKey = randomBytes(32)
|
|
362
|
+
/** @type {(() => void) | null} */
|
|
363
|
+
let stopNetworkAdvertSub = null
|
|
364
|
+
/** @type {Map<string, () => void>} */
|
|
365
|
+
const groupSubs = new Map()
|
|
366
|
+
/** @type {Map<string, () => void>} */
|
|
367
|
+
const nodeAdvertSubs = new Map()
|
|
368
|
+
/** @type {Map<string, () => void>} */
|
|
369
|
+
const nodeSignalSubs = new Map()
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* @returns {void}
|
|
373
|
+
*/
|
|
374
|
+
function ensureNetworkAdvertSubscription() {
|
|
375
|
+
if (stopNetworkAdvertSub) return
|
|
376
|
+
const rendezvousKey = networkRendezvousKey()
|
|
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
|
+
},
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* @param {string} roomSecret 房间密钥
|
|
393
|
+
* @returns {void}
|
|
394
|
+
*/
|
|
395
|
+
function ensureGroupSubscription(roomSecret) {
|
|
396
|
+
const key = String(roomSecret || '')
|
|
397
|
+
if (!key || groupSubs.has(key)) return
|
|
398
|
+
const rendezvousKey = groupRendezvousKey(key)
|
|
399
|
+
groupSubs.set(key, subscribeNostrKind(resolveRelayUrls(), {
|
|
400
|
+
kind: NOSTR_ADVERT_KIND,
|
|
401
|
+
rendezvousKey,
|
|
402
|
+
tagX: 'advert',
|
|
403
|
+
/**
|
|
404
|
+
* @param {Uint8Array} bytes 加密 advert 载荷
|
|
405
|
+
* @returns {Promise<void>}
|
|
406
|
+
*/
|
|
407
|
+
async onPayload(bytes) {
|
|
408
|
+
await acceptNostrAdvert(rendezvousKey, bytes, { roomSecret: key })
|
|
409
|
+
},
|
|
410
|
+
}))
|
|
411
|
+
}
|
|
412
|
+
|
|
247
413
|
return {
|
|
248
414
|
id: 'nostr',
|
|
249
415
|
priority: 100,
|
|
250
416
|
caps: { canDiscover: true, canSignal: true, canRelay: false },
|
|
251
417
|
/**
|
|
252
|
-
*
|
|
253
|
-
* @
|
|
254
|
-
* @param {Uint8Array} bytes advert 载荷
|
|
255
|
-
* @returns {Promise<() => void>} 取消广播函数
|
|
418
|
+
* @param {{ limit?: number, roomSecret?: string }} [options] 扫描选项
|
|
419
|
+
* @returns {Promise<string[]>} 可见 nodeHash;带 roomSecret 时仅返回该群池
|
|
256
420
|
*/
|
|
257
|
-
async
|
|
258
|
-
const
|
|
421
|
+
async listVisibleNodeHashes(options = {}) {
|
|
422
|
+
const limit = Math.max(1, Number(options.limit) || 64)
|
|
423
|
+
if (options.roomSecret) {
|
|
424
|
+
ensureGroupSubscription(options.roomSecret)
|
|
425
|
+
return listNostrGroupVisibleNodeHashes(options.roomSecret).slice(0, limit)
|
|
426
|
+
}
|
|
427
|
+
ensureNetworkAdvertSubscription()
|
|
428
|
+
return listNostrVisibleNodeHashes().slice(0, limit)
|
|
429
|
+
},
|
|
430
|
+
/**
|
|
431
|
+
* 挂上对该节点的内部 advert 订阅(建链由 registry dialer / ensureLinkToNode 完成)。
|
|
432
|
+
* @param {string} nodeHash 目标
|
|
433
|
+
* @returns {Promise<boolean>} 是否已准备
|
|
434
|
+
*/
|
|
435
|
+
async connectToNode(nodeHash) {
|
|
436
|
+
const hash = normalizeHex64(nodeHash)
|
|
437
|
+
if (!isHex64(hash)) return false
|
|
438
|
+
if (!nodeAdvertSubs.has(hash)) {
|
|
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
|
+
}
|
|
453
|
+
return true
|
|
454
|
+
},
|
|
455
|
+
/**
|
|
456
|
+
* @param {() => Promise<object | null>} getBeacon 本机 advert body 工厂
|
|
457
|
+
* @returns {Promise<() => void>} 停止函数
|
|
458
|
+
*/
|
|
459
|
+
async startPresence(getBeacon) {
|
|
460
|
+
const rendezvousKey = networkRendezvousKey()
|
|
461
|
+
const abortController = new AbortController()
|
|
462
|
+
ensureNetworkAdvertSubscription()
|
|
259
463
|
/**
|
|
260
|
-
* 向中继发布当前 advert。
|
|
261
464
|
* @returns {Promise<void>}
|
|
262
465
|
*/
|
|
263
466
|
const publish = async () => {
|
|
264
|
-
if (
|
|
467
|
+
if (abortController.signal.aborted) return
|
|
468
|
+
const beacon = await getBeacon?.()
|
|
469
|
+
if (!beacon?.nodeHash) return
|
|
470
|
+
noteNostrVisibleNode(beacon.nodeHash)
|
|
471
|
+
const advertBody = beacon.advertBody || beacon.body || beacon
|
|
472
|
+
const bytes = encryptSignalPacket(rendezvousKey, { type: 'advert', body: advertBody })
|
|
265
473
|
const event = await signNostrEvent(
|
|
266
474
|
NOSTR_ADVERT_KIND,
|
|
267
|
-
[['t',
|
|
475
|
+
[['t', rendezvousKey], ['x', 'advert']],
|
|
268
476
|
bytesToBase64(bytes),
|
|
269
477
|
secretKey,
|
|
270
478
|
)
|
|
271
|
-
await publishEvent(
|
|
272
|
-
|
|
273
|
-
void publish().catch(() => { })
|
|
274
|
-
const timer = setInterval(() => { void publish().catch(() => { }) }, 5 * 60_000)
|
|
275
|
-
return () => {
|
|
276
|
-
ac.abort()
|
|
277
|
-
clearInterval(timer)
|
|
479
|
+
await publishEvent(resolveRelayUrls(), event, abortController.signal)
|
|
480
|
+
nodeDebug('p2p:nostr presence published', { self: shortHash(beacon.nodeHash) })
|
|
278
481
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
*/
|
|
286
|
-
async subscribe(topic, onAdvert) {
|
|
287
|
-
const ac = new AbortController()
|
|
288
|
-
/** @type {import('ws').WebSocket[]} */
|
|
289
|
-
const sockets = []
|
|
290
|
-
const subscriptionId = randomBytes(8).toString('hex')
|
|
291
|
-
connectRelaysProgressive(relayUrls, (ws, relayUrl) => {
|
|
292
|
-
ws.on('message', data => {
|
|
293
|
-
if (ac.signal.aborted) return
|
|
294
|
-
let parsed
|
|
295
|
-
try { parsed = JSON.parse(String(data)) } catch { return }
|
|
296
|
-
if (!Array.isArray(parsed) || parsed[0] !== 'EVENT') return
|
|
297
|
-
const nostrEvent = parsed[2]
|
|
298
|
-
if (nostrEvent?.kind !== NOSTR_ADVERT_KIND) return
|
|
299
|
-
try { onAdvert(base64ToBytes(String(nostrEvent.content || '')), { relayUrl, event: nostrEvent }) }
|
|
300
|
-
catch { /* ignore */ }
|
|
482
|
+
void publish().catch(error => {
|
|
483
|
+
nodeDebug('p2p:nostr presence publish fail', { err: String(error?.message || error) })
|
|
484
|
+
})
|
|
485
|
+
const timer = setInterval(() => {
|
|
486
|
+
void publish().catch(error => {
|
|
487
|
+
nodeDebug('p2p:nostr presence publish fail', { err: String(error?.message || error) })
|
|
301
488
|
})
|
|
302
|
-
|
|
303
|
-
|
|
489
|
+
}, 5 * 60_000)
|
|
490
|
+
timer.unref?.()
|
|
304
491
|
return () => {
|
|
305
|
-
|
|
306
|
-
|
|
492
|
+
abortController.abort()
|
|
493
|
+
clearInterval(timer)
|
|
307
494
|
}
|
|
308
495
|
},
|
|
309
496
|
/**
|
|
310
|
-
*
|
|
311
|
-
* @param {
|
|
312
|
-
* @param {string} to 目标节点标识
|
|
313
|
-
* @param {Uint8Array} bytes 信令载荷
|
|
497
|
+
* @param {string} toNodeHash 目标 nodeHash
|
|
498
|
+
* @param {Uint8Array} bytes 加密信令
|
|
314
499
|
* @returns {Promise<void>}
|
|
315
500
|
*/
|
|
316
|
-
async
|
|
501
|
+
async sendNodeSignal(toNodeHash, bytes) {
|
|
502
|
+
const hash = normalizeHex64(toNodeHash)
|
|
503
|
+
if (!isHex64(hash)) throw new Error('nostr: invalid nodeHash')
|
|
504
|
+
const rendezvousKey = nodeRendezvousKey(hash)
|
|
317
505
|
const event = await signNostrEvent(
|
|
318
506
|
NOSTR_SIGNAL_KIND,
|
|
319
|
-
[['t',
|
|
507
|
+
[['t', rendezvousKey], ['x', 'signal'], ['p', hash]],
|
|
320
508
|
bytesToBase64(bytes),
|
|
321
509
|
secretKey,
|
|
322
510
|
)
|
|
323
|
-
await publishEvent(
|
|
511
|
+
await publishEvent(resolveRelayUrls(), event)
|
|
324
512
|
},
|
|
325
513
|
/**
|
|
326
|
-
*
|
|
327
|
-
* @param {
|
|
328
|
-
* @
|
|
329
|
-
* @returns {Promise<() => void>} 取消订阅函数
|
|
514
|
+
* @param {string} localNodeHash 本机 nodeHash
|
|
515
|
+
* @param {(bytes: Uint8Array) => void} onSignal 信令回调
|
|
516
|
+
* @returns {Promise<() => void>} 取消函数
|
|
330
517
|
*/
|
|
331
|
-
async
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
const
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
catch { /* ignore */ }
|
|
346
|
-
})
|
|
347
|
-
ws.send(JSON.stringify(['REQ', subscriptionId, { kinds: [NOSTR_SIGNAL_KIND], '#t': [topic], '#x': ['signal'] }]))
|
|
348
|
-
}, ac.signal, sockets)
|
|
518
|
+
async listenNodeSignals(localNodeHash, onSignal) {
|
|
519
|
+
const hash = normalizeHex64(localNodeHash)
|
|
520
|
+
if (!isHex64(hash)) throw new Error('nostr: invalid nodeHash')
|
|
521
|
+
const rendezvousKey = nodeRendezvousKey(hash)
|
|
522
|
+
const existing = nodeSignalSubs.get(hash)
|
|
523
|
+
if (existing) existing()
|
|
524
|
+
nodeDebug('p2p:nostr signal listen', { self: shortHash(hash), relays: resolveRelayUrls().length })
|
|
525
|
+
const stop = subscribeNostrKind(resolveRelayUrls(), {
|
|
526
|
+
kind: NOSTR_SIGNAL_KIND,
|
|
527
|
+
rendezvousKey,
|
|
528
|
+
tagX: 'signal',
|
|
529
|
+
onPayload: onSignal,
|
|
530
|
+
})
|
|
531
|
+
nodeSignalSubs.set(hash, stop)
|
|
349
532
|
return () => {
|
|
350
|
-
|
|
351
|
-
|
|
533
|
+
stop()
|
|
534
|
+
nodeSignalSubs.delete(hash)
|
|
535
|
+
}
|
|
536
|
+
},
|
|
537
|
+
/**
|
|
538
|
+
* @param {string} nodeHash 目标 nodeHash
|
|
539
|
+
* @param {(bytes: Uint8Array, meta: object) => void} onAdvert advert 回调
|
|
540
|
+
* @returns {Promise<() => void>} 取消函数
|
|
541
|
+
*/
|
|
542
|
+
async watchNodeAdvert(nodeHash, onAdvert) {
|
|
543
|
+
const hash = normalizeHex64(nodeHash)
|
|
544
|
+
if (!isHex64(hash)) throw new Error('nostr: invalid nodeHash')
|
|
545
|
+
const rendezvousKey = nodeRendezvousKey(hash)
|
|
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
|
|
558
|
+
},
|
|
559
|
+
/**
|
|
560
|
+
* @param {string} roomSecret 房间密钥
|
|
561
|
+
* @param {() => Promise<object | null>} getBeacon advert 工厂
|
|
562
|
+
* @returns {Promise<() => void>} 停止群 presence 广播
|
|
563
|
+
*/
|
|
564
|
+
async startGroupPresence(roomSecret, getBeacon) {
|
|
565
|
+
const key = String(roomSecret || '')
|
|
566
|
+
const rendezvousKey = groupRendezvousKey(key)
|
|
567
|
+
const abortController = new AbortController()
|
|
568
|
+
ensureGroupSubscription(key)
|
|
569
|
+
/**
|
|
570
|
+
* @returns {Promise<void>}
|
|
571
|
+
*/
|
|
572
|
+
const publish = async () => {
|
|
573
|
+
if (abortController.signal.aborted) return
|
|
574
|
+
const beacon = await getBeacon?.()
|
|
575
|
+
if (!beacon?.nodeHash) return
|
|
576
|
+
noteNostrGroupVisibleNode(key, beacon.nodeHash)
|
|
577
|
+
const advertBody = beacon.advertBody || beacon.body || beacon
|
|
578
|
+
const bytes = encryptSignalPacket(rendezvousKey, { type: 'advert', body: advertBody })
|
|
579
|
+
const event = await signNostrEvent(
|
|
580
|
+
NOSTR_ADVERT_KIND,
|
|
581
|
+
[['t', rendezvousKey], ['x', 'advert']],
|
|
582
|
+
bytesToBase64(bytes),
|
|
583
|
+
secretKey,
|
|
584
|
+
)
|
|
585
|
+
await publishEvent(resolveRelayUrls(), event, abortController.signal)
|
|
352
586
|
}
|
|
587
|
+
void publish().catch(() => { })
|
|
588
|
+
const timer = setInterval(() => { void publish().catch(() => { }) }, 5 * 60_000)
|
|
589
|
+
timer.unref?.()
|
|
590
|
+
return () => {
|
|
591
|
+
abortController.abort()
|
|
592
|
+
clearInterval(timer)
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
/**
|
|
596
|
+
* @param {string} roomSecret 房间密钥
|
|
597
|
+
* @param {(bytes: Uint8Array, meta: object) => void} onAdvert 回调
|
|
598
|
+
* @returns {Promise<() => void>} 取消群 advert 监听
|
|
599
|
+
*/
|
|
600
|
+
async watchGroupAdverts(roomSecret, onAdvert) {
|
|
601
|
+
const key = String(roomSecret || '')
|
|
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
|
+
})
|
|
615
|
+
},
|
|
616
|
+
/**
|
|
617
|
+
* 供 advert 解析路径写入可见 hash。
|
|
618
|
+
* @param {string} nodeHash 节点 hash
|
|
619
|
+
* @param {{ roomSecret?: string }} [options] 带 roomSecret 时写入群池
|
|
620
|
+
* @returns {void}
|
|
621
|
+
*/
|
|
622
|
+
noteVisibleNode(nodeHash, options = {}) {
|
|
623
|
+
if (options.roomSecret) noteNostrGroupVisibleNode(options.roomSecret, nodeHash)
|
|
624
|
+
else noteNostrVisibleNode(nodeHash)
|
|
625
|
+
},
|
|
626
|
+
/**
|
|
627
|
+
* 停止全部内部订阅(reload / unregister 时调用)。
|
|
628
|
+
* @returns {void}
|
|
629
|
+
*/
|
|
630
|
+
dispose() {
|
|
631
|
+
stopNetworkAdvertSub?.()
|
|
632
|
+
stopNetworkAdvertSub = null
|
|
633
|
+
for (const stop of groupSubs.values())
|
|
634
|
+
try { stop() } catch { /* ignore */ }
|
|
635
|
+
groupSubs.clear()
|
|
636
|
+
for (const stop of nodeAdvertSubs.values())
|
|
637
|
+
try { stop() } catch { /* ignore */ }
|
|
638
|
+
nodeAdvertSubs.clear()
|
|
639
|
+
for (const stop of nodeSignalSubs.values())
|
|
640
|
+
try { stop() } catch { /* ignore */ }
|
|
641
|
+
nodeSignalSubs.clear()
|
|
353
642
|
},
|
|
354
643
|
}
|
|
355
644
|
}
|