@steve02081504/fount-p2p 0.0.35 → 0.0.37
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 +2 -2
- package/core/entity_id.mjs +6 -6
- package/core/entity_id_parse.mjs +7 -7
- package/core/hexIds.mjs +6 -18
- package/crypto/checkpoint_sign.mjs +4 -4
- package/dag/canonicalize_row.mjs +4 -7
- package/discovery/adverts.mjs +17 -10
- package/discovery/bt/index.mjs +11 -11
- package/discovery/bt/peer_hints.mjs +4 -7
- package/discovery/index.mjs +8 -12
- package/discovery/internal/signal_crypto.mjs +1 -2
- package/discovery/lan.mjs +7 -9
- package/discovery/lan_peer_hints.mjs +5 -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/discovery/peer_clue.mjs +1 -4
- package/docs/nostr_relay_discovery.md +92 -0
- package/federation/entity_key_chain.mjs +10 -9
- package/federation/message_rate_limit.mjs +1 -3
- package/files/manifest/fetch.mjs +4 -6
- package/files/manifest/normalize.mjs +2 -2
- package/files/manifest/public.mjs +5 -5
- package/governance/branch.mjs +1 -2
- package/governance/join_pow.mjs +2 -4
- package/governance/owner_succession_ballot.mjs +5 -5
- package/index.mjs +11 -4
- package/infra/priority.mjs +1 -2
- package/link/handshake.mjs +153 -41
- package/link/pipe.mjs +1 -2
- package/link/providers/ble_gatt.mjs +2 -3
- package/link/providers/lan_tcp.mjs +2 -3
- package/link/providers/link_id_pipe.mjs +3 -3
- package/link/providers/{nostr.mjs → nostr/index.mjs} +22 -17
- package/mailbox/deliver_or_store.mjs +11 -12
- package/mailbox/prune.mjs +3 -4
- package/mailbox/rate.mjs +3 -2
- package/mailbox/store.mjs +6 -9
- package/node/denylist.mjs +25 -28
- package/node/feature_config.mjs +30 -0
- package/node/identity.mjs +6 -6
- package/node/instance.mjs +21 -0
- package/node/network.mjs +12 -12
- package/node/personal_block.mjs +4 -4
- package/node/reputation_store.mjs +13 -14
- package/node/reputation_sync.mjs +9 -10
- 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 +13 -16
- package/schemas/discovery.mjs +5 -6
- package/schemas/federation_pull.mjs +7 -7
- package/schemas/mailbox.mjs +3 -3
- package/schemas/part_query.mjs +5 -5
- package/timeline/verify_remote.mjs +7 -7
- package/transport/link_registry.mjs +16 -19
- package/transport/mesh_keepalive.mjs +8 -11
- package/transport/offer_answer.mjs +3 -3
- package/transport/peer_health.mjs +12 -15
- package/transport/runtime_bootstrap.mjs +39 -3
- package/trust_graph/send.mjs +3 -3
- package/discovery/nostr.mjs +0 -1074
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
import { schnorr } from '@noble/curves/secp256k1.js'
|
|
4
|
+
|
|
5
|
+
import { bytesToBase64, bytesToHex, hexToBytes } from '../../core/bytes_codec.mjs'
|
|
6
|
+
import { isHex64 } from '../../core/hexIds.mjs'
|
|
7
|
+
import { sha256Hex } from '../../crypto/crypto.mjs'
|
|
8
|
+
import { getNodeTransportSettings } from '../../node/identity.mjs'
|
|
9
|
+
import { getSignalingRuntimeConfig } from '../../node/instance.mjs'
|
|
10
|
+
import { nodeDebug, shortHash } from '../../node/log.mjs'
|
|
11
|
+
import { noteAdvertPeerHints } from '../advert_peer_hints.mjs'
|
|
12
|
+
import { ingestEncryptedAdvert } from '../adverts.mjs'
|
|
13
|
+
import {
|
|
14
|
+
encryptSignalPacket,
|
|
15
|
+
groupRendezvousKey,
|
|
16
|
+
networkRendezvousKey,
|
|
17
|
+
nodeRendezvousKey,
|
|
18
|
+
} from '../internal/signal_crypto.mjs'
|
|
19
|
+
import { noteDiscoveryPeerClue } from '../peer_clue.mjs'
|
|
20
|
+
|
|
21
|
+
import { createNostrCensus } from './census.mjs'
|
|
22
|
+
import {
|
|
23
|
+
DEFAULT_RELAY_URLS,
|
|
24
|
+
getListenRelays,
|
|
25
|
+
getPeerRoute,
|
|
26
|
+
getPoolByUrl,
|
|
27
|
+
isRelayDestinationAllowed,
|
|
28
|
+
probeRelay,
|
|
29
|
+
registerProviderTrustedRelayUrls,
|
|
30
|
+
resolveRelayConnectTarget,
|
|
31
|
+
setPeerRoute,
|
|
32
|
+
upsertRelay,
|
|
33
|
+
} from './relays.mjs'
|
|
34
|
+
import { routePublishEvent } from './selection.mjs'
|
|
35
|
+
import { dedupeRelayUrls, publishViaSharedRelay, subscribeNostrKind } from './session.mjs'
|
|
36
|
+
|
|
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
|
|
42
|
+
|
|
43
|
+
/** 打广告用的话题 tag(hashtag,NIP-01),公开可被搜索聚合。 */
|
|
44
|
+
const NOSTR_TOPIC_TAG = ['t', 'fount']
|
|
45
|
+
|
|
46
|
+
const ADVERT_TTL_MS = 10 * 60_000
|
|
47
|
+
|
|
48
|
+
/** @type {Map<string, number>} 网络域 nodeHash → lastSeenAt */
|
|
49
|
+
const visibleByHash = new Map()
|
|
50
|
+
/** @type {Map<string, Map<string, number>>} roomSecret → (nodeHash → lastSeenAt) */
|
|
51
|
+
const visibleByGroup = new Map()
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {Map<string, number>} pool 可见池
|
|
55
|
+
* @param {number} now 当前时间
|
|
56
|
+
* @param {number} ttlMs TTL
|
|
57
|
+
* @returns {string[]} 未过期 nodeHash
|
|
58
|
+
*/
|
|
59
|
+
function listPoolHashes(pool, now, ttlMs) {
|
|
60
|
+
/** @type {string[]} */
|
|
61
|
+
const out = []
|
|
62
|
+
for (const [hash, seenAt] of pool)
|
|
63
|
+
if (now - seenAt <= ttlMs) out.push(hash)
|
|
64
|
+
else pool.delete(hash)
|
|
65
|
+
return out
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 写入网络域可见池(非群)。
|
|
70
|
+
* @param {string} nodeHash 节点 hash
|
|
71
|
+
* @param {number} [now=Date.now()] 当前时间
|
|
72
|
+
* @returns {void}
|
|
73
|
+
*/
|
|
74
|
+
export function noteNostrVisibleNode(nodeHash, now = Date.now()) {
|
|
75
|
+
const hash = isHex64(nodeHash)
|
|
76
|
+
if (!hash) return
|
|
77
|
+
visibleByHash.set(hash, now)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 写入群域可见池(与网络域隔离)。
|
|
82
|
+
* @param {string} roomSecret 房间密钥
|
|
83
|
+
* @param {string} nodeHash 节点 hash
|
|
84
|
+
* @param {number} [now=Date.now()] 当前时间
|
|
85
|
+
* @returns {void}
|
|
86
|
+
*/
|
|
87
|
+
export function noteNostrGroupVisibleNode(roomSecret, nodeHash, now = Date.now()) {
|
|
88
|
+
const hash = isHex64(nodeHash)
|
|
89
|
+
if (!roomSecret || !hash) return
|
|
90
|
+
let pool = visibleByGroup.get(roomSecret)
|
|
91
|
+
if (!pool) {
|
|
92
|
+
pool = new Map()
|
|
93
|
+
visibleByGroup.set(roomSecret, pool)
|
|
94
|
+
}
|
|
95
|
+
pool.set(hash, now)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {number} [now=Date.now()] 当前时间
|
|
100
|
+
* @param {number} [ttlMs=ADVERT_TTL_MS] TTL
|
|
101
|
+
* @returns {string[]} 网络域可见 nodeHash
|
|
102
|
+
*/
|
|
103
|
+
export function listNostrVisibleNodeHashes(now = Date.now(), ttlMs = ADVERT_TTL_MS) {
|
|
104
|
+
return listPoolHashes(visibleByHash, now, ttlMs)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @param {string} roomSecret 房间密钥
|
|
109
|
+
* @param {number} [now=Date.now()] 当前时间
|
|
110
|
+
* @param {number} [ttlMs=ADVERT_TTL_MS] TTL
|
|
111
|
+
* @returns {string[]} 该群可见 nodeHash
|
|
112
|
+
*/
|
|
113
|
+
export function listNostrGroupVisibleNodeHashes(roomSecret, now = Date.now(), ttlMs = ADVERT_TTL_MS) {
|
|
114
|
+
const pool = visibleByGroup.get(roomSecret)
|
|
115
|
+
if (!pool) return []
|
|
116
|
+
const out = listPoolHashes(pool, now, ttlMs)
|
|
117
|
+
if (!pool.size) visibleByGroup.delete(roomSecret)
|
|
118
|
+
return out
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 捕获对端 advert 中的 relay 字段:listen 存 peerRoutes.listenRelays、pool 存 peerPool;
|
|
123
|
+
* 本机池中未见 url 以 source='peer' upsert 并后台 probe。
|
|
124
|
+
* 仅吸收/探测允许的公网地址(或本机显式配置/引导集中的中继),阻止远端 advert 驱动对 loopback/私网地址的 probe 与入池。
|
|
125
|
+
* @param {string} verifiedNodeHash 验签 nodeHash
|
|
126
|
+
* @param {string[]} listenRelays 规范化 listen
|
|
127
|
+
* @param {Array<{ url: string, rtt: number }>} relayPool 规范化 pool
|
|
128
|
+
* @returns {Promise<void>}
|
|
129
|
+
*/
|
|
130
|
+
async function capturePeerRelayFields(verifiedNodeHash, listenRelays, relayPool) {
|
|
131
|
+
if (!isHex64(verifiedNodeHash)) return
|
|
132
|
+
setPeerRoute(verifiedNodeHash, {
|
|
133
|
+
listenRelays,
|
|
134
|
+
peerPool: relayPool,
|
|
135
|
+
})
|
|
136
|
+
for (const item of relayPool) {
|
|
137
|
+
if (getPoolByUrl().has(item.url)) continue
|
|
138
|
+
if (!await isRelayDestinationAllowed(item.url)) continue
|
|
139
|
+
upsertRelay({ url: item.url, rttMs: item.rtt, source: 'peer' })
|
|
140
|
+
void probeRelay(item.url).catch(() => { })
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 解密并验签后写入 Nostr 可见池;捕获对端 relay 字段到 peerRoutes;伪造 body.nodeHash 无效。
|
|
146
|
+
* @param {string} rendezvousKey rendezvous 键
|
|
147
|
+
* @param {Uint8Array} bytes 加密 advert
|
|
148
|
+
* @param {{ roomSecret?: string, skipNodeHash?: string, meta?: object }} [options] 群池 / 本机回环过滤 / meta
|
|
149
|
+
* @returns {Promise<string | null>} 验签通过的 nodeHash
|
|
150
|
+
*/
|
|
151
|
+
export async function acceptNostrAdvert(rendezvousKey, bytes, options = {}) {
|
|
152
|
+
const ingested = await ingestEncryptedAdvert(rendezvousKey, bytes)
|
|
153
|
+
if (!ingested) return null
|
|
154
|
+
const hash = ingested.verifiedNodeHash
|
|
155
|
+
if (isHex64(options.skipNodeHash) === hash) return hash
|
|
156
|
+
let firstSeen = true
|
|
157
|
+
if (options.roomSecret) {
|
|
158
|
+
firstSeen = !visibleByGroup.get(options.roomSecret)?.has(hash)
|
|
159
|
+
noteNostrGroupVisibleNode(options.roomSecret, hash)
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
firstSeen = !visibleByHash.has(hash)
|
|
163
|
+
noteNostrVisibleNode(hash)
|
|
164
|
+
}
|
|
165
|
+
if (firstSeen) {
|
|
166
|
+
noteDiscoveryPeerClue(hash)
|
|
167
|
+
nodeDebug('p2p:nostr peer visible', { peer: shortHash(hash), group: !!options.roomSecret })
|
|
168
|
+
}
|
|
169
|
+
await capturePeerRelayFields(hash, ingested.listenRelays, ingested.relayPool)
|
|
170
|
+
noteAdvertPeerHints(hash, ingested.body, options.meta || {})
|
|
171
|
+
return hash
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** @returns {void} 测试用 */
|
|
175
|
+
export function clearNostrVisibleNodes() {
|
|
176
|
+
visibleByHash.clear()
|
|
177
|
+
visibleByGroup.clear()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 当前可用 nostr 中继:用户显式配置 `channels.nostr.relay` 优先;否则返回 listenRelays 工作子集。
|
|
182
|
+
* @returns {string[]} relay URL 列表
|
|
183
|
+
*/
|
|
184
|
+
export function resolveNostrRelayUrls() {
|
|
185
|
+
const relay = getSignalingRuntimeConfig().channels?.nostr?.relay
|
|
186
|
+
if (Array.isArray(relay) && relay.length) return dedupeRelayUrls(relay)
|
|
187
|
+
const configRelay = getNodeTransportSettings().relayUrls
|
|
188
|
+
return configRelay.length ? dedupeRelayUrls(configRelay) : getListenRelays().map(entry => entry.url)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* @param {number} kind 事件 kind
|
|
193
|
+
* @param {string[][]} tags 事件标签
|
|
194
|
+
* @param {string} content 事件内容
|
|
195
|
+
* @param {Uint8Array} secretKey Schnorr 私钥
|
|
196
|
+
* @returns {Promise<object>} 已签名的 Nostr 事件对象
|
|
197
|
+
*/
|
|
198
|
+
async function signNostrEvent(kind, tags, content, secretKey) {
|
|
199
|
+
const pubkey = bytesToHex(schnorr.getPublicKey(secretKey))
|
|
200
|
+
const created_at = Math.floor(Date.now() / 1000)
|
|
201
|
+
const id = sha256Hex(JSON.stringify([0, pubkey, created_at, kind, tags, content]))
|
|
202
|
+
return { id, pubkey, created_at, kind, tags, content, sig: bytesToHex(await schnorr.sign(hexToBytes(id), secretKey)) }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 全量发布到给定 relay(任一成功即返回)。
|
|
207
|
+
* @param {string[]} relayUrls 中继 URL 列表
|
|
208
|
+
* @param {object} event 待发布事件
|
|
209
|
+
* @param {AbortSignal} [signal] 取消信号
|
|
210
|
+
* @returns {Promise<void>}
|
|
211
|
+
*/
|
|
212
|
+
async function publishEvent(relayUrls, event, signal) {
|
|
213
|
+
const urls = dedupeRelayUrls(relayUrls)
|
|
214
|
+
if (!urls.length) throw new Error('nostr: no relay')
|
|
215
|
+
let published = false
|
|
216
|
+
let lastError = null
|
|
217
|
+
await Promise.allSettled(urls.map(async relayUrl => {
|
|
218
|
+
try {
|
|
219
|
+
const connectTarget = await resolveRelayConnectTarget(relayUrl)
|
|
220
|
+
if (!connectTarget) return
|
|
221
|
+
if (await publishViaSharedRelay(relayUrl, event, signal, connectTarget)) published = true
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
lastError = error
|
|
225
|
+
}
|
|
226
|
+
}))
|
|
227
|
+
if (!published) throw lastError || new Error('nostr: no relay accepted publish')
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 创建 Nostr discovery provider(list+connect;topic 仅内部)。
|
|
232
|
+
* @param {{ relayUrls?: string[] | null, getRelayUrls?: () => string[] | null | undefined, localNodeHash?: string }} [options] 中继配置与本机 hash
|
|
233
|
+
* @returns {import('../index.mjs').DiscoveryProvider} Nostr discovery provider
|
|
234
|
+
*/
|
|
235
|
+
export function createNostrDiscoveryProvider(options = {}) {
|
|
236
|
+
const hasExplicitRelay = options.relayUrls != null || !!options.getRelayUrls
|
|
237
|
+
const releaseProviderTrustedRelays = options.relayUrls != null
|
|
238
|
+
? registerProviderTrustedRelayUrls(options.relayUrls)
|
|
239
|
+
: null
|
|
240
|
+
/** @returns {string[]} 去重后的中继 URL 列表 */
|
|
241
|
+
const resolveRelayUrls = () => {
|
|
242
|
+
if (options.getRelayUrls) return dedupeRelayUrls(options.getRelayUrls() ?? DEFAULT_RELAY_URLS)
|
|
243
|
+
return options.relayUrls == null ? resolveNostrRelayUrls() : dedupeRelayUrls(options.relayUrls)
|
|
244
|
+
}
|
|
245
|
+
const secretKey = randomBytes(32)
|
|
246
|
+
/** @type {string | null} */
|
|
247
|
+
let selfNodeHash = isHex64(options.localNodeHash)
|
|
248
|
+
const NETWORK_SUB_KEY = 'network'
|
|
249
|
+
/**
|
|
250
|
+
* @typedef {{ stop: () => void, held: boolean, listeners: Set<(bytes: Uint8Array, meta: object) => void> }} AdvertSubEntry
|
|
251
|
+
*/
|
|
252
|
+
/** @type {Map<string, AdvertSubEntry>} */
|
|
253
|
+
const advertSubs = new Map()
|
|
254
|
+
/** @type {Map<string, () => void>} */
|
|
255
|
+
const nodeSignalSubs = new Map()
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* @param {string | undefined | null} nodeHash 本机 hash
|
|
259
|
+
* @returns {void}
|
|
260
|
+
*/
|
|
261
|
+
function noteSelfNodeHash(nodeHash) {
|
|
262
|
+
const hash = isHex64(nodeHash)
|
|
263
|
+
if (hash) selfNodeHash = hash
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* @param {string} key 订阅键
|
|
268
|
+
* @param {AdvertSubEntry} entry 条目
|
|
269
|
+
* @returns {void}
|
|
270
|
+
*/
|
|
271
|
+
function releaseAdvertEntryIfIdle(key, entry) {
|
|
272
|
+
if (entry.held || entry.listeners.size) return
|
|
273
|
+
try { entry.stop() } catch { /* ignore */ }
|
|
274
|
+
advertSubs.delete(key)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* pool/connect 永久 hold;watch listener 归零且无 hold 时拆 REQ。
|
|
279
|
+
* @param {string} key 订阅键
|
|
280
|
+
* @param {{ rendezvousKey: string, roomSecret?: string }} bind advert 绑定
|
|
281
|
+
* @param {(bytes: Uint8Array, meta: object) => void} [listener] 额外监听
|
|
282
|
+
* @returns {() => void} 取消 listener;无 listener 时 no-op(hold 至 dispose)
|
|
283
|
+
*/
|
|
284
|
+
function ensureAdvertSubscription(key, bind, listener) {
|
|
285
|
+
let entry = advertSubs.get(key)
|
|
286
|
+
if (!entry) {
|
|
287
|
+
/** @type {AdvertSubEntry} */
|
|
288
|
+
const created = {
|
|
289
|
+
/**
|
|
290
|
+
* @returns {void}
|
|
291
|
+
*/
|
|
292
|
+
stop: () => { }, held: false, listeners: new Set()
|
|
293
|
+
}
|
|
294
|
+
created.stop = subscribeNostrKind(resolveRelayUrls(), {
|
|
295
|
+
kind: NOSTR_ADVERT_KIND,
|
|
296
|
+
rendezvousKey: bind.rendezvousKey,
|
|
297
|
+
tagX: 'advert',
|
|
298
|
+
addressable: true,
|
|
299
|
+
resolveConnectTarget: resolveRelayConnectTarget,
|
|
300
|
+
/**
|
|
301
|
+
* @param {Uint8Array} bytes 加密 advert 载荷
|
|
302
|
+
* @param {object} meta relay 元数据
|
|
303
|
+
* @returns {Promise<void>}
|
|
304
|
+
*/
|
|
305
|
+
async onPayload(bytes, meta) {
|
|
306
|
+
await acceptNostrAdvert(bind.rendezvousKey, bytes, {
|
|
307
|
+
roomSecret: bind.roomSecret,
|
|
308
|
+
skipNodeHash: selfNodeHash || undefined,
|
|
309
|
+
meta,
|
|
310
|
+
})
|
|
311
|
+
for (const fn of created.listeners)
|
|
312
|
+
try { fn(bytes, meta) } catch { /* ignore */ }
|
|
313
|
+
},
|
|
314
|
+
})
|
|
315
|
+
entry = created
|
|
316
|
+
advertSubs.set(key, entry)
|
|
317
|
+
}
|
|
318
|
+
if (!listener) {
|
|
319
|
+
entry.held = true
|
|
320
|
+
return () => { }
|
|
321
|
+
}
|
|
322
|
+
entry.listeners.add(listener)
|
|
323
|
+
return () => {
|
|
324
|
+
entry.listeners.delete(listener)
|
|
325
|
+
releaseAdvertEntryIfIdle(key, entry)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* @returns {() => void} no-op(network hold 至 dispose)
|
|
331
|
+
*/
|
|
332
|
+
function ensureNetworkAdvertSubscription() {
|
|
333
|
+
return ensureAdvertSubscription(NETWORK_SUB_KEY, {
|
|
334
|
+
rendezvousKey: networkRendezvousKey(),
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* @param {string} roomSecret 房间密钥
|
|
340
|
+
* @param {(bytes: Uint8Array, meta: object) => void} [listener] 额外 advert 监听
|
|
341
|
+
* @returns {() => void} 取消 listener
|
|
342
|
+
*/
|
|
343
|
+
function ensureGroupSubscription(roomSecret, listener) {
|
|
344
|
+
return ensureAdvertSubscription('group:' + roomSecret, {
|
|
345
|
+
rendezvousKey: groupRendezvousKey(roomSecret),
|
|
346
|
+
roomSecret,
|
|
347
|
+
}, listener)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* @param {string} nodeHash 目标
|
|
352
|
+
* @param {(bytes: Uint8Array, meta: object) => void} [listener] 额外 advert 监听
|
|
353
|
+
* @returns {() => void} 取消 listener
|
|
354
|
+
*/
|
|
355
|
+
function ensureNodeAdvertSubscription(nodeHash, listener) {
|
|
356
|
+
const hash = isHex64(nodeHash)
|
|
357
|
+
if (!hash) return () => { }
|
|
358
|
+
return ensureAdvertSubscription('node:' + hash, {
|
|
359
|
+
rendezvousKey: nodeRendezvousKey(hash),
|
|
360
|
+
}, listener)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** @type {Array<() => void>} */
|
|
364
|
+
const extraSubs = []
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* connectToNode 额外订阅对端历史/声称的中继以加速汇聚(adhoc,dispose 时释放)。
|
|
368
|
+
* 仅订阅允许连接的公网中继,拒绝解析到私网/本机未配置的目的地。
|
|
369
|
+
* @param {string} nodeHash 目标节点
|
|
370
|
+
* @returns {Promise<void>}
|
|
371
|
+
*/
|
|
372
|
+
async function ensurePeerRelaySubscriptions(nodeHash) {
|
|
373
|
+
const hash = isHex64(nodeHash)
|
|
374
|
+
if (!hash) return
|
|
375
|
+
const route = getPeerRoute(hash)
|
|
376
|
+
const extra = dedupeRelayUrls([
|
|
377
|
+
...route?.lastGoodNostrRelays || [],
|
|
378
|
+
...route?.listenRelays || [],
|
|
379
|
+
]).filter(url => !resolveRelayUrls().includes(url))
|
|
380
|
+
if (!extra.length) return
|
|
381
|
+
const entries = await Promise.all(extra.map(async url => ({ url, allowed: await isRelayDestinationAllowed(url) })))
|
|
382
|
+
const targets = entries.filter(entry => entry.allowed).map(entry => entry.url)
|
|
383
|
+
if (!targets.length) return
|
|
384
|
+
const rendezvousKey = nodeRendezvousKey(hash)
|
|
385
|
+
extraSubs.push(subscribeNostrKind(targets, {
|
|
386
|
+
kind: NOSTR_ADVERT_KIND,
|
|
387
|
+
rendezvousKey,
|
|
388
|
+
tagX: 'advert',
|
|
389
|
+
addressable: true,
|
|
390
|
+
resolveConnectTarget: resolveRelayConnectTarget,
|
|
391
|
+
/**
|
|
392
|
+
* 处理订阅收到的 advert 字节。
|
|
393
|
+
* @param {Uint8Array} bytes advert 字节
|
|
394
|
+
* @returns {Promise<string | null>} 接受后的节点 hash,拒绝时为 null
|
|
395
|
+
*/
|
|
396
|
+
onPayload: bytes => acceptNostrAdvert(rendezvousKey, bytes, {
|
|
397
|
+
skipNodeHash: selfNodeHash || undefined,
|
|
398
|
+
}),
|
|
399
|
+
}))
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
id: 'nostr',
|
|
404
|
+
priority: 100,
|
|
405
|
+
caps: { canDiscover: true, canSignal: true, canRelay: false },
|
|
406
|
+
/**
|
|
407
|
+
* @param {{ limit?: number, roomSecret?: string }} [options] 扫描选项
|
|
408
|
+
* @returns {Promise<string[]>} 可见 nodeHash;带 roomSecret 时仅返回该群池
|
|
409
|
+
*/
|
|
410
|
+
async listVisibleNodeHashes(options = {}) {
|
|
411
|
+
const limit = Math.max(1, Number(options.limit) || 64)
|
|
412
|
+
if (options.roomSecret) {
|
|
413
|
+
ensureGroupSubscription(options.roomSecret)
|
|
414
|
+
return listNostrGroupVisibleNodeHashes(options.roomSecret)
|
|
415
|
+
.filter(hash => hash !== selfNodeHash)
|
|
416
|
+
.slice(0, limit)
|
|
417
|
+
}
|
|
418
|
+
ensureNetworkAdvertSubscription()
|
|
419
|
+
return listNostrVisibleNodeHashes()
|
|
420
|
+
.filter(hash => hash !== selfNodeHash)
|
|
421
|
+
.slice(0, limit)
|
|
422
|
+
},
|
|
423
|
+
/**
|
|
424
|
+
* 挂上对该节点的内部 advert 订阅(建链由 registry dialer / ensureLinkToNode 完成)。
|
|
425
|
+
* @param {string} nodeHash 目标
|
|
426
|
+
* @returns {Promise<boolean>} 是否已准备
|
|
427
|
+
*/
|
|
428
|
+
async connectToNode(nodeHash) {
|
|
429
|
+
const hash = isHex64(nodeHash)
|
|
430
|
+
if (!hash) return false
|
|
431
|
+
ensureNodeAdvertSubscription(hash)
|
|
432
|
+
await ensurePeerRelaySubscriptions(hash)
|
|
433
|
+
return true
|
|
434
|
+
},
|
|
435
|
+
/**
|
|
436
|
+
* @param {() => Promise<object | null>} getBeacon 本机 advert body 工厂
|
|
437
|
+
* @returns {Promise<() => void>} 停止函数
|
|
438
|
+
*/
|
|
439
|
+
async startPresence(getBeacon) {
|
|
440
|
+
const rendezvousKey = networkRendezvousKey()
|
|
441
|
+
const abortController = new AbortController()
|
|
442
|
+
ensureNetworkAdvertSubscription()
|
|
443
|
+
/**
|
|
444
|
+
* @returns {Promise<void>}
|
|
445
|
+
*/
|
|
446
|
+
const publish = async () => {
|
|
447
|
+
if (abortController.signal.aborted) return
|
|
448
|
+
const beacon = await getBeacon?.()
|
|
449
|
+
if (!beacon?.nodeHash) return
|
|
450
|
+
noteSelfNodeHash(beacon.nodeHash)
|
|
451
|
+
const event = await signNostrEvent(
|
|
452
|
+
NOSTR_ADVERT_KIND,
|
|
453
|
+
[NOSTR_TOPIC_TAG, ['t', rendezvousKey], ['x', 'advert'], ['d', rendezvousKey]],
|
|
454
|
+
bytesToBase64(encryptSignalPacket(rendezvousKey, { type: 'advert', body: beacon.advertBody || beacon.body || beacon })),
|
|
455
|
+
secretKey,
|
|
456
|
+
)
|
|
457
|
+
await publishEvent(resolveRelayUrls(), event, abortController.signal)
|
|
458
|
+
nodeDebug('p2p:nostr presence published', { self: shortHash(beacon.nodeHash) })
|
|
459
|
+
}
|
|
460
|
+
void publish().catch(error => {
|
|
461
|
+
nodeDebug('p2p:nostr presence publish fail', { err: String(error?.message || error) })
|
|
462
|
+
})
|
|
463
|
+
const timer = setInterval(() => {
|
|
464
|
+
void publish().catch(error => {
|
|
465
|
+
nodeDebug('p2p:nostr presence publish fail', { err: String(error?.message || error) })
|
|
466
|
+
})
|
|
467
|
+
}, 5 * 60_000)
|
|
468
|
+
timer.unref?.()
|
|
469
|
+
// census(人口统计):由 features.census 开关驱动,worker 内部每周期自检。
|
|
470
|
+
const census = createNostrCensus({
|
|
471
|
+
resolveRelayUrls,
|
|
472
|
+
publishEvent,
|
|
473
|
+
/**
|
|
474
|
+
* @param {number} kind 事件 kind
|
|
475
|
+
* @param {string[][]} tags 事件标签
|
|
476
|
+
* @param {string} content 事件内容
|
|
477
|
+
* @returns {Promise<object>} 已签名事件
|
|
478
|
+
*/
|
|
479
|
+
signEvent: (kind, tags, content) => signNostrEvent(kind, tags, content, secretKey),
|
|
480
|
+
subscribeNostrKind,
|
|
481
|
+
})
|
|
482
|
+
census.start()
|
|
483
|
+
return () => {
|
|
484
|
+
abortController.abort()
|
|
485
|
+
clearInterval(timer)
|
|
486
|
+
census.stop()
|
|
487
|
+
}
|
|
488
|
+
},
|
|
489
|
+
/**
|
|
490
|
+
* @param {string} toNodeHash 目标 nodeHash
|
|
491
|
+
* @param {Uint8Array} bytes 加密信令
|
|
492
|
+
* @returns {Promise<void>}
|
|
493
|
+
*/
|
|
494
|
+
async sendNodeSignal(toNodeHash, bytes) {
|
|
495
|
+
const hash = isHex64(toNodeHash)
|
|
496
|
+
if (!hash) throw new Error('nostr: invalid nodeHash')
|
|
497
|
+
const rendezvousKey = nodeRendezvousKey(hash)
|
|
498
|
+
const event = await signNostrEvent(
|
|
499
|
+
NOSTR_SIGNAL_KIND,
|
|
500
|
+
[NOSTR_TOPIC_TAG, ['t', rendezvousKey], ['x', 'signal'], ['p', hash]],
|
|
501
|
+
bytesToBase64(bytes),
|
|
502
|
+
secretKey,
|
|
503
|
+
)
|
|
504
|
+
// 显式 relay 覆盖(测试/用户 pin)时直接全量发布;否则走握手路由。
|
|
505
|
+
if (hasExplicitRelay) {
|
|
506
|
+
await publishEvent(resolveRelayUrls(), event)
|
|
507
|
+
return
|
|
508
|
+
}
|
|
509
|
+
await routePublishEvent(hash, event)
|
|
510
|
+
},
|
|
511
|
+
/**
|
|
512
|
+
* @param {string} localNodeHash 本机 nodeHash
|
|
513
|
+
* @param {(bytes: Uint8Array) => void} onSignal 信令回调
|
|
514
|
+
* @returns {Promise<() => void>} 取消函数
|
|
515
|
+
*/
|
|
516
|
+
async listenNodeSignals(localNodeHash, onSignal) {
|
|
517
|
+
const hash = isHex64(localNodeHash)
|
|
518
|
+
if (!hash) throw new Error('nostr: invalid nodeHash')
|
|
519
|
+
noteSelfNodeHash(hash)
|
|
520
|
+
const rendezvousKey = nodeRendezvousKey(hash)
|
|
521
|
+
const existing = nodeSignalSubs.get(hash)
|
|
522
|
+
if (existing) existing()
|
|
523
|
+
nodeDebug('p2p:nostr signal listen', { self: shortHash(hash), relays: resolveRelayUrls().length })
|
|
524
|
+
const stop = subscribeNostrKind(resolveRelayUrls(), {
|
|
525
|
+
kind: NOSTR_SIGNAL_KIND,
|
|
526
|
+
rendezvousKey,
|
|
527
|
+
tagX: 'signal',
|
|
528
|
+
resolveConnectTarget: resolveRelayConnectTarget,
|
|
529
|
+
onPayload: onSignal,
|
|
530
|
+
})
|
|
531
|
+
nodeSignalSubs.set(hash, stop)
|
|
532
|
+
return () => {
|
|
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 = isHex64(nodeHash)
|
|
544
|
+
if (!hash) throw new Error('nostr: invalid nodeHash')
|
|
545
|
+
return ensureNodeAdvertSubscription(hash, onAdvert)
|
|
546
|
+
},
|
|
547
|
+
/**
|
|
548
|
+
* @param {string} roomSecret 房间密钥
|
|
549
|
+
* @param {() => Promise<object | null>} getBeacon advert 工厂
|
|
550
|
+
* @returns {Promise<() => void>} 停止群 presence 广播
|
|
551
|
+
*/
|
|
552
|
+
async startGroupPresence(roomSecret, getBeacon) {
|
|
553
|
+
const rendezvousKey = groupRendezvousKey(roomSecret)
|
|
554
|
+
const abortController = new AbortController()
|
|
555
|
+
ensureGroupSubscription(roomSecret)
|
|
556
|
+
/**
|
|
557
|
+
* @returns {Promise<void>}
|
|
558
|
+
*/
|
|
559
|
+
const publish = async () => {
|
|
560
|
+
if (abortController.signal.aborted) return
|
|
561
|
+
const beacon = await getBeacon?.()
|
|
562
|
+
if (!beacon?.nodeHash) return
|
|
563
|
+
noteSelfNodeHash(beacon.nodeHash)
|
|
564
|
+
const event = await signNostrEvent(
|
|
565
|
+
NOSTR_ADVERT_KIND,
|
|
566
|
+
[NOSTR_TOPIC_TAG, ['t', rendezvousKey], ['x', 'advert'], ['d', rendezvousKey]],
|
|
567
|
+
bytesToBase64(encryptSignalPacket(rendezvousKey, { type: 'advert', body: beacon.advertBody || beacon.body || beacon })),
|
|
568
|
+
secretKey,
|
|
569
|
+
)
|
|
570
|
+
await publishEvent(resolveRelayUrls(), event, abortController.signal)
|
|
571
|
+
}
|
|
572
|
+
void publish().catch(() => { })
|
|
573
|
+
const timer = setInterval(() => { void publish().catch(() => { }) }, 5 * 60_000)
|
|
574
|
+
timer.unref?.()
|
|
575
|
+
return () => {
|
|
576
|
+
abortController.abort()
|
|
577
|
+
clearInterval(timer)
|
|
578
|
+
}
|
|
579
|
+
},
|
|
580
|
+
/**
|
|
581
|
+
* @param {string} roomSecret 房间密钥
|
|
582
|
+
* @param {(bytes: Uint8Array, meta: object) => void} onAdvert advert 回调
|
|
583
|
+
* @returns {Promise<() => void>} 取消群 advert 监听
|
|
584
|
+
*/
|
|
585
|
+
async watchGroupAdverts(roomSecret, onAdvert) {
|
|
586
|
+
return ensureGroupSubscription(roomSecret, onAdvert)
|
|
587
|
+
},
|
|
588
|
+
/**
|
|
589
|
+
* 供 advert 解析路径写入可见 hash。
|
|
590
|
+
* @param {string} nodeHash 节点 hash
|
|
591
|
+
* @param {{ roomSecret?: string }} [options] 带 roomSecret 时写入群池
|
|
592
|
+
* @returns {void}
|
|
593
|
+
*/
|
|
594
|
+
noteVisibleNode(nodeHash, options = {}) {
|
|
595
|
+
if (options.roomSecret) noteNostrGroupVisibleNode(options.roomSecret, nodeHash)
|
|
596
|
+
else noteNostrVisibleNode(nodeHash)
|
|
597
|
+
},
|
|
598
|
+
/** @returns {void} 停止全部内部订阅 */
|
|
599
|
+
dispose() {
|
|
600
|
+
releaseProviderTrustedRelays?.()
|
|
601
|
+
for (const entry of advertSubs.values())
|
|
602
|
+
try { entry.stop() } catch { /* ignore */ }
|
|
603
|
+
advertSubs.clear()
|
|
604
|
+
for (const stop of nodeSignalSubs.values())
|
|
605
|
+
try { stop() } catch { /* ignore */ }
|
|
606
|
+
nodeSignalSubs.clear()
|
|
607
|
+
for (const stop of extraSubs)
|
|
608
|
+
try { stop() } catch { /* ignore */ }
|
|
609
|
+
extraSubs.length = 0
|
|
610
|
+
},
|
|
611
|
+
}
|
|
612
|
+
}
|