@steve02081504/fount-p2p 0.0.10 → 0.0.12
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 +25 -7
- package/core/bytes_codec.mjs +65 -10
- package/core/tcp_port.mjs +1 -1
- package/crypto/checkpoint_sign.mjs +2 -2
- package/dag/canonicalize_row.mjs +3 -3
- package/dag/index.mjs +2 -3
- package/discovery/bt/index.mjs +5 -5
- package/discovery/bt/probe_child.mjs +20 -0
- package/discovery/bt/runtime.mjs +59 -15
- package/discovery/index.mjs +45 -48
- package/discovery/mdns.mjs +72 -17
- package/discovery/nostr.mjs +165 -129
- package/federation/chunk_fetch_pending.mjs +4 -5
- package/federation/dag_order_cache.mjs +1 -1
- package/federation/entity_key_chain.mjs +3 -4
- package/federation/topo_order_memo.mjs +11 -4
- package/files/chunk_fetch.mjs +2 -2
- package/files/evfs.mjs +2 -2
- package/link/channel_mux.mjs +10 -10
- package/link/frame.mjs +76 -124
- package/link/handshake.mjs +5 -5
- package/link/pipe.mjs +49 -75
- package/link/providers/ble_gatt.mjs +15 -27
- package/link/providers/index.mjs +7 -9
- package/link/providers/lan_tcp.mjs +18 -33
- package/link/providers/link_id_pipe.mjs +46 -0
- package/link/providers/webrtc.mjs +43 -52
- package/link/rtc.mjs +21 -9
- package/mailbox/deliver_or_store.mjs +1 -1
- package/node/identity.mjs +4 -4
- package/node/network.mjs +9 -9
- package/overlay/index.mjs +13 -13
- package/package.json +3 -2
- package/rooms/scoped_link.mjs +22 -38
- 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 +20 -0
- package/transport/group_link_set.mjs +26 -45
- package/transport/ice_servers.mjs +12 -3
- package/transport/link_registry.mjs +181 -523
- package/transport/offer_answer.mjs +194 -0
- package/transport/peer_pool.mjs +53 -64
- package/transport/remote_user_room.mjs +13 -15
- package/transport/rtc_connection_budget.mjs +18 -4
- package/transport/runtime_bootstrap.mjs +369 -0
- package/transport/signal_crypto.mjs +104 -0
- package/transport/user_room.mjs +22 -15
- package/trust_graph/send.mjs +1 -1
- package/utils/emit_safe.mjs +11 -0
- package/utils/shuffle.mjs +13 -0
- package/utils/ttl_map.mjs +29 -4
- package/wire/ingress.mjs +1 -1
- package/wire/part_ingress.mjs +6 -8
- package/wire/part_invoke.mjs +4 -4
- package/wire/part_query.mjs +2 -3
- 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
CHANGED
|
@@ -6,6 +6,7 @@ const DEFAULT_GROUP = '239.255.42.99'
|
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* 轻量 multicast 发现插件:不做完整 DNS-SD,只复用 mDNS 的 LAN multicast 发现思路。
|
|
9
|
+
* 引用计数为 0 时关闭 UDP socket,避免 shutdown 后句柄拖住进程。
|
|
9
10
|
*
|
|
10
11
|
* @param {{ port?: number, group?: string }} [options] 组播端口与组地址
|
|
11
12
|
* @returns {import('./index.mjs').DiscoveryProvider} mDNS 发现提供者
|
|
@@ -13,14 +14,24 @@ const DEFAULT_GROUP = '239.255.42.99'
|
|
|
13
14
|
export function createMdnsDiscoveryProvider(options = {}) {
|
|
14
15
|
const port = Number(options.port) || DEFAULT_PORT
|
|
15
16
|
const group = String(options.group || DEFAULT_GROUP)
|
|
16
|
-
|
|
17
|
+
/** @type {import('node:dgram').Socket | null} */
|
|
18
|
+
let socket = null
|
|
17
19
|
let bound = false
|
|
18
20
|
let bindPromise = null
|
|
21
|
+
let refs = 0
|
|
19
22
|
/** @type {Map<string, Set<Function>>} */
|
|
20
23
|
const advertListeners = new Map()
|
|
21
24
|
/** @type {Map<string, Set<Function>>} */
|
|
22
25
|
const signalListeners = new Map()
|
|
23
26
|
|
|
27
|
+
/**
|
|
28
|
+
* 懒创建或返回当前 UDP socket。
|
|
29
|
+
* @returns {import('node:dgram').Socket} 用于组播 bind/send 的 socket
|
|
30
|
+
*/
|
|
31
|
+
function getSocket() {
|
|
32
|
+
return socket ||= dgram.createSocket({ type: 'udp4', reuseAddr: true })
|
|
33
|
+
}
|
|
34
|
+
|
|
24
35
|
/**
|
|
25
36
|
* 绑定 UDP socket 并注册组播消息处理器。
|
|
26
37
|
* @returns {Promise<void>}
|
|
@@ -29,16 +40,24 @@ export function createMdnsDiscoveryProvider(options = {}) {
|
|
|
29
40
|
if (bound) return
|
|
30
41
|
if (!bindPromise)
|
|
31
42
|
bindPromise = (async () => {
|
|
43
|
+
const sock = getSocket()
|
|
32
44
|
await new Promise((resolve, reject) => {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
45
|
+
sock.once('error', reject)
|
|
46
|
+
sock.bind(port, '0.0.0.0', () => {
|
|
47
|
+
sock.off('error', reject)
|
|
48
|
+
sock.addMembership(group)
|
|
49
|
+
sock.setMulticastTTL(1)
|
|
38
50
|
resolve()
|
|
39
51
|
})
|
|
40
52
|
})
|
|
41
|
-
|
|
53
|
+
// acquire 已全部释放:关掉刚 bind 的 socket,避免泄漏句柄。
|
|
54
|
+
if (refs <= 0) {
|
|
55
|
+
try { sock.close() } catch { /* ignore */ }
|
|
56
|
+
if (socket === sock) socket = null
|
|
57
|
+
bound = false
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
sock.on('message', (raw, rinfo) => {
|
|
42
61
|
let packet
|
|
43
62
|
try { packet = JSON.parse(String(raw)) } catch { return }
|
|
44
63
|
const listeners = packet.type === 'advert'
|
|
@@ -70,16 +89,34 @@ export function createMdnsDiscoveryProvider(options = {}) {
|
|
|
70
89
|
*/
|
|
71
90
|
async function multicast(type, topic, bytes) {
|
|
72
91
|
await ensureBound()
|
|
92
|
+
const sock = getSocket()
|
|
73
93
|
const packet = Buffer.from(JSON.stringify({
|
|
74
94
|
type,
|
|
75
95
|
topic,
|
|
76
96
|
data: Buffer.from(bytes).toString('base64'),
|
|
77
97
|
}))
|
|
78
98
|
await new Promise((resolve, reject) => {
|
|
79
|
-
|
|
99
|
+
sock.send(packet, port, group, error => error ? reject(error) : resolve())
|
|
80
100
|
})
|
|
81
101
|
}
|
|
82
102
|
|
|
103
|
+
/**
|
|
104
|
+
* 占用 socket;归还后若无引用则关闭。
|
|
105
|
+
* @returns {() => void} 释放函数
|
|
106
|
+
*/
|
|
107
|
+
function acquire() {
|
|
108
|
+
refs++
|
|
109
|
+
void ensureBound().catch(() => { })
|
|
110
|
+
return () => {
|
|
111
|
+
refs = Math.max(0, refs - 1)
|
|
112
|
+
if (refs > 0 || !socket) return
|
|
113
|
+
try { socket.close() } catch { /* ignore */ }
|
|
114
|
+
socket = null
|
|
115
|
+
bound = false
|
|
116
|
+
bindPromise = null
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
83
120
|
/**
|
|
84
121
|
* 向 topic bucket 注册监听器。
|
|
85
122
|
* @param {Map<string, Set<Function>>} bucket topic → 监听器集合
|
|
@@ -90,7 +127,17 @@ export function createMdnsDiscoveryProvider(options = {}) {
|
|
|
90
127
|
function addListener(bucket, topic, listener) {
|
|
91
128
|
if (!bucket.has(topic)) bucket.set(topic, new Set())
|
|
92
129
|
bucket.get(topic).add(listener)
|
|
93
|
-
|
|
130
|
+
const release = acquire()
|
|
131
|
+
return () => {
|
|
132
|
+
const set = bucket.get(topic)
|
|
133
|
+
if (!set) {
|
|
134
|
+
release()
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
set.delete(listener)
|
|
138
|
+
if (!set.size) bucket.delete(topic)
|
|
139
|
+
release()
|
|
140
|
+
}
|
|
94
141
|
}
|
|
95
142
|
|
|
96
143
|
return {
|
|
@@ -98,24 +145,27 @@ export function createMdnsDiscoveryProvider(options = {}) {
|
|
|
98
145
|
priority: 10,
|
|
99
146
|
caps: { canDiscover: true, canSignal: true, canRelay: false },
|
|
100
147
|
/**
|
|
101
|
-
* 周期性组播广播 advert
|
|
148
|
+
* 周期性组播广播 advert(首发后台,不阻塞调用方)。
|
|
102
149
|
* @param {string} topic advert 主题
|
|
103
150
|
* @param {Uint8Array} bytes advert 载荷
|
|
104
151
|
* @returns {Promise<() => void>} 取消广播函数
|
|
105
152
|
*/
|
|
106
153
|
async advertise(topic, bytes) {
|
|
107
|
-
|
|
154
|
+
const release = acquire()
|
|
155
|
+
void multicast('advert', topic, bytes).catch(() => { })
|
|
108
156
|
const timer = setInterval(() => { void multicast('advert', topic, bytes).catch(() => { }) }, 30_000)
|
|
109
|
-
return () =>
|
|
157
|
+
return () => {
|
|
158
|
+
clearInterval(timer)
|
|
159
|
+
release()
|
|
160
|
+
}
|
|
110
161
|
},
|
|
111
162
|
/**
|
|
112
|
-
* 订阅组播 advert
|
|
163
|
+
* 订阅组播 advert(立即返回;UDP bind 后台完成)。
|
|
113
164
|
* @param {string} topic advert 主题
|
|
114
165
|
* @param {Function} onAdvert advert 回调
|
|
115
166
|
* @returns {Promise<() => void>} 取消订阅函数
|
|
116
167
|
*/
|
|
117
168
|
async subscribe(topic, onAdvert) {
|
|
118
|
-
await ensureBound()
|
|
119
169
|
return addListener(advertListeners, topic, onAdvert)
|
|
120
170
|
},
|
|
121
171
|
/**
|
|
@@ -126,16 +176,21 @@ export function createMdnsDiscoveryProvider(options = {}) {
|
|
|
126
176
|
* @returns {Promise<void>}
|
|
127
177
|
*/
|
|
128
178
|
async sendSignal(topic, _to, bytes) {
|
|
129
|
-
|
|
179
|
+
const release = acquire()
|
|
180
|
+
try {
|
|
181
|
+
await multicast('signal', topic, bytes)
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
release()
|
|
185
|
+
}
|
|
130
186
|
},
|
|
131
187
|
/**
|
|
132
|
-
*
|
|
188
|
+
* 订阅组播信令(立即返回;UDP bind 后台完成)。
|
|
133
189
|
* @param {string} topic 信令 topic
|
|
134
190
|
* @param {Function} onSignal 信令回调
|
|
135
191
|
* @returns {Promise<() => void>} 取消订阅函数
|
|
136
192
|
*/
|
|
137
193
|
async onSignal(topic, onSignal) {
|
|
138
|
-
await ensureBound()
|
|
139
194
|
return addListener(signalListeners, topic, onSignal)
|
|
140
195
|
},
|
|
141
196
|
}
|
package/discovery/nostr.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto'
|
|
2
2
|
|
|
3
3
|
import { schnorr } from '@noble/curves/secp256k1.js'
|
|
4
|
+
import WebSocket from 'ws'
|
|
4
5
|
|
|
6
|
+
import { base64ToBytes, hexToBytes, bytesToBase64, bytesToHex } from '../core/bytes_codec.mjs'
|
|
5
7
|
import { sha256Hex } from '../crypto/crypto.mjs'
|
|
6
8
|
|
|
7
9
|
/** 默认 Nostr 中继 URL 列表。 */
|
|
@@ -11,67 +13,64 @@ export const DEFAULT_RELAY_URLS = [
|
|
|
11
13
|
'wss://relay.nostr.band',
|
|
12
14
|
]
|
|
13
15
|
|
|
16
|
+
/** 单中继 WebSocket 首连超时(短超时 + 并行,避免串行 10s×N)。 */
|
|
17
|
+
export const NOSTR_CONNECT_TIMEOUT_MS = 2_000
|
|
18
|
+
/** 先 close,超时未 CLOSED 再 terminate(给对端优雅关闭的窗口)。 */
|
|
19
|
+
export const NOSTR_CLOSE_GRACE_MS = 1_000
|
|
20
|
+
|
|
14
21
|
/** Nostr advert 事件 kind。 */
|
|
15
22
|
export const NOSTR_ADVERT_KIND = 27235
|
|
16
23
|
/** Nostr signal 事件 kind。 */
|
|
17
24
|
export const NOSTR_SIGNAL_KIND = 27236
|
|
18
25
|
|
|
19
26
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* @
|
|
27
|
+
* 关掉 WebSocket:已连上则先 close,grace 内未 CLOSED 再 terminate。
|
|
28
|
+
* 连接中直接 terminate(无握手可优雅收尾)。
|
|
29
|
+
* @param {import('ws').WebSocket} ws 连接
|
|
30
|
+
* @returns {void}
|
|
23
31
|
*/
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
function dropWebSocket(ws) {
|
|
33
|
+
if (ws.readyState === WebSocket.CLOSED) return
|
|
34
|
+
if (ws.readyState === WebSocket.CONNECTING) {
|
|
35
|
+
try { ws.terminate() } catch { /* ignore */ }
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
const timer = setTimeout(() => {
|
|
39
|
+
if (ws.readyState !== WebSocket.CLOSED)
|
|
40
|
+
try { ws.terminate() } catch { /* ignore */ }
|
|
41
|
+
}, NOSTR_CLOSE_GRACE_MS)
|
|
42
|
+
// Grace is polite to the peer; must not pin the process after shutdown.
|
|
43
|
+
timer.unref()
|
|
44
|
+
ws.once('close', () => clearTimeout(timer))
|
|
45
|
+
try {
|
|
46
|
+
ws.close()
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
clearTimeout(timer)
|
|
50
|
+
try { ws.terminate() } catch { /* ignore */ }
|
|
33
51
|
}
|
|
34
|
-
return merged.length ? merged : [...DEFAULT_RELAY_URLS]
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* 字节数组转十六进制字符串。
|
|
39
|
-
* @param {Uint8Array} bytes 输入字节
|
|
40
|
-
* @returns {string} 小写 hex 字符串
|
|
41
|
-
*/
|
|
42
|
-
function bytesToHex(bytes) {
|
|
43
|
-
return [...bytes].map(byte => byte.toString(16).padStart(2, '0')).join('')
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* 十六进制字符串转字节数组。
|
|
48
|
-
* @param {string} hex 输入 hex 字符串
|
|
49
|
-
* @returns {Uint8Array} 解码后的字节
|
|
50
|
-
*/
|
|
51
|
-
function hexToBytes(hex) {
|
|
52
|
-
const normalized = String(hex || '').trim().toLowerCase()
|
|
53
|
-
const out = new Uint8Array(Math.floor(normalized.length / 2))
|
|
54
|
-
for (let index = 0; index < out.length; index++)
|
|
55
|
-
out[index] = parseInt(normalized.slice(index * 2, index * 2 + 2), 16)
|
|
56
|
-
return out
|
|
57
52
|
}
|
|
58
53
|
|
|
59
54
|
/**
|
|
60
|
-
*
|
|
61
|
-
* @param {
|
|
62
|
-
* @returns {string}
|
|
55
|
+
* 去重并清洗中继 URL 列表。
|
|
56
|
+
* @param {string[] | undefined | null} urls 原始列表
|
|
57
|
+
* @returns {string[]} 清洗后的列表
|
|
63
58
|
*/
|
|
64
|
-
function
|
|
65
|
-
|
|
59
|
+
function dedupeRelayUrls(urls) {
|
|
60
|
+
const seen = new Set()
|
|
61
|
+
return (urls || [])
|
|
62
|
+
.map(url => url.trim())
|
|
63
|
+
.filter(trimmed => trimmed && !seen.has(trimmed) && (seen.add(trimmed), true))
|
|
66
64
|
}
|
|
67
65
|
|
|
68
66
|
/**
|
|
69
|
-
*
|
|
70
|
-
* @param {string
|
|
71
|
-
* @returns {
|
|
67
|
+
* 合并默认与用户配置的中继 URL(去重)。
|
|
68
|
+
* @param {string[] | undefined | null} userRelayUrls 用户自定义中继列表
|
|
69
|
+
* @returns {string[]} 合并后的中继 URL 列表
|
|
72
70
|
*/
|
|
73
|
-
function
|
|
74
|
-
|
|
71
|
+
export function mergeSignalingRelayUrls(userRelayUrls) {
|
|
72
|
+
const merged = dedupeRelayUrls([...DEFAULT_RELAY_URLS, ...userRelayUrls || []])
|
|
73
|
+
return merged.length ? merged : [...DEFAULT_RELAY_URLS]
|
|
75
74
|
}
|
|
76
75
|
|
|
77
76
|
/**
|
|
@@ -92,40 +91,67 @@ async function signNostrEvent(kind, tags, content, secretKey) {
|
|
|
92
91
|
}
|
|
93
92
|
|
|
94
93
|
/**
|
|
95
|
-
* 连接 Nostr 中继 WebSocket
|
|
94
|
+
* 连接 Nostr 中继 WebSocket(`ws` 包,支持 terminate)。
|
|
96
95
|
* @param {string} relayUrl 中继 URL
|
|
97
|
-
* @
|
|
96
|
+
* @param {number} [timeoutMs] 超时毫秒
|
|
97
|
+
* @param {AbortSignal} [signal] 取消信号
|
|
98
|
+
* @returns {Promise<import('ws').WebSocket>} 已打开的 WebSocket
|
|
98
99
|
*/
|
|
99
|
-
function connectRelay(relayUrl) {
|
|
100
|
+
function connectRelay(relayUrl, timeoutMs = NOSTR_CONNECT_TIMEOUT_MS, signal) {
|
|
100
101
|
return new Promise((resolve, reject) => {
|
|
102
|
+
if (signal?.aborted) {
|
|
103
|
+
reject(new Error('nostr: aborted'))
|
|
104
|
+
return
|
|
105
|
+
}
|
|
101
106
|
const ws = new WebSocket(relayUrl)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
+
let settled = false
|
|
108
|
+
/**
|
|
109
|
+
* @param {Error} error 失败原因
|
|
110
|
+
* @returns {void}
|
|
111
|
+
*/
|
|
112
|
+
const fail = error => {
|
|
113
|
+
if (settled) return
|
|
114
|
+
settled = true
|
|
107
115
|
clearTimeout(timer)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
116
|
+
signal?.removeEventListener('abort', onAbort)
|
|
117
|
+
dropWebSocket(ws)
|
|
118
|
+
reject(error)
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* @returns {void}
|
|
122
|
+
*/
|
|
123
|
+
const onAbort = () => fail(new Error('nostr: aborted'))
|
|
124
|
+
const timer = setTimeout(() => fail(new Error(`nostr: connect timeout for ${relayUrl}`)), timeoutMs)
|
|
125
|
+
timer.unref()
|
|
126
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
127
|
+
ws.once('open', () => {
|
|
128
|
+
if (settled) return
|
|
129
|
+
settled = true
|
|
111
130
|
clearTimeout(timer)
|
|
112
|
-
|
|
113
|
-
|
|
131
|
+
signal?.removeEventListener('abort', onAbort)
|
|
132
|
+
resolve(ws)
|
|
133
|
+
})
|
|
134
|
+
ws.once('error', () => {
|
|
135
|
+
fail(new Error(`nostr: websocket error for ${relayUrl}`))
|
|
136
|
+
})
|
|
114
137
|
})
|
|
115
138
|
}
|
|
116
139
|
|
|
117
140
|
/**
|
|
118
|
-
*
|
|
141
|
+
* 向多个中继并行发布 Nostr 事件(任一成功即可)。
|
|
119
142
|
* @param {string[]} relayUrls 中继 URL 列表
|
|
120
143
|
* @param {object} event 待发布事件
|
|
144
|
+
* @param {AbortSignal} [signal] 取消信号
|
|
121
145
|
* @returns {Promise<void>}
|
|
122
146
|
*/
|
|
123
|
-
async function publishEvent(relayUrls, event) {
|
|
147
|
+
async function publishEvent(relayUrls, event, signal) {
|
|
124
148
|
let published = false
|
|
125
149
|
let lastError = null
|
|
126
150
|
await Promise.allSettled(relayUrls.map(async relayUrl => {
|
|
127
|
-
|
|
151
|
+
if (signal?.aborted) throw new Error('nostr: aborted')
|
|
152
|
+
const ws = await connectRelay(relayUrl, NOSTR_CONNECT_TIMEOUT_MS, signal)
|
|
128
153
|
try {
|
|
154
|
+
if (signal?.aborted) throw new Error('nostr: aborted')
|
|
129
155
|
ws.send(JSON.stringify(['EVENT', event]))
|
|
130
156
|
published = true
|
|
131
157
|
}
|
|
@@ -134,91 +160,123 @@ async function publishEvent(relayUrls, event) {
|
|
|
134
160
|
throw error
|
|
135
161
|
}
|
|
136
162
|
finally {
|
|
137
|
-
|
|
163
|
+
dropWebSocket(ws)
|
|
138
164
|
}
|
|
139
165
|
}))
|
|
140
166
|
if (!published) throw lastError || new Error('nostr: no relay accepted publish')
|
|
141
167
|
}
|
|
142
168
|
|
|
169
|
+
/**
|
|
170
|
+
* 并行渐进连接中继:立刻返回,连上后回调;abort 后 stop 接入并 drop 已开 socket。
|
|
171
|
+
* @param {string[]} relayUrls 中继列表
|
|
172
|
+
* @param {(ws: import('ws').WebSocket, relayUrl: string) => void} onOpen 连上回调
|
|
173
|
+
* @param {AbortSignal} signal 取消信号
|
|
174
|
+
* @param {import('ws').WebSocket[]} sockets 已打开 socket 收集
|
|
175
|
+
* @returns {void}
|
|
176
|
+
*/
|
|
177
|
+
function connectRelaysProgressive(relayUrls, onOpen, signal, sockets) {
|
|
178
|
+
for (const relayUrl of relayUrls)
|
|
179
|
+
void connectRelay(relayUrl, NOSTR_CONNECT_TIMEOUT_MS, signal).then(ws => {
|
|
180
|
+
if (signal.aborted) {
|
|
181
|
+
dropWebSocket(ws)
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
sockets.push(ws)
|
|
185
|
+
onOpen(ws, relayUrl)
|
|
186
|
+
}).catch(() => { /* 单路失败正常降级 */ })
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 订阅指定 kind 的 Nostr 事件(渐进连中继,立即返回 cleanup)。
|
|
191
|
+
* @param {string[]} relayUrls 中继 URL 列表
|
|
192
|
+
* @param {{ kind: number, topic: string, tagX: string, onPayload: (bytes: Uint8Array, meta: { relayUrl: string, event: object }) => void }} options 订阅选项
|
|
193
|
+
* @returns {() => void} 取消订阅
|
|
194
|
+
*/
|
|
195
|
+
function subscribeNostrKind(relayUrls, options) {
|
|
196
|
+
const { kind, topic, tagX, onPayload } = options
|
|
197
|
+
const abortController = new AbortController()
|
|
198
|
+
/** @type {import('ws').WebSocket[]} */
|
|
199
|
+
const sockets = []
|
|
200
|
+
const subscriptionId = randomBytes(8).toString('hex')
|
|
201
|
+
connectRelaysProgressive(relayUrls, (ws, relayUrl) => {
|
|
202
|
+
ws.on('message', data => {
|
|
203
|
+
if (abortController.signal.aborted) return
|
|
204
|
+
let parsed
|
|
205
|
+
try { parsed = JSON.parse(String(data)) } catch { return }
|
|
206
|
+
if (parsed?.[0] !== 'EVENT') return
|
|
207
|
+
const nostrEvent = parsed[2]
|
|
208
|
+
if (nostrEvent?.kind !== kind) return
|
|
209
|
+
try { onPayload(base64ToBytes(nostrEvent.content), { relayUrl, event: nostrEvent }) }
|
|
210
|
+
catch { /* ignore */ }
|
|
211
|
+
})
|
|
212
|
+
ws.send(JSON.stringify(['REQ', subscriptionId, { kinds: [kind], '#t': [topic], '#x': [tagX] }]))
|
|
213
|
+
}, abortController.signal, sockets)
|
|
214
|
+
return () => {
|
|
215
|
+
abortController.abort()
|
|
216
|
+
for (const ws of sockets) dropWebSocket(ws)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
143
220
|
/**
|
|
144
221
|
* 创建 Nostr discovery provider。
|
|
145
|
-
*
|
|
222
|
+
* subscribe/onSignal/advertise 首调用不阻塞等公网中继;连上后渐进生效。
|
|
223
|
+
* `options.relayUrls` 为最终列表(调用方 `mergeSignalingRelayUrls`,或直接传入 `relayOverride`)。
|
|
224
|
+
* 省略 / `undefined` → 默认公网中继;显式 `[]` 表示无中继(不再回填默认)。
|
|
225
|
+
* @param {{ relayUrls?: string[] | null }} [options] 可选最终中继 URL 列表
|
|
146
226
|
* @returns {import('./index.mjs').DiscoveryProvider} Nostr 发现提供者
|
|
147
227
|
*/
|
|
148
228
|
export function createNostrDiscoveryProvider(options = {}) {
|
|
149
|
-
const relayUrls =
|
|
229
|
+
const relayUrls = dedupeRelayUrls(options.relayUrls || DEFAULT_RELAY_URLS)
|
|
150
230
|
const secretKey = randomBytes(32)
|
|
151
231
|
return {
|
|
152
232
|
id: 'nostr',
|
|
153
233
|
priority: 100,
|
|
154
234
|
caps: { canDiscover: true, canSignal: true, canRelay: false },
|
|
155
235
|
/**
|
|
156
|
-
* 周期性向中继发布 advert
|
|
236
|
+
* 周期性向中继发布 advert 事件(首发后台,不阻塞 ensureRuntime)。
|
|
157
237
|
* @param {string} topic advert 主题
|
|
158
238
|
* @param {Uint8Array} bytes advert 载荷
|
|
159
239
|
* @returns {Promise<() => void>} 取消广播函数
|
|
160
240
|
*/
|
|
161
241
|
async advertise(topic, bytes) {
|
|
162
|
-
|
|
242
|
+
const abortController = new AbortController()
|
|
163
243
|
/**
|
|
164
244
|
* 向中继发布当前 advert。
|
|
165
245
|
* @returns {Promise<void>}
|
|
166
246
|
*/
|
|
167
247
|
const publish = async () => {
|
|
168
|
-
if (
|
|
248
|
+
if (abortController.signal.aborted) return
|
|
169
249
|
const event = await signNostrEvent(
|
|
170
250
|
NOSTR_ADVERT_KIND,
|
|
171
251
|
[['t', topic], ['x', 'advert']],
|
|
172
252
|
bytesToBase64(bytes),
|
|
173
253
|
secretKey,
|
|
174
254
|
)
|
|
175
|
-
await publishEvent(relayUrls, event)
|
|
255
|
+
await publishEvent(relayUrls, event, abortController.signal)
|
|
176
256
|
}
|
|
177
|
-
|
|
257
|
+
void publish().catch(() => { })
|
|
178
258
|
const timer = setInterval(() => { void publish().catch(() => { }) }, 5 * 60_000)
|
|
179
259
|
return () => {
|
|
180
|
-
|
|
260
|
+
abortController.abort()
|
|
181
261
|
clearInterval(timer)
|
|
182
262
|
}
|
|
183
263
|
},
|
|
184
264
|
/**
|
|
185
|
-
* 订阅中继上的 advert
|
|
265
|
+
* 订阅中继上的 advert 事件(并行渐进连中继,立即返回 cleanup)。
|
|
186
266
|
* @param {string} topic advert 主题
|
|
187
267
|
* @param {Function} onAdvert advert 回调
|
|
188
268
|
* @returns {Promise<() => void>} 取消订阅函数
|
|
189
269
|
*/
|
|
190
270
|
async subscribe(topic, onAdvert) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
try { ws = await connectRelay(relayUrl) }
|
|
198
|
-
catch { continue }
|
|
199
|
-
connected++
|
|
200
|
-
sockets.push(ws)
|
|
201
|
-
ws.addEventListener('message', event => {
|
|
202
|
-
if (closed) return
|
|
203
|
-
let parsed
|
|
204
|
-
try { parsed = JSON.parse(String(event.data)) } catch { return }
|
|
205
|
-
if (!Array.isArray(parsed) || parsed[0] !== 'EVENT') return
|
|
206
|
-
const nostrEvent = parsed[2]
|
|
207
|
-
if (nostrEvent?.kind !== NOSTR_ADVERT_KIND) return
|
|
208
|
-
try { onAdvert(base64ToBytes(String(nostrEvent.content || '')), { relayUrl, event: nostrEvent }) }
|
|
209
|
-
catch { /* ignore */ }
|
|
210
|
-
})
|
|
211
|
-
ws.send(JSON.stringify(['REQ', subscriptionId, { kinds: [NOSTR_ADVERT_KIND], '#t': [topic], '#x': ['advert'] }]))
|
|
212
|
-
}
|
|
213
|
-
if (!connected) throw new Error('nostr: no relay available for advert subscribe')
|
|
214
|
-
return () => {
|
|
215
|
-
closed = true
|
|
216
|
-
for (const ws of sockets)
|
|
217
|
-
try { ws.close() } catch { /* ignore */ }
|
|
218
|
-
}
|
|
271
|
+
return subscribeNostrKind(relayUrls, {
|
|
272
|
+
kind: NOSTR_ADVERT_KIND,
|
|
273
|
+
topic,
|
|
274
|
+
tagX: 'advert',
|
|
275
|
+
onPayload: onAdvert,
|
|
276
|
+
})
|
|
219
277
|
},
|
|
220
278
|
/**
|
|
221
|
-
* 向中继发布 signal
|
|
279
|
+
* 向中继发布 signal 事件(按需发送,仍等待至少一路成功)。
|
|
222
280
|
* @param {string} topic 信令 topic
|
|
223
281
|
* @param {string} to 目标节点标识
|
|
224
282
|
* @param {Uint8Array} bytes 信令载荷
|
|
@@ -234,40 +292,18 @@ export function createNostrDiscoveryProvider(options = {}) {
|
|
|
234
292
|
await publishEvent(relayUrls, event)
|
|
235
293
|
},
|
|
236
294
|
/**
|
|
237
|
-
* 订阅中继上的 signal
|
|
295
|
+
* 订阅中继上的 signal 事件(并行渐进连中继,立即返回 cleanup)。
|
|
238
296
|
* @param {string} topic 信令 topic
|
|
239
297
|
* @param {Function} onSignal 信令回调
|
|
240
298
|
* @returns {Promise<() => void>} 取消订阅函数
|
|
241
299
|
*/
|
|
242
300
|
async onSignal(topic, onSignal) {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
try { ws = await connectRelay(relayUrl) }
|
|
250
|
-
catch { continue }
|
|
251
|
-
connected++
|
|
252
|
-
sockets.push(ws)
|
|
253
|
-
ws.addEventListener('message', event => {
|
|
254
|
-
if (closed) return
|
|
255
|
-
let parsed
|
|
256
|
-
try { parsed = JSON.parse(String(event.data)) } catch { return }
|
|
257
|
-
if (!Array.isArray(parsed) || parsed[0] !== 'EVENT') return
|
|
258
|
-
const nostrEvent = parsed[2]
|
|
259
|
-
if (nostrEvent?.kind !== NOSTR_SIGNAL_KIND) return
|
|
260
|
-
try { onSignal(base64ToBytes(String(nostrEvent.content || '')), { relayUrl, event: nostrEvent }) }
|
|
261
|
-
catch { /* ignore */ }
|
|
262
|
-
})
|
|
263
|
-
ws.send(JSON.stringify(['REQ', subscriptionId, { kinds: [NOSTR_SIGNAL_KIND], '#t': [topic], '#x': ['signal'] }]))
|
|
264
|
-
}
|
|
265
|
-
if (!connected) throw new Error('nostr: no relay available for signal subscribe')
|
|
266
|
-
return () => {
|
|
267
|
-
closed = true
|
|
268
|
-
for (const ws of sockets)
|
|
269
|
-
try { ws.close() } catch { /* ignore */ }
|
|
270
|
-
}
|
|
301
|
+
return subscribeNostrKind(relayUrls, {
|
|
302
|
+
kind: NOSTR_SIGNAL_KIND,
|
|
303
|
+
topic,
|
|
304
|
+
tagX: 'signal',
|
|
305
|
+
onPayload: onSignal,
|
|
306
|
+
})
|
|
271
307
|
},
|
|
272
308
|
}
|
|
273
309
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { base64ToBytes } from '../core/bytes_codec.mjs'
|
|
2
2
|
import { verifiedChunkBytes } from '../files/chunk_fetch_verify.mjs'
|
|
3
3
|
|
|
4
4
|
/** @type {Map<string, { expectedHash: string, timer: ReturnType<typeof setTimeout>, resolve: (v: Uint8Array | null) => void, reject?: (e: Error) => void }>} */
|
|
@@ -109,14 +109,13 @@ export function resolveChunkFetchWait(key, expectedHash, bytes) {
|
|
|
109
109
|
* @returns {boolean} 是否命中 pending
|
|
110
110
|
*/
|
|
111
111
|
export function resolvePendingChunkFetch(payload) {
|
|
112
|
-
const requestId =
|
|
112
|
+
const requestId = typeof payload?.requestId === 'string' ? payload.requestId : ''
|
|
113
113
|
if (!requestId) return false
|
|
114
114
|
const entry = pendingChunkFetches.get(requestId)
|
|
115
115
|
if (!entry) return false
|
|
116
|
-
if (payload?.
|
|
116
|
+
if (typeof payload?.dataBase64 === 'string') {
|
|
117
117
|
try {
|
|
118
|
-
|
|
119
|
-
return resolveChunkFetchWait(requestId, entry.expectedHash, bytes)
|
|
118
|
+
return resolveChunkFetchWait(requestId, entry.expectedHash, base64ToBytes(payload.dataBase64))
|
|
120
119
|
}
|
|
121
120
|
catch { /* keep waiting */ }
|
|
122
121
|
return false
|
|
@@ -56,7 +56,7 @@ export function resolveEventTopologicalOrder(events, cache, options = {}) {
|
|
|
56
56
|
const tipsHash = computeLocalTipsHash(tips)
|
|
57
57
|
const eventCount = events.length
|
|
58
58
|
|
|
59
|
-
if (cache
|
|
59
|
+
if (cache?.order?.length) {
|
|
60
60
|
if (cache.tipsHash === tipsHash && cache.eventCount === eventCount) {
|
|
61
61
|
const ok = cache.order.length === eventCount && cache.order.every(id => byId.has(id))
|
|
62
62
|
if (ok) return cache.order
|
|
@@ -131,9 +131,8 @@ export function reduceEntityKeyRotate(state, event) {
|
|
|
131
131
|
export function reduceEntityKeyRevoke(state, event) {
|
|
132
132
|
const newGeneration = Number(event.content?.newGeneration)
|
|
133
133
|
const activePubKeyHex = normalizeHex64(event.content?.activePubKeyHex || '')
|
|
134
|
-
const revokeGenerations =
|
|
135
|
-
|
|
136
|
-
: []
|
|
134
|
+
const revokeGenerations = (event.content?.revokeGenerations || [])
|
|
135
|
+
.map(Number).filter(Number.isFinite)
|
|
137
136
|
if (!Number.isFinite(newGeneration) || newGeneration < 0 || !isHex64(activePubKeyHex))
|
|
138
137
|
return state
|
|
139
138
|
state.entityKeyHistory = state.entityKeyHistory || []
|
|
@@ -181,7 +180,7 @@ export function foldEntityKeyHistoryFromEvents(events) {
|
|
|
181
180
|
*/
|
|
182
181
|
export function entityKeyRevokeSignBytes(revokeBody) {
|
|
183
182
|
const body = {
|
|
184
|
-
revokeGenerations: (revokeBody.revokeGenerations || []).map(
|
|
183
|
+
revokeGenerations: (revokeBody.revokeGenerations || []).map(Number),
|
|
185
184
|
newGeneration: Number(revokeBody.newGeneration),
|
|
186
185
|
activePubKeyHex: normalizeHex64(revokeBody.activePubKeyHex || ''),
|
|
187
186
|
entityHash: String(revokeBody.entityHash || '').trim().toLowerCase(),
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { createLruMap } from '../utils/lru.mjs'
|
|
2
|
+
|
|
3
|
+
/** 进程内拓扑序 memo 上限(每条目持有整份 order 数组) */
|
|
4
|
+
const MEMO_MAX = 64
|
|
5
|
+
|
|
6
|
+
/** @type {ReturnType<typeof createLruMap<string, { fp: string, order: string[] }>>} */
|
|
7
|
+
const memoByKey = createLruMap(MEMO_MAX)
|
|
3
8
|
|
|
4
9
|
/**
|
|
5
10
|
* 进程内拓扑序 memo;`resolveOrder` 可接入磁盘缓存等实现。
|
|
@@ -12,11 +17,13 @@ const memoByKey = new Map()
|
|
|
12
17
|
export function resolveTopologicalOrderMemoCached(memoKey, fingerprint, resolveOrder, options = {}) {
|
|
13
18
|
if (!options.force) {
|
|
14
19
|
const cached = memoByKey.get(memoKey)
|
|
15
|
-
if (cached?.fp === fingerprint && cached.order.length)
|
|
20
|
+
if (cached?.fp === fingerprint && cached.order.length) {
|
|
21
|
+
memoByKey.touch(memoKey, cached)
|
|
16
22
|
return cached.order
|
|
23
|
+
}
|
|
17
24
|
}
|
|
18
25
|
const order = resolveOrder()
|
|
19
|
-
memoByKey.
|
|
26
|
+
memoByKey.touch(memoKey, { fp: fingerprint, order })
|
|
20
27
|
return order
|
|
21
28
|
}
|
|
22
29
|
|