@steve02081504/fount-p2p 0.0.36 → 0.0.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -3
- package/discovery/adverts.mjs +16 -8
- package/discovery/nostr/census.mjs +281 -0
- package/discovery/nostr/census_math.mjs +54 -0
- package/discovery/nostr/constants.mjs +43 -0
- package/discovery/nostr/index.mjs +612 -0
- package/discovery/nostr/relays.mjs +1071 -0
- package/discovery/nostr/selection.mjs +224 -0
- package/discovery/nostr/session.mjs +584 -0
- package/docs/evfs.md +15 -6
- package/docs/nostr_relay_discovery.md +92 -0
- package/files/chunk/responder.mjs +2 -2
- package/files/evfs.mjs +2 -2
- package/files/fed/fetch_shared.mjs +3 -1
- package/files/fed/responder.mjs +3 -2
- package/files/fetch_fanout.mjs +26 -5
- package/files/manifest/fetch.mjs +73 -38
- package/files/manifest/pending.mjs +21 -7
- package/files/manifest/servicer_registry.mjs +51 -0
- package/governance/branch.mjs +1 -1
- package/governance/join_pow.mjs +2 -2
- package/index.mjs +11 -4
- package/link/handshake.mjs +135 -23
- package/link/providers/link_id_pipe.mjs +1 -1
- package/link/providers/{nostr.mjs → nostr/index.mjs} +19 -14
- package/mailbox/deliver_or_store.mjs +3 -4
- package/mailbox/store.mjs +1 -1
- package/node/feature_config.mjs +30 -0
- package/node/instance.mjs +21 -0
- package/node/reputation_store.mjs +1 -2
- package/node/storage.mjs +23 -0
- package/package.json +10 -4
- package/pages/app.mjs +172 -0
- package/pages/index.html +36 -0
- package/reputation/engine.mjs +1 -2
- package/transport/runtime_bootstrap.mjs +39 -3
- package/utils/fetch_wait.mjs +12 -7
- package/discovery/nostr.mjs +0 -1073
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { isIP } from 'node:net'
|
|
3
|
+
|
|
4
|
+
import WebSocket from 'ws'
|
|
5
|
+
|
|
6
|
+
import { base64ToBytes } from '../../core/bytes_codec.mjs'
|
|
7
|
+
import { nodeDebug } from '../../node/log.mjs'
|
|
8
|
+
|
|
9
|
+
/** 单中继 WebSocket 首连超时(短超时 + 并行,避免串行 10s×N)。 */
|
|
10
|
+
export const NOSTR_CONNECT_TIMEOUT_MS = 2_000
|
|
11
|
+
/** 先 close,超时未 CLOSED 再 terminate(给对端优雅关闭的窗口)。 */
|
|
12
|
+
const NOSTR_CLOSE_GRACE_MS = 1_000
|
|
13
|
+
/** 单 relay 等待 EVENT OK 回执超时。 */
|
|
14
|
+
const NOSTR_PUBLISH_OK_TIMEOUT_MS = 3_000
|
|
15
|
+
/** 共享 relay 会话断线后重连间隔。 */
|
|
16
|
+
const NOSTR_RECONNECT_DELAY_MS = 500
|
|
17
|
+
/** 无 sub/publish 工作时共享 relay 空闲回收延迟(给连续 send 复用窗口)。 */
|
|
18
|
+
const NOSTR_IDLE_DROP_MS = 2_000
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string[] | undefined | null} urls 原始列表
|
|
22
|
+
* @returns {string[]} 去重后的列表
|
|
23
|
+
*/
|
|
24
|
+
export function dedupeRelayUrls(urls) {
|
|
25
|
+
const seen = new Set()
|
|
26
|
+
return (urls || [])
|
|
27
|
+
.filter(url => url && !seen.has(url) && (seen.add(url), true))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 关掉 WebSocket:已连上则先 close,grace 内未 CLOSED 再 terminate。
|
|
32
|
+
* @param {import('ws').WebSocket} ws 连接
|
|
33
|
+
* @returns {void}
|
|
34
|
+
*/
|
|
35
|
+
function dropWebSocket(ws) {
|
|
36
|
+
if (ws.readyState === WebSocket.CLOSED) return
|
|
37
|
+
if (ws.readyState === WebSocket.CONNECTING) {
|
|
38
|
+
try { ws.terminate() } catch { /* ignore */ }
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
const timer = setTimeout(() => {
|
|
42
|
+
if (ws.readyState !== WebSocket.CLOSED)
|
|
43
|
+
try { ws.terminate() } catch { /* ignore */ }
|
|
44
|
+
}, NOSTR_CLOSE_GRACE_MS)
|
|
45
|
+
timer.unref()
|
|
46
|
+
ws.once('close', () => clearTimeout(timer))
|
|
47
|
+
try {
|
|
48
|
+
ws.close()
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
clearTimeout(timer)
|
|
52
|
+
try { ws.terminate() } catch { /* ignore */ }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 生成钉死地址集的 dns.lookup 形状函数:忽略请求的 hostname,始终返回给定地址,
|
|
58
|
+
* 供 `new WebSocket(url, { lookup })` / node:http(s) 使用,避免对同一 hostname 的第二次不受控解析。
|
|
59
|
+
* @param {string[]} addresses 已校验/受控解析的地址
|
|
60
|
+
* @returns {(hostname: string, options: object | ((err: Error | null, address?: string | object[], family?: number) => void), callback?: (err: Error | null, address?: string | object[], family?: number) => void) => void} lookup 函数
|
|
61
|
+
*/
|
|
62
|
+
export function pinnedLookup(addresses) {
|
|
63
|
+
return (hostname, options, callback) => {
|
|
64
|
+
if (typeof options === 'function') {
|
|
65
|
+
callback = options
|
|
66
|
+
options = {}
|
|
67
|
+
}
|
|
68
|
+
const family = addresses.length ? isIP(addresses[0]) : 0
|
|
69
|
+
if (options?.all)
|
|
70
|
+
callback(null, addresses.map(address => ({ address, family: isIP(address) })), family)
|
|
71
|
+
else
|
|
72
|
+
callback(null, addresses[0], family)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {string} relayUrl 中继 URL
|
|
78
|
+
* @param {number} [timeoutMs] 超时毫秒
|
|
79
|
+
* @param {AbortSignal} [signal] 取消信号
|
|
80
|
+
* @param {{ hostname: string, addresses: string[] } | ((url: string) => Promise<{ hostname: string, addresses: string[] } | null>) | null} [connectTarget] 已解析的连接目标(钉 IP 用)或解析函数
|
|
81
|
+
* @returns {Promise<import('ws').WebSocket>} 已打开的 WebSocket
|
|
82
|
+
*/
|
|
83
|
+
export function connectRelay(relayUrl, timeoutMs = NOSTR_CONNECT_TIMEOUT_MS, signal, connectTarget) {
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
if (signal?.aborted) {
|
|
86
|
+
reject(new Error('nostr: aborted'))
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
let settled = false
|
|
90
|
+
/**
|
|
91
|
+
* @param {Error} error 失败原因
|
|
92
|
+
* @returns {void}
|
|
93
|
+
*/
|
|
94
|
+
const fail = error => {
|
|
95
|
+
if (settled) return
|
|
96
|
+
settled = true
|
|
97
|
+
clearTimeout(timer)
|
|
98
|
+
signal?.removeEventListener('abort', onAbort)
|
|
99
|
+
if (ws) dropWebSocket(ws)
|
|
100
|
+
reject(error)
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* 收到 abort 信号时失败
|
|
104
|
+
* @returns {void}
|
|
105
|
+
*/
|
|
106
|
+
const onAbort = () => fail(new Error('nostr: aborted'))
|
|
107
|
+
const timer = setTimeout(() => fail(new Error(`nostr: connect timeout for ${relayUrl}`)), timeoutMs)
|
|
108
|
+
timer.unref()
|
|
109
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
110
|
+
let ws = null
|
|
111
|
+
void (async () => {
|
|
112
|
+
let wsOptions
|
|
113
|
+
try {
|
|
114
|
+
const target = typeof connectTarget === 'function' ? await connectTarget(relayUrl) : connectTarget
|
|
115
|
+
if (connectTarget != null && (!target || !target.addresses?.length)) {
|
|
116
|
+
fail(new Error(`nostr: connect target resolution returned empty for ${relayUrl}`))
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
if (target) wsOptions = { lookup: pinnedLookup(target.addresses) }
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
fail(error instanceof Error ? error : new Error(String(error)))
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
if (settled) return
|
|
126
|
+
ws = new WebSocket(relayUrl, wsOptions)
|
|
127
|
+
ws.once('open', () => {
|
|
128
|
+
if (settled) return
|
|
129
|
+
settled = true
|
|
130
|
+
clearTimeout(timer)
|
|
131
|
+
signal?.removeEventListener('abort', onAbort)
|
|
132
|
+
resolve(ws)
|
|
133
|
+
})
|
|
134
|
+
ws.once('error', () => {
|
|
135
|
+
fail(new Error(`nostr: websocket error for ${relayUrl}`))
|
|
136
|
+
})
|
|
137
|
+
})()
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @param {import('ws').WebSocket} ws 已连接 relay
|
|
143
|
+
* @param {string} relayUrl 中继 URL
|
|
144
|
+
* @param {object} event 待发布事件
|
|
145
|
+
* @param {AbortSignal} [signal] 取消信号
|
|
146
|
+
* @param {() => boolean} isCurrent 该尝试是否仍是 publishRequest 的当前尝试(断线重发后过期)
|
|
147
|
+
* @returns {Promise<boolean>} relay 是否接受 EVENT
|
|
148
|
+
*/
|
|
149
|
+
function publishEventOnRelay(ws, relayUrl, event, signal, isCurrent) {
|
|
150
|
+
return new Promise((resolve, reject) => {
|
|
151
|
+
if (signal?.aborted) {
|
|
152
|
+
reject(new Error('nostr: aborted'))
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
let settled = false
|
|
156
|
+
/**
|
|
157
|
+
* @param {boolean} ok relay 是否接受
|
|
158
|
+
* @param {Error | null} [error] 失败原因
|
|
159
|
+
* @returns {void}
|
|
160
|
+
*/
|
|
161
|
+
const finish = (ok, error = null) => {
|
|
162
|
+
if (settled) return
|
|
163
|
+
settled = true
|
|
164
|
+
clearTimeout(timer)
|
|
165
|
+
signal?.removeEventListener('abort', onAbort)
|
|
166
|
+
ws.off('message', onMessage)
|
|
167
|
+
// 过期的 in-flight 尝试:只清理自身监听器/定时器,不结算,避免影响重发后的当前尝试。
|
|
168
|
+
if (!isCurrent()) return
|
|
169
|
+
if (error) reject(error)
|
|
170
|
+
else resolve(ok)
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* 收到 abort 信号时以失败结束
|
|
174
|
+
* @returns {void}
|
|
175
|
+
*/
|
|
176
|
+
const onAbort = () => finish(false, new Error('nostr: aborted'))
|
|
177
|
+
/**
|
|
178
|
+
* @param {import('ws').RawData} data relay 消息
|
|
179
|
+
* @returns {void}
|
|
180
|
+
*/
|
|
181
|
+
const onMessage = data => {
|
|
182
|
+
let parsed
|
|
183
|
+
try { parsed = JSON.parse(String(data)) } catch { return }
|
|
184
|
+
if (parsed?.[0] !== 'OK' || parsed[1] !== event.id) return
|
|
185
|
+
const accepted = parsed[2] === true
|
|
186
|
+
if (!accepted)
|
|
187
|
+
nodeDebug('p2p:nostr publish rejected', {
|
|
188
|
+
url: relayUrl,
|
|
189
|
+
reason: String(parsed[3] || 'rejected'),
|
|
190
|
+
})
|
|
191
|
+
finish(accepted)
|
|
192
|
+
}
|
|
193
|
+
const timer = setTimeout(
|
|
194
|
+
() => finish(false, new Error(`nostr: publish ok timeout for ${relayUrl}`)),
|
|
195
|
+
NOSTR_PUBLISH_OK_TIMEOUT_MS,
|
|
196
|
+
)
|
|
197
|
+
timer.unref()
|
|
198
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
199
|
+
ws.on('message', onMessage)
|
|
200
|
+
try {
|
|
201
|
+
ws.send(JSON.stringify(['EVENT', event]))
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
finish(false, error instanceof Error ? error : new Error(String(error)))
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 一次共享 relay publish 请求:入队(pending)与已派发(inflight)时共用同一结构。
|
|
211
|
+
* attempt 标识每次实际发送尝试,断线重发会递增;旧的 in-flight 尝试据此自检为过期后自我清理,
|
|
212
|
+
* 不再结算当前尝试。
|
|
213
|
+
* @typedef {{
|
|
214
|
+
* event: object,
|
|
215
|
+
* signal: AbortSignal | undefined,
|
|
216
|
+
* attempt: number,
|
|
217
|
+
* onAbort: (() => void) | null,
|
|
218
|
+
* removeAbort: (() => void) | null,
|
|
219
|
+
* resolve: (ok: boolean) => void,
|
|
220
|
+
* reject: (err: Error) => void,
|
|
221
|
+
* }} NostrPublishRequest
|
|
222
|
+
*/
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 共享 relay 会话:多 SUB / publish 复用同一 URL 的 WebSocket,避免 signal/presence/advert 各建一池、
|
|
226
|
+
* 以及每次 send 重开一条连接(内存泄漏)。
|
|
227
|
+
* 仍有活跃 sub 或待发 publish 时断线会自动重连并重发 REQ / 重发 EVENT。
|
|
228
|
+
* @typedef {{
|
|
229
|
+
* ws: import('ws').WebSocket | null,
|
|
230
|
+
* connecting: boolean,
|
|
231
|
+
* reconnectTimer: ReturnType<typeof setTimeout> | null,
|
|
232
|
+
* idleTimer: ReturnType<typeof setTimeout> | null,
|
|
233
|
+
* connectTarget: { hostname: string, addresses: string[] } | ((url: string) => Promise<{ hostname: string, addresses: string[] } | null>) | null,
|
|
234
|
+
* subs: Map<string, { filter: object, onEvent: (event: object, relayUrl: string) => void }>,
|
|
235
|
+
* pendingPublishes: Array<NostrPublishRequest>,
|
|
236
|
+
* inflightPublishes: Array<NostrPublishRequest>,
|
|
237
|
+
* }} SharedRelaySession
|
|
238
|
+
*/
|
|
239
|
+
|
|
240
|
+
/** @type {Map<string, SharedRelaySession>} */
|
|
241
|
+
const sharedRelaySessions = new Map()
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 会话是否仍有需要连接的工作(sub 或待发 publish)。
|
|
245
|
+
* @param {SharedRelaySession} session 会话
|
|
246
|
+
* @returns {boolean} 是否有活跃工作
|
|
247
|
+
*/
|
|
248
|
+
function hasPendingWork(session) {
|
|
249
|
+
return session.subs.size || session.pendingPublishes.length || session.inflightPublishes.length
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* 取消会话的空闲回收定时器。
|
|
254
|
+
* @param {SharedRelaySession} session 会话
|
|
255
|
+
* @returns {void}
|
|
256
|
+
*/
|
|
257
|
+
function clearIdleDrop(session) {
|
|
258
|
+
if (!session.idleTimer) return
|
|
259
|
+
clearTimeout(session.idleTimer)
|
|
260
|
+
session.idleTimer = null
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 会话无 sub 时,待 publish 清空后延迟回收空闲 socket(给连续 send 复用窗口)。
|
|
265
|
+
* @param {string} relayUrl 中继 URL
|
|
266
|
+
* @param {SharedRelaySession} session 会话
|
|
267
|
+
* @returns {void}
|
|
268
|
+
*/
|
|
269
|
+
function scheduleIdleDrop(relayUrl, session) {
|
|
270
|
+
if (session.subs.size || session.idleTimer) return
|
|
271
|
+
session.idleTimer = setTimeout(() => {
|
|
272
|
+
session.idleTimer = null
|
|
273
|
+
if (!isLiveSharedSession(relayUrl, session) || hasPendingWork(session)) return
|
|
274
|
+
sharedRelaySessions.delete(relayUrl)
|
|
275
|
+
clearSharedRelayReconnect(session)
|
|
276
|
+
if (session.ws) dropWebSocket(session.ws)
|
|
277
|
+
session.ws = null
|
|
278
|
+
}, NOSTR_IDLE_DROP_MS)
|
|
279
|
+
session.idleTimer.unref?.()
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* @param {string} relayUrl 中继 URL
|
|
284
|
+
* @param {SharedRelaySession} session 会话
|
|
285
|
+
* @returns {boolean} session 是否仍是该 URL 的活动会话
|
|
286
|
+
*/
|
|
287
|
+
function isLiveSharedSession(relayUrl, session) {
|
|
288
|
+
return sharedRelaySessions.get(relayUrl) === session
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* @param {SharedRelaySession} session 会话
|
|
293
|
+
* @returns {void}
|
|
294
|
+
*/
|
|
295
|
+
function clearSharedRelayReconnect(session) {
|
|
296
|
+
if (!session.reconnectTimer) return
|
|
297
|
+
clearTimeout(session.reconnectTimer)
|
|
298
|
+
session.reconnectTimer = null
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* @param {string} relayUrl 中继 URL
|
|
303
|
+
* @param {SharedRelaySession} session 会话
|
|
304
|
+
* @param {import('ws').WebSocket} ws 已打开连接
|
|
305
|
+
* @returns {void}
|
|
306
|
+
*/
|
|
307
|
+
function attachSharedRelaySocket(relayUrl, session, ws) {
|
|
308
|
+
session.ws = ws
|
|
309
|
+
// 提升监听器上限:每个并发 publish 的临时 message 监听 + 会话自身监听,避免 MaxListenersExceededWarning。
|
|
310
|
+
ws.setMaxListeners(session.pendingPublishes.length + session.inflightPublishes.length + 8)
|
|
311
|
+
nodeDebug('p2p:nostr relay up', { url: relayUrl })
|
|
312
|
+
ws.on('message', data => {
|
|
313
|
+
let parsed
|
|
314
|
+
try { parsed = JSON.parse(String(data)) } catch { return }
|
|
315
|
+
if (parsed?.[0] !== 'EVENT') return
|
|
316
|
+
const subId = String(parsed[1] || '')
|
|
317
|
+
const nostrEvent = parsed[2]
|
|
318
|
+
const sub = session.subs.get(subId)
|
|
319
|
+
if (!sub || !nostrEvent) return
|
|
320
|
+
try { sub.onEvent(nostrEvent, relayUrl) } catch { /* ignore */ }
|
|
321
|
+
})
|
|
322
|
+
ws.once('close', () => {
|
|
323
|
+
if (!isLiveSharedSession(relayUrl, session)) return
|
|
324
|
+
session.ws = null
|
|
325
|
+
// 尚未确认的 inflight publish 重新入队,重连后由 flushPendingPublishes 重发。
|
|
326
|
+
if (session.inflightPublishes.length) {
|
|
327
|
+
session.pendingPublishes.push(...session.inflightPublishes)
|
|
328
|
+
for (const publishRequest of session.inflightPublishes)
|
|
329
|
+
attachQueuedAbort(session, publishRequest)
|
|
330
|
+
session.inflightPublishes = []
|
|
331
|
+
}
|
|
332
|
+
if (!hasPendingWork(session)) {
|
|
333
|
+
sharedRelaySessions.delete(relayUrl)
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
scheduleSharedRelayConnect(relayUrl, session, NOSTR_RECONNECT_DELAY_MS)
|
|
337
|
+
})
|
|
338
|
+
for (const [subId, sub] of session.subs)
|
|
339
|
+
try { ws.send(JSON.stringify(['REQ', subId, sub.filter])) } catch { /* ignore */ }
|
|
340
|
+
flushPendingPublishes(relayUrl, session, ws)
|
|
341
|
+
if (!hasPendingWork(session)) scheduleIdleDrop(relayUrl, session)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* @param {string} relayUrl 中继 URL
|
|
346
|
+
* @param {SharedRelaySession} session 会话
|
|
347
|
+
* @param {number} [delayMs=0] 延迟毫秒(断线重连用)
|
|
348
|
+
* @returns {void}
|
|
349
|
+
*/
|
|
350
|
+
function scheduleSharedRelayConnect(relayUrl, session, delayMs = 0) {
|
|
351
|
+
if (!isLiveSharedSession(relayUrl, session) || session.connecting || session.ws) return
|
|
352
|
+
clearSharedRelayReconnect(session)
|
|
353
|
+
/** 启动/恢复共享会话连接 */
|
|
354
|
+
const start = () => {
|
|
355
|
+
session.reconnectTimer = null
|
|
356
|
+
if (!isLiveSharedSession(relayUrl, session) || session.connecting || session.ws) return
|
|
357
|
+
if (!hasPendingWork(session)) {
|
|
358
|
+
sharedRelaySessions.delete(relayUrl)
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
session.connecting = true
|
|
362
|
+
void connectRelay(relayUrl, NOSTR_CONNECT_TIMEOUT_MS, undefined, session.connectTarget).then(ws => {
|
|
363
|
+
session.connecting = false
|
|
364
|
+
if (!isLiveSharedSession(relayUrl, session) || !hasPendingWork(session)) {
|
|
365
|
+
sharedRelaySessions.delete(relayUrl)
|
|
366
|
+
dropWebSocket(ws)
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
attachSharedRelaySocket(relayUrl, session, ws)
|
|
370
|
+
}).catch(error => {
|
|
371
|
+
session.connecting = false
|
|
372
|
+
if (!isLiveSharedSession(relayUrl, session)) return
|
|
373
|
+
nodeDebug('p2p:nostr relay fail', {
|
|
374
|
+
url: relayUrl,
|
|
375
|
+
err: String(error?.message || error),
|
|
376
|
+
})
|
|
377
|
+
if (!hasPendingWork(session)) {
|
|
378
|
+
sharedRelaySessions.delete(relayUrl)
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
scheduleSharedRelayConnect(relayUrl, session, NOSTR_RECONNECT_DELAY_MS)
|
|
382
|
+
})
|
|
383
|
+
}
|
|
384
|
+
if (delayMs <= 0) {
|
|
385
|
+
// 让同一 tick 内的 registerSharedRelaySub 先写入 subs,再决定是否连接。
|
|
386
|
+
queueMicrotask(start)
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
nodeDebug('p2p:nostr relay reconnect', { url: relayUrl, delayMs })
|
|
390
|
+
session.reconnectTimer = setTimeout(start, delayMs)
|
|
391
|
+
session.reconnectTimer.unref?.()
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* @param {string} relayUrl 中继 URL
|
|
396
|
+
* @param {{ hostname: string, addresses: string[] } | ((url: string) => Promise<{ hostname: string, addresses: string[] } | null>) | null} [connectTarget] 钉 IP 用的连接目标(首个请求方提供;重连复用)
|
|
397
|
+
* @returns {SharedRelaySession} 共享会话
|
|
398
|
+
*/
|
|
399
|
+
function acquireSharedRelay(relayUrl, connectTarget) {
|
|
400
|
+
const existing = sharedRelaySessions.get(relayUrl)
|
|
401
|
+
if (existing) return existing
|
|
402
|
+
/** @type {SharedRelaySession} */
|
|
403
|
+
const session = {
|
|
404
|
+
ws: null,
|
|
405
|
+
connecting: false,
|
|
406
|
+
reconnectTimer: null,
|
|
407
|
+
idleTimer: null,
|
|
408
|
+
connectTarget: connectTarget || null,
|
|
409
|
+
subs: new Map(),
|
|
410
|
+
pendingPublishes: [],
|
|
411
|
+
inflightPublishes: [],
|
|
412
|
+
}
|
|
413
|
+
sharedRelaySessions.set(relayUrl, session)
|
|
414
|
+
scheduleSharedRelayConnect(relayUrl, session)
|
|
415
|
+
return session
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* @param {string} relayUrl 中继 URL
|
|
420
|
+
* @param {string} subscriptionId 订阅 id
|
|
421
|
+
* @returns {void}
|
|
422
|
+
*/
|
|
423
|
+
function releaseSharedRelaySub(relayUrl, subscriptionId) {
|
|
424
|
+
const session = sharedRelaySessions.get(relayUrl)
|
|
425
|
+
if (!session) return
|
|
426
|
+
session.subs.delete(subscriptionId)
|
|
427
|
+
const { ws } = session
|
|
428
|
+
if (ws?.readyState === WebSocket.OPEN)
|
|
429
|
+
try { ws.send(JSON.stringify(['CLOSE', subscriptionId])) } catch { /* ignore */ }
|
|
430
|
+
if (hasPendingWork(session)) return
|
|
431
|
+
sharedRelaySessions.delete(relayUrl)
|
|
432
|
+
clearSharedRelayReconnect(session)
|
|
433
|
+
clearIdleDrop(session)
|
|
434
|
+
if (ws) dropWebSocket(ws)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* 在已打开的共享连接上登记 REQ;连接中则等 attach 时统一重放。
|
|
439
|
+
* @param {SharedRelaySession} session 会话
|
|
440
|
+
* @param {string} subscriptionId 订阅 id
|
|
441
|
+
* @param {object} filter Nostr filter
|
|
442
|
+
* @param {(event: object, relayUrl: string) => void} onEvent 事件回调
|
|
443
|
+
* @returns {void}
|
|
444
|
+
*/
|
|
445
|
+
function registerSharedRelaySub(session, subscriptionId, filter, onEvent) {
|
|
446
|
+
session.subs.set(subscriptionId, { filter, onEvent })
|
|
447
|
+
clearIdleDrop(session)
|
|
448
|
+
const { ws } = session
|
|
449
|
+
if (ws?.readyState !== WebSocket.OPEN) return
|
|
450
|
+
try { ws.send(JSON.stringify(['REQ', subscriptionId, filter])) } catch { /* ignore */ }
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* 将已入队的 publish 逐个派发到已打开 socket 上;复用 publishEventOnRelay 等 OK 回执。
|
|
455
|
+
* 每个 publish 独立携带自己的 message/abort 监听,socket 共享安全。
|
|
456
|
+
* @param {string} relayUrl 中继 URL
|
|
457
|
+
* @param {SharedRelaySession} session 会话
|
|
458
|
+
* @param {import('ws').WebSocket} socket 已打开连接
|
|
459
|
+
* @returns {void}
|
|
460
|
+
*/
|
|
461
|
+
function flushPendingPublishes(relayUrl, session, socket) {
|
|
462
|
+
const pending = session.pendingPublishes
|
|
463
|
+
session.pendingPublishes = []
|
|
464
|
+
for (const publishRequest of pending) {
|
|
465
|
+
publishRequest.removeAbort?.()
|
|
466
|
+
publishRequest.onAbort = null
|
|
467
|
+
publishRequest.removeAbort = null
|
|
468
|
+
const attempt = ++publishRequest.attempt
|
|
469
|
+
session.inflightPublishes.push(publishRequest)
|
|
470
|
+
void publishEventOnRelay(socket, relayUrl, publishRequest.event, publishRequest.signal, () => publishRequest.attempt === attempt)
|
|
471
|
+
.then(
|
|
472
|
+
ok => settleInflightPublish(relayUrl, session, publishRequest, attempt, () => publishRequest.resolve(ok)),
|
|
473
|
+
error => settleInflightPublish(relayUrl, session, publishRequest, attempt, () => publishRequest.reject(error)),
|
|
474
|
+
)
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* 为队列中的 publish 挂上 abort 处理:先移除监听器(释放 signal 对回调/event/session 的引用),
|
|
480
|
+
* 再从队列删除并 reject。
|
|
481
|
+
* @param {SharedRelaySession} session 会话
|
|
482
|
+
* @param {SharedRelaySession['pendingPublishes'][number]} publishRequest 待发请求
|
|
483
|
+
* @returns {void}
|
|
484
|
+
*/
|
|
485
|
+
function attachQueuedAbort(session, publishRequest) {
|
|
486
|
+
const { signal, reject } = publishRequest
|
|
487
|
+
/**
|
|
488
|
+
* 处理 abort 事件
|
|
489
|
+
*/
|
|
490
|
+
publishRequest.onAbort = () => {
|
|
491
|
+
publishRequest.removeAbort?.()
|
|
492
|
+
const pendingIndex = session.pendingPublishes.indexOf(publishRequest)
|
|
493
|
+
if (pendingIndex < 0) return
|
|
494
|
+
session.pendingPublishes.splice(pendingIndex, 1)
|
|
495
|
+
reject(new Error('nostr: aborted'))
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* 移除 abort 监听器
|
|
499
|
+
*/
|
|
500
|
+
publishRequest.removeAbort = () => {
|
|
501
|
+
if (signal) signal.removeEventListener('abort', publishRequest.onAbort)
|
|
502
|
+
}
|
|
503
|
+
if (signal) signal.addEventListener('abort', publishRequest.onAbort, { once: true })
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* publish 在 socket 上得到 OK / 失败 / abort 后结算:从 inflight 移除并 settle。
|
|
508
|
+
* 仅结算仍与当前 attempt 匹配的请求:断线重发后 attempt 已递增,旧尝试的结算在此短路,
|
|
509
|
+
* 避免提前结算重发后的当前尝试。已被 requeue(断线重发)的请求不在 inflight,跳过结算。
|
|
510
|
+
* @param {string} relayUrl 中继 URL
|
|
511
|
+
* @param {SharedRelaySession} session 会话
|
|
512
|
+
* @param {SharedRelaySession['inflightPublishes'][number]} publishRequest 已发送请求
|
|
513
|
+
* @param {number} attempt 本次发送的 attempt 标识
|
|
514
|
+
* @param {() => void} settle 结算回调(resolve/reject)
|
|
515
|
+
* @returns {void}
|
|
516
|
+
*/
|
|
517
|
+
function settleInflightPublish(relayUrl, session, publishRequest, attempt, settle) {
|
|
518
|
+
if (publishRequest.attempt !== attempt) return
|
|
519
|
+
const inflightIndex = session.inflightPublishes.indexOf(publishRequest)
|
|
520
|
+
if (inflightIndex < 0) return
|
|
521
|
+
session.inflightPublishes.splice(inflightIndex, 1)
|
|
522
|
+
settle()
|
|
523
|
+
if (!hasPendingWork(session)) scheduleIdleDrop(relayUrl, session)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* 通过共享 relay 会话发布 EVENT:复用已打开 socket,避免每次 send 重开一条连接(内存泄漏)。
|
|
528
|
+
* 无现成连接时入队并触发连接,连上后由 flushPendingPublishes 统一派发。
|
|
529
|
+
* @param {string} relayUrl 中继 URL
|
|
530
|
+
* @param {object} event 待发布事件
|
|
531
|
+
* @param {AbortSignal} [signal] 取消信号
|
|
532
|
+
* @param {{ hostname: string, addresses: string[] } | ((url: string) => Promise<{ hostname: string, addresses: string[] } | null>) | null} [connectTarget] 钉 IP 用的连接目标
|
|
533
|
+
* @returns {Promise<boolean>} relay 是否接受 EVENT
|
|
534
|
+
*/
|
|
535
|
+
export function publishViaSharedRelay(relayUrl, event, signal, connectTarget) {
|
|
536
|
+
return new Promise((resolve, reject) => {
|
|
537
|
+
if (signal?.aborted) {
|
|
538
|
+
reject(new Error('nostr: aborted'))
|
|
539
|
+
return
|
|
540
|
+
}
|
|
541
|
+
const session = acquireSharedRelay(relayUrl, connectTarget)
|
|
542
|
+
clearIdleDrop(session)
|
|
543
|
+
/** @type {SharedRelaySession['pendingPublishes'][number]} */
|
|
544
|
+
const publishRequest = { event, signal, attempt: 0, resolve, reject, onAbort: null, removeAbort: null }
|
|
545
|
+
attachQueuedAbort(session, publishRequest)
|
|
546
|
+
session.pendingPublishes.push(publishRequest)
|
|
547
|
+
if (session.ws?.readyState === WebSocket.OPEN)
|
|
548
|
+
flushPendingPublishes(relayUrl, session, session.ws)
|
|
549
|
+
else
|
|
550
|
+
scheduleSharedRelayConnect(relayUrl, session)
|
|
551
|
+
})
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* 内部:按 rendezvous 键订阅 Nostr kind(topic 不导出)。多订阅共享每 URL 一条连接。
|
|
556
|
+
* @param {string[]} relayUrls 中继 URL 列表
|
|
557
|
+
* @param {{ kind: number, rendezvousKey: string, tagX: string, onPayload: (bytes: Uint8Array, meta: { relayUrl: string, event: object }) => void | Promise<void>, addressable?: boolean, resolveConnectTarget?: (url: string) => Promise<{ hostname: string, addresses: string[] } | null> }} options 订阅选项
|
|
558
|
+
* @returns {() => void} 取消订阅
|
|
559
|
+
*/
|
|
560
|
+
export function subscribeNostrKind(relayUrls, options) {
|
|
561
|
+
const { kind, rendezvousKey, tagX, onPayload, addressable = false, resolveConnectTarget } = options
|
|
562
|
+
const subscriptionId = randomBytes(8).toString('hex')
|
|
563
|
+
const filter = { kinds: [kind], '#t': [rendezvousKey], '#x': [tagX] }
|
|
564
|
+
if (addressable) filter['#d'] = [rendezvousKey]
|
|
565
|
+
/**
|
|
566
|
+
* @param {object} nostrEvent Nostr EVENT
|
|
567
|
+
* @param {string} relayUrl 来源中继
|
|
568
|
+
* @returns {void}
|
|
569
|
+
*/
|
|
570
|
+
const onEvent = (nostrEvent, relayUrl) => {
|
|
571
|
+
if (nostrEvent?.kind !== kind) return
|
|
572
|
+
try {
|
|
573
|
+
const result = onPayload(base64ToBytes(nostrEvent.content), { relayUrl, event: nostrEvent })
|
|
574
|
+
if (result?.then) void result.catch(() => { })
|
|
575
|
+
}
|
|
576
|
+
catch { /* ignore */ }
|
|
577
|
+
}
|
|
578
|
+
const urls = dedupeRelayUrls(relayUrls)
|
|
579
|
+
for (const relayUrl of urls)
|
|
580
|
+
registerSharedRelaySub(acquireSharedRelay(relayUrl, resolveConnectTarget), subscriptionId, filter, onEvent)
|
|
581
|
+
return () => {
|
|
582
|
+
for (const relayUrl of urls) releaseSharedRelaySub(relayUrl, subscriptionId)
|
|
583
|
+
}
|
|
584
|
+
}
|
package/docs/evfs.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# EVFS
|
|
1
|
+
# EVFS manifests & fetch
|
|
2
2
|
|
|
3
3
|
Day-to-day package rules: [AGENTS.md](../AGENTS.md). Implementation: `files/` (`evfs`, `evfs_ref`, `chunk/*`, `manifest/*`).
|
|
4
4
|
|
|
@@ -16,17 +16,26 @@ Day-to-day package rules: [AGENTS.md](../AGENTS.md). Implementation: `files/` (`
|
|
|
16
16
|
- Signature covers **content fields only**. After verify, drop incoming `meta` except `publicSig`.
|
|
17
17
|
- Profile / avatar meaning belongs in the shell — not in this package.
|
|
18
18
|
|
|
19
|
-
## `
|
|
19
|
+
## `fetchManifest`
|
|
20
20
|
|
|
21
21
|
| Case | Behavior |
|
|
22
22
|
| --- | --- |
|
|
23
|
-
| Default options |
|
|
24
|
-
| Local hit with `publicSig` | Return immediately; fanout revalidates in the background. With `cache: true`, write only when remote `publishedAt` is newer |
|
|
25
|
-
|
|
|
26
|
-
|
|
|
23
|
+
| Default options | Node-scope fanout; **only** signed public manifests accepted; no cache write |
|
|
24
|
+
| Local public hit with `publicSig` | Return immediately; fanout revalidates in the background. With `cache: true`, write only when remote `publishedAt` is newer |
|
|
25
|
+
| Local non-public hit | Return immediately (**targeted mode only**); no revalidation (no publish order to compare); public mode refuses non-public local |
|
|
26
|
+
| `fanoutTargets` given | Fanout **only** to that node set; accepts signed public **or** normalized non-public manifests; non-public hit is cached locally by default |
|
|
27
|
+
| Same key in flight | Deduped via `utils/inflight_table` (key includes `public`/`targeted` mode) |
|
|
27
28
|
|
|
28
29
|
Outer caller timeouts must **not** abort the in-flight work — background fill continues after the caller gives up.
|
|
29
30
|
|
|
31
|
+
## Non-public (ACL-gated) manifests
|
|
32
|
+
|
|
33
|
+
- `file-master-key-wrap` / `vault-wrap` / `identity-wrap` manifests carry no author signature, so the remote-acquisition trust boundary is **not** a signature — it is the **fanout target set** plus the **serving node's authorization**.
|
|
34
|
+
- Client: the caller passes the authorized node set (group roster / cabinet members) as `fanoutTargets`. `fed_manifest_get` is sent only to those nodes; a response is accepted when it normalizes, its `ownerEntityHash`+`logicalPath` match the request, and its authenticated `senderNodeHash` belongs to the target set.
|
|
35
|
+
- Server: `handleIncomingManifestGet` refuses non-public manifests unless a **servicer** is registered for the type. Shells call `registerManifestServicer(type, ownerId, handler)`; the handler receives `{ manifest, ownerEntityHash, logicalPath, requesterNodeHash, peerId, payload }` and returns a boolean. `requesterNodeHash` is **always** the transport-authenticated sender nodeHash (`peerId`); the client no longer self-asserts a `nodeHash` in `fed_manifest_get`, so the fanout target node can never be mistaken for the requester — authorization must still be enforced from the trust graph / group membership.
|
|
36
|
+
- Non-public responses carry the **full manifest including `meta`** (public responses strip meta to `publicSig`): the reader needs `meta.dagParts` / `meta.groupId` to read DAG plaintext.
|
|
37
|
+
- Confidentiality does not rely on manifest secrecy: ciphertext blocks are content-addressed, AES-GCM authenticated, and decryption requires a wrap key held by the shell. A forged manifest can at worst fail the read (DoS), never decrypt content.
|
|
38
|
+
|
|
30
39
|
## ACL
|
|
31
40
|
|
|
32
41
|
Shells register matchers via the ACL registry. Core does not hard-code chat/social entity types.
|