@steve02081504/fount-p2p 0.0.14 → 0.0.15
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 +8 -0
- package/deno.json +4 -1
- package/discovery/adverts.mjs +1 -2
- package/discovery/bt/index.mjs +4 -1
- package/discovery/lan.mjs +12 -4
- package/discovery/nostr.mjs +307 -133
- package/discovery/peer_clue.mjs +23 -0
- package/link/handshake.mjs +3 -3
- package/link/providers/webrtc.mjs +13 -1
- package/link/rtc.mjs +14 -5
- package/package.json +1 -1
- package/transport/link_registry.mjs +37 -1
- package/transport/runtime_bootstrap.mjs +4 -1
package/README.md
CHANGED
|
@@ -44,6 +44,14 @@ npx @steve02081504/fount-p2p
|
|
|
44
44
|
|
|
45
45
|
Default `nodeDir`: Windows `%LOCALAPPDATA%/fount-p2p/node`; elsewhere `~/.local/share/fount-p2p/node`.
|
|
46
46
|
|
|
47
|
+
Deno (optional; WebRTC needs explicit native scripts — **not** blanket `--allow-scripts`):
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
deno run -A --minimum-dependency-age=0 --node-modules-dir=auto --allow-scripts=npm:node-datachannel npm:@steve02081504/fount-p2p
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
See [docs/runtime.md](./docs/runtime.md).
|
|
54
|
+
|
|
47
55
|
```javascript
|
|
48
56
|
import { initNode, startNode, startInfra, stopInfra, setInfraPriority } from '@steve02081504/fount-p2p'
|
|
49
57
|
|
package/deno.json
CHANGED
package/discovery/adverts.mjs
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { normalizeHex64 } from '../core/hexIds.mjs'
|
|
2
2
|
import { buildSignedAdvert, verifySignedAdvert } from '../link/handshake.mjs'
|
|
3
3
|
|
|
4
|
-
import { listMulticastIpv4Addresses } from './lan_interfaces.mjs'
|
|
5
|
-
|
|
6
4
|
import {
|
|
7
5
|
decryptSignalPacket,
|
|
8
6
|
encryptSignalPacket,
|
|
@@ -10,6 +8,7 @@ import {
|
|
|
10
8
|
networkRendezvousKey,
|
|
11
9
|
nodeRendezvousKey,
|
|
12
10
|
} from './internal/signal_crypto.mjs'
|
|
11
|
+
import { listMulticastIpv4Addresses } from './lan_interfaces.mjs'
|
|
13
12
|
|
|
14
13
|
/** @typedef {'node' | 'network' | { roomSecret: string }} AdvertScope */
|
|
15
14
|
|
package/discovery/bt/index.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { isHex64, normalizeHex64 } from '../../core/hexIds.mjs'
|
|
|
4
4
|
import { nodeDebug, shortHash } from '../../node/log.mjs'
|
|
5
5
|
import { noteAdvertPeerHints } from '../advert_peer_hints.mjs'
|
|
6
6
|
import { ingestNetworkAdvert } from '../adverts.mjs'
|
|
7
|
+
import { noteDiscoveryPeerClue } from '../peer_clue.mjs'
|
|
7
8
|
|
|
8
9
|
import { getBtPeerHint } from './peer_hints.mjs'
|
|
9
10
|
import { canUseBluetoothRuntime, loadBleno, loadNoble, resolveBtRole, waitPoweredOn } from './runtime.mjs'
|
|
@@ -106,11 +107,13 @@ export async function acceptBtScannedPresence(bytes, meta) {
|
|
|
106
107
|
const firstSeen = !visibleByHash.has(ingested.verifiedNodeHash)
|
|
107
108
|
noteBtVisibleNode(ingested.verifiedNodeHash)
|
|
108
109
|
noteAdvertPeerHints(ingested.verifiedNodeHash, ingested.body, meta)
|
|
109
|
-
if (firstSeen)
|
|
110
|
+
if (firstSeen) {
|
|
111
|
+
noteDiscoveryPeerClue(ingested.verifiedNodeHash)
|
|
110
112
|
nodeDebug('p2p:bt peer visible', {
|
|
111
113
|
peer: shortHash(ingested.verifiedNodeHash),
|
|
112
114
|
peripheralId,
|
|
113
115
|
})
|
|
116
|
+
}
|
|
114
117
|
return ingested
|
|
115
118
|
}
|
|
116
119
|
|
package/discovery/lan.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { noteAdvertPeerHints } from './advert_peer_hints.mjs'
|
|
|
9
9
|
import { ingestNetworkAdvert } from './adverts.mjs'
|
|
10
10
|
import { listMulticastIpv4Addresses } from './lan_interfaces.mjs'
|
|
11
11
|
import { getLanPeerHint } from './lan_peer_hints.mjs'
|
|
12
|
+
import { noteDiscoveryPeerClue } from './peer_clue.mjs'
|
|
12
13
|
|
|
13
14
|
const DEFAULT_PORT = 53531
|
|
14
15
|
const DEFAULT_GROUP = '239.255.42.99'
|
|
@@ -66,6 +67,7 @@ export async function acceptLanPresenceAdvert(advertBytes, meta = {}) {
|
|
|
66
67
|
noteLanVisibleNode(ingested.verifiedNodeHash)
|
|
67
68
|
noteAdvertPeerHints(ingested.verifiedNodeHash, ingested.body, meta)
|
|
68
69
|
if (firstSeen) {
|
|
70
|
+
noteDiscoveryPeerClue(ingested.verifiedNodeHash)
|
|
69
71
|
const host = String(meta.address || '').trim()
|
|
70
72
|
nodeDebug('p2p:lan peer visible', {
|
|
71
73
|
peer: shortHash(ingested.verifiedNodeHash),
|
|
@@ -78,7 +80,7 @@ export async function acceptLanPresenceAdvert(advertBytes, meta = {}) {
|
|
|
78
80
|
|
|
79
81
|
/**
|
|
80
82
|
* LAN UDP presence:段内 beacon,非 topic 订阅模型。
|
|
81
|
-
* @param {{ port?: number, group?: string }} [options] 配置
|
|
83
|
+
* @param {{ port?: number, group?: string, localNodeHash?: string }} [options] 配置
|
|
82
84
|
* @returns {import('./index.mjs').DiscoveryProvider} LAN 发现提供者
|
|
83
85
|
*/
|
|
84
86
|
export function createLanDiscoveryProvider(options = {}) {
|
|
@@ -91,12 +93,14 @@ export function createLanDiscoveryProvider(options = {}) {
|
|
|
91
93
|
let refs = 0
|
|
92
94
|
/** @type {ReturnType<typeof setInterval> | null} */
|
|
93
95
|
let beaconTimer = null
|
|
96
|
+
const seededSelf = normalizeHex64(options.localNodeHash)
|
|
94
97
|
/** @type {string | null} */
|
|
95
|
-
let selfNodeHash = null
|
|
98
|
+
let selfNodeHash = isHex64(seededSelf) ? seededSelf : null
|
|
96
99
|
/** @type {Set<string>} */
|
|
97
100
|
const joinedAddresses = new Set()
|
|
98
101
|
|
|
99
102
|
/**
|
|
103
|
+
* 在每个本机 IPv4 接口上加入组播组;全部失败则回退到默认接口。
|
|
100
104
|
* @param {import('node:dgram').Socket} sock UDP socket
|
|
101
105
|
* @returns {void}
|
|
102
106
|
*/
|
|
@@ -112,6 +116,11 @@ export function createLanDiscoveryProvider(options = {}) {
|
|
|
112
116
|
if (!joinedAddresses.size)
|
|
113
117
|
try { sock.addMembership(group) } catch { /* ignore */ }
|
|
114
118
|
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 懒创建复用的 udp4 socket(reuseAddr,便于同机多实例绑同一 port)。
|
|
122
|
+
* @returns {import('node:dgram').Socket} 组播 presence socket
|
|
123
|
+
*/
|
|
115
124
|
function getSocket() {
|
|
116
125
|
return socket ||= dgram.createSocket({ type: 'udp4', reuseAddr: true })
|
|
117
126
|
}
|
|
@@ -190,7 +199,7 @@ export function createLanDiscoveryProvider(options = {}) {
|
|
|
190
199
|
let sent = 0
|
|
191
200
|
/** @type {unknown} */
|
|
192
201
|
let lastError = null
|
|
193
|
-
for (const addr of addrs)
|
|
202
|
+
for (const addr of addrs)
|
|
194
203
|
try {
|
|
195
204
|
await sendOnce(addr)
|
|
196
205
|
sent++
|
|
@@ -198,7 +207,6 @@ export function createLanDiscoveryProvider(options = {}) {
|
|
|
198
207
|
catch (error) {
|
|
199
208
|
lastError = error
|
|
200
209
|
}
|
|
201
|
-
}
|
|
202
210
|
if (!sent) throw lastError || new Error('p2p: lan multicast send failed')
|
|
203
211
|
}
|
|
204
212
|
|
package/discovery/nostr.mjs
CHANGED
|
@@ -8,14 +8,15 @@ import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
|
|
|
8
8
|
import { sha256Hex } from '../crypto/crypto.mjs'
|
|
9
9
|
import { nodeDebug, shortHash } from '../node/log.mjs'
|
|
10
10
|
|
|
11
|
-
import { ingestEncryptedAdvert } from './adverts.mjs'
|
|
12
11
|
import { noteAdvertPeerHints } from './advert_peer_hints.mjs'
|
|
12
|
+
import { ingestEncryptedAdvert } from './adverts.mjs'
|
|
13
13
|
import {
|
|
14
14
|
encryptSignalPacket,
|
|
15
15
|
groupRendezvousKey,
|
|
16
16
|
networkRendezvousKey,
|
|
17
17
|
nodeRendezvousKey,
|
|
18
18
|
} from './internal/signal_crypto.mjs'
|
|
19
|
+
import { noteDiscoveryPeerClue } from './peer_clue.mjs'
|
|
19
20
|
|
|
20
21
|
/** 默认 Nostr 中继 URL 列表。 */
|
|
21
22
|
export const DEFAULT_RELAY_URLS = [
|
|
@@ -31,6 +32,8 @@ export const NOSTR_CLOSE_GRACE_MS = 1_000
|
|
|
31
32
|
|
|
32
33
|
/** 单 relay 等待 EVENT OK 回执超时。 */
|
|
33
34
|
export const NOSTR_PUBLISH_OK_TIMEOUT_MS = 3_000
|
|
35
|
+
/** 共享 relay 会话断线后重连间隔。 */
|
|
36
|
+
export const NOSTR_RECONNECT_DELAY_MS = 500
|
|
34
37
|
|
|
35
38
|
/** Nostr network advert 事件 kind(addressable,可存储)。 */
|
|
36
39
|
export const NOSTR_ADVERT_KIND = 30787
|
|
@@ -56,7 +59,6 @@ function listPoolHashes(pool, now, ttlMs) {
|
|
|
56
59
|
for (const [hash, seenAt] of pool)
|
|
57
60
|
if (now - seenAt <= ttlMs) out.push(hash)
|
|
58
61
|
else pool.delete(hash)
|
|
59
|
-
|
|
60
62
|
return out
|
|
61
63
|
}
|
|
62
64
|
|
|
@@ -119,13 +121,15 @@ export function listNostrGroupVisibleNodeHashes(roomSecret, now = Date.now(), tt
|
|
|
119
121
|
* 解密并验签后写入 Nostr 可见池;伪造 body.nodeHash 无效。
|
|
120
122
|
* @param {string} rendezvousKey rendezvous 键
|
|
121
123
|
* @param {Uint8Array} bytes 加密 advert
|
|
122
|
-
* @param {{ roomSecret?: string }} [options]
|
|
124
|
+
* @param {{ roomSecret?: string, skipNodeHash?: string, meta?: object }} [options] 群池 / 本机回环过滤 / meta
|
|
123
125
|
* @returns {Promise<string | null>} 验签通过的 nodeHash
|
|
124
126
|
*/
|
|
125
127
|
export async function acceptNostrAdvert(rendezvousKey, bytes, options = {}) {
|
|
126
128
|
const ingested = await ingestEncryptedAdvert(rendezvousKey, bytes)
|
|
127
129
|
if (!ingested) return null
|
|
128
130
|
const hash = ingested.verifiedNodeHash
|
|
131
|
+
const skipHash = options.skipNodeHash ? normalizeHex64(options.skipNodeHash) : null
|
|
132
|
+
if (skipHash && hash === skipHash) return hash
|
|
129
133
|
const roomSecret = options.roomSecret
|
|
130
134
|
let firstSeen = true
|
|
131
135
|
if (roomSecret) {
|
|
@@ -137,8 +141,10 @@ export async function acceptNostrAdvert(rendezvousKey, bytes, options = {}) {
|
|
|
137
141
|
firstSeen = !visibleByHash.has(hash)
|
|
138
142
|
noteNostrVisibleNode(hash)
|
|
139
143
|
}
|
|
140
|
-
if (firstSeen)
|
|
144
|
+
if (firstSeen) {
|
|
145
|
+
noteDiscoveryPeerClue(hash)
|
|
141
146
|
nodeDebug('p2p:nostr peer visible', { peer: shortHash(hash), group: !!roomSecret })
|
|
147
|
+
}
|
|
142
148
|
noteAdvertPeerHints(hash, ingested.body, options.meta || {})
|
|
143
149
|
return hash
|
|
144
150
|
}
|
|
@@ -351,69 +357,210 @@ async function publishEvent(relayUrls, event, signal) {
|
|
|
351
357
|
}
|
|
352
358
|
|
|
353
359
|
/**
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
* @
|
|
357
|
-
*
|
|
360
|
+
* 共享 relay 会话:多 SUB 复用同一 URL 的 WebSocket,避免 signal/presence/advert 各建一池。
|
|
361
|
+
* 仍有活跃 sub 时断线会自动重连并重发 REQ。
|
|
362
|
+
* @typedef {{
|
|
363
|
+
* ws: import('ws').WebSocket | null,
|
|
364
|
+
* connecting: boolean,
|
|
365
|
+
* reconnectTimer: ReturnType<typeof setTimeout> | null,
|
|
366
|
+
* subs: Map<string, { filter: object, onEvent: (event: object, relayUrl: string) => void }>,
|
|
367
|
+
* }} SharedRelaySession
|
|
368
|
+
*/
|
|
369
|
+
|
|
370
|
+
/** @type {Map<string, SharedRelaySession>} */
|
|
371
|
+
const sharedRelaySessions = new Map()
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* @param {string} relayUrl 中继 URL
|
|
375
|
+
* @param {SharedRelaySession} session 会话
|
|
376
|
+
* @returns {boolean} session 是否仍是该 URL 的活动会话
|
|
377
|
+
*/
|
|
378
|
+
function isLiveSharedSession(relayUrl, session) {
|
|
379
|
+
return sharedRelaySessions.get(relayUrl) === session
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* @param {SharedRelaySession} session 会话
|
|
358
384
|
* @returns {void}
|
|
359
385
|
*/
|
|
360
|
-
function
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
386
|
+
function clearSharedRelayReconnect(session) {
|
|
387
|
+
if (!session.reconnectTimer) return
|
|
388
|
+
clearTimeout(session.reconnectTimer)
|
|
389
|
+
session.reconnectTimer = null
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* @param {string} relayUrl 中继 URL
|
|
394
|
+
* @param {SharedRelaySession} session 会话
|
|
395
|
+
* @param {import('ws').WebSocket} ws 已打开连接
|
|
396
|
+
* @returns {void}
|
|
397
|
+
*/
|
|
398
|
+
function attachSharedRelaySocket(relayUrl, session, ws) {
|
|
399
|
+
session.ws = ws
|
|
400
|
+
nodeDebug('p2p:nostr relay up', { url: relayUrl })
|
|
401
|
+
ws.on('message', data => {
|
|
402
|
+
let parsed
|
|
403
|
+
try { parsed = JSON.parse(String(data)) } catch { return }
|
|
404
|
+
if (parsed?.[0] !== 'EVENT') return
|
|
405
|
+
const subId = String(parsed[1] || '')
|
|
406
|
+
const nostrEvent = parsed[2]
|
|
407
|
+
const sub = session.subs.get(subId)
|
|
408
|
+
if (!sub || !nostrEvent) return
|
|
409
|
+
try { sub.onEvent(nostrEvent, relayUrl) } catch { /* ignore */ }
|
|
410
|
+
})
|
|
411
|
+
ws.once('close', () => {
|
|
412
|
+
if (!isLiveSharedSession(relayUrl, session)) return
|
|
413
|
+
session.ws = null
|
|
414
|
+
if (!session.subs.size) {
|
|
415
|
+
sharedRelaySessions.delete(relayUrl)
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
scheduleSharedRelayConnect(relayUrl, session, NOSTR_RECONNECT_DELAY_MS)
|
|
419
|
+
})
|
|
420
|
+
for (const [subId, sub] of session.subs)
|
|
421
|
+
try { ws.send(JSON.stringify(['REQ', subId, sub.filter])) } catch { /* ignore */ }
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* @param {string} relayUrl 中继 URL
|
|
426
|
+
* @param {SharedRelaySession} session 会话
|
|
427
|
+
* @param {number} [delayMs=0] 延迟毫秒(断线重连用)
|
|
428
|
+
* @returns {void}
|
|
429
|
+
*/
|
|
430
|
+
function scheduleSharedRelayConnect(relayUrl, session, delayMs = 0) {
|
|
431
|
+
if (!isLiveSharedSession(relayUrl, session) || session.connecting || session.ws) return
|
|
432
|
+
clearSharedRelayReconnect(session)
|
|
433
|
+
/**
|
|
434
|
+
* @returns {void}
|
|
435
|
+
*/
|
|
436
|
+
const start = () => {
|
|
437
|
+
session.reconnectTimer = null
|
|
438
|
+
if (!isLiveSharedSession(relayUrl, session) || session.connecting || session.ws) return
|
|
439
|
+
if (!session.subs.size) {
|
|
440
|
+
sharedRelaySessions.delete(relayUrl)
|
|
441
|
+
return
|
|
442
|
+
}
|
|
443
|
+
session.connecting = true
|
|
444
|
+
void connectRelay(relayUrl, NOSTR_CONNECT_TIMEOUT_MS).then(ws => {
|
|
445
|
+
session.connecting = false
|
|
446
|
+
if (!isLiveSharedSession(relayUrl, session) || !session.subs.size) {
|
|
447
|
+
if (!session.subs.size && isLiveSharedSession(relayUrl, session))
|
|
448
|
+
sharedRelaySessions.delete(relayUrl)
|
|
364
449
|
dropWebSocket(ws)
|
|
365
450
|
return
|
|
366
451
|
}
|
|
367
|
-
|
|
368
|
-
nodeDebug('p2p:nostr relay up', { url: relayUrl })
|
|
369
|
-
onOpen(ws, relayUrl)
|
|
452
|
+
attachSharedRelaySocket(relayUrl, session, ws)
|
|
370
453
|
}).catch(error => {
|
|
454
|
+
session.connecting = false
|
|
455
|
+
if (!isLiveSharedSession(relayUrl, session)) return
|
|
371
456
|
nodeDebug('p2p:nostr relay fail', {
|
|
372
457
|
url: relayUrl,
|
|
373
458
|
err: String(error?.message || error),
|
|
374
459
|
})
|
|
460
|
+
if (!session.subs.size) {
|
|
461
|
+
sharedRelaySessions.delete(relayUrl)
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
scheduleSharedRelayConnect(relayUrl, session, NOSTR_RECONNECT_DELAY_MS)
|
|
375
465
|
})
|
|
466
|
+
}
|
|
467
|
+
if (delayMs <= 0) {
|
|
468
|
+
// 让同一 tick 内的 registerSharedRelaySub 先写入 subs,再决定是否连接。
|
|
469
|
+
queueMicrotask(start)
|
|
470
|
+
return
|
|
471
|
+
}
|
|
472
|
+
nodeDebug('p2p:nostr relay reconnect', { url: relayUrl, delayMs })
|
|
473
|
+
session.reconnectTimer = setTimeout(start, delayMs)
|
|
474
|
+
session.reconnectTimer.unref?.()
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* @param {string} relayUrl 中继 URL
|
|
479
|
+
* @returns {SharedRelaySession} 共享会话
|
|
480
|
+
*/
|
|
481
|
+
function acquireSharedRelay(relayUrl) {
|
|
482
|
+
const existing = sharedRelaySessions.get(relayUrl)
|
|
483
|
+
if (existing) return existing
|
|
484
|
+
/** @type {SharedRelaySession} */
|
|
485
|
+
const session = {
|
|
486
|
+
ws: null,
|
|
487
|
+
connecting: false,
|
|
488
|
+
reconnectTimer: null,
|
|
489
|
+
subs: new Map(),
|
|
490
|
+
}
|
|
491
|
+
sharedRelaySessions.set(relayUrl, session)
|
|
492
|
+
scheduleSharedRelayConnect(relayUrl, session)
|
|
493
|
+
return session
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* @param {string} relayUrl 中继 URL
|
|
498
|
+
* @param {string} subscriptionId 订阅 id
|
|
499
|
+
* @returns {void}
|
|
500
|
+
*/
|
|
501
|
+
function releaseSharedRelaySub(relayUrl, subscriptionId) {
|
|
502
|
+
const session = sharedRelaySessions.get(relayUrl)
|
|
503
|
+
if (!session) return
|
|
504
|
+
session.subs.delete(subscriptionId)
|
|
505
|
+
const ws = session.ws
|
|
506
|
+
if (ws?.readyState === WebSocket.OPEN)
|
|
507
|
+
try { ws.send(JSON.stringify(['CLOSE', subscriptionId])) } catch { /* ignore */ }
|
|
508
|
+
if (session.subs.size) return
|
|
509
|
+
sharedRelaySessions.delete(relayUrl)
|
|
510
|
+
clearSharedRelayReconnect(session)
|
|
511
|
+
if (ws) dropWebSocket(ws)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* 在已打开的共享连接上登记 REQ;连接中则等 attach 时统一重放。
|
|
516
|
+
* @param {SharedRelaySession} session 会话
|
|
517
|
+
* @param {string} subscriptionId 订阅 id
|
|
518
|
+
* @param {object} filter Nostr filter
|
|
519
|
+
* @param {(event: object, relayUrl: string) => void} onEvent 事件回调
|
|
520
|
+
* @returns {void}
|
|
521
|
+
*/
|
|
522
|
+
function registerSharedRelaySub(session, subscriptionId, filter, onEvent) {
|
|
523
|
+
session.subs.set(subscriptionId, { filter, onEvent })
|
|
524
|
+
const ws = session.ws
|
|
525
|
+
if (ws?.readyState !== WebSocket.OPEN) return
|
|
526
|
+
try { ws.send(JSON.stringify(['REQ', subscriptionId, filter])) } catch { /* ignore */ }
|
|
376
527
|
}
|
|
377
528
|
|
|
378
529
|
/**
|
|
379
|
-
* 内部:按 rendezvous 键订阅 Nostr kind(topic
|
|
530
|
+
* 内部:按 rendezvous 键订阅 Nostr kind(topic 不导出)。多订阅共享每 URL 一条连接。
|
|
380
531
|
* @param {string[]} relayUrls 中继 URL 列表
|
|
381
|
-
* @param {{ kind: number, rendezvousKey: string, tagX: string, onPayload: (bytes: Uint8Array, meta: { relayUrl: string, event: object }) => void | Promise<void
|
|
532
|
+
* @param {{ kind: number, rendezvousKey: string, tagX: string, onPayload: (bytes: Uint8Array, meta: { relayUrl: string, event: object }) => void | Promise<void>, addressable?: boolean }} options 订阅选项
|
|
382
533
|
* @returns {() => void} 取消订阅
|
|
383
534
|
*/
|
|
384
535
|
function subscribeNostrKind(relayUrls, options) {
|
|
385
536
|
const { kind, rendezvousKey, tagX, onPayload, addressable = false } = options
|
|
386
|
-
const abortController = new AbortController()
|
|
387
|
-
/** @type {import('ws').WebSocket[]} */
|
|
388
|
-
const sockets = []
|
|
389
537
|
const subscriptionId = randomBytes(8).toString('hex')
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
538
|
+
const filter = { kinds: [kind], '#t': [rendezvousKey], '#x': [tagX] }
|
|
539
|
+
if (addressable) filter['#d'] = [rendezvousKey]
|
|
540
|
+
/**
|
|
541
|
+
* @param {object} nostrEvent Nostr EVENT
|
|
542
|
+
* @param {string} relayUrl 来源中继
|
|
543
|
+
* @returns {void}
|
|
544
|
+
*/
|
|
545
|
+
const onEvent = (nostrEvent, relayUrl) => {
|
|
546
|
+
if (nostrEvent?.kind !== kind) return
|
|
547
|
+
try {
|
|
548
|
+
const result = onPayload(base64ToBytes(nostrEvent.content), { relayUrl, event: nostrEvent })
|
|
549
|
+
if (result?.then) void result.catch(() => { })
|
|
550
|
+
}
|
|
551
|
+
catch { /* ignore */ }
|
|
552
|
+
}
|
|
553
|
+
const urls = dedupeRelayUrls(relayUrls)
|
|
554
|
+
for (const relayUrl of urls)
|
|
555
|
+
registerSharedRelaySub(acquireSharedRelay(relayUrl), subscriptionId, filter, onEvent)
|
|
408
556
|
return () => {
|
|
409
|
-
|
|
410
|
-
for (const ws of sockets) dropWebSocket(ws)
|
|
557
|
+
for (const relayUrl of urls) releaseSharedRelaySub(relayUrl, subscriptionId)
|
|
411
558
|
}
|
|
412
559
|
}
|
|
413
560
|
|
|
414
561
|
/**
|
|
415
562
|
* 创建 Nostr discovery provider(list+connect;topic 仅内部)。
|
|
416
|
-
* @param {{ relayUrls?: string[] | null, getRelayUrls?: () => string[] | null | undefined }} [options]
|
|
563
|
+
* @param {{ relayUrls?: string[] | null, getRelayUrls?: () => string[] | null | undefined, localNodeHash?: string }} [options] 中继配置与本机 hash
|
|
417
564
|
* @returns {import('./index.mjs').DiscoveryProvider} Nostr discovery provider
|
|
418
565
|
*/
|
|
419
566
|
export function createNostrDiscoveryProvider(options = {}) {
|
|
@@ -430,57 +577,124 @@ export function createNostrDiscoveryProvider(options = {}) {
|
|
|
430
577
|
return dedupeRelayUrls(options.relayUrls)
|
|
431
578
|
}
|
|
432
579
|
const secretKey = randomBytes(32)
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
const
|
|
437
|
-
/**
|
|
438
|
-
|
|
580
|
+
const seededSelf = normalizeHex64(options.localNodeHash)
|
|
581
|
+
/** @type {string | null} */
|
|
582
|
+
let selfNodeHash = isHex64(seededSelf) ? seededSelf : null
|
|
583
|
+
const NETWORK_SUB_KEY = 'network'
|
|
584
|
+
/**
|
|
585
|
+
* @typedef {{ stop: () => void, held: boolean, listeners: Set<(bytes: Uint8Array, meta: object) => void> }} AdvertSubEntry
|
|
586
|
+
*/
|
|
587
|
+
/** @type {Map<string, AdvertSubEntry>} */
|
|
588
|
+
const advertSubs = new Map()
|
|
439
589
|
/** @type {Map<string, () => void>} */
|
|
440
590
|
const nodeSignalSubs = new Map()
|
|
441
591
|
|
|
442
592
|
/**
|
|
593
|
+
* @param {string | undefined | null} nodeHash 本机 hash
|
|
443
594
|
* @returns {void}
|
|
444
595
|
*/
|
|
596
|
+
function noteSelfNodeHash(nodeHash) {
|
|
597
|
+
const hash = normalizeHex64(nodeHash)
|
|
598
|
+
if (isHex64(hash)) selfNodeHash = hash
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* @param {string} key 订阅键
|
|
603
|
+
* @param {AdvertSubEntry} entry 条目
|
|
604
|
+
* @returns {void}
|
|
605
|
+
*/
|
|
606
|
+
function releaseAdvertEntryIfIdle(key, entry) {
|
|
607
|
+
if (entry.held || entry.listeners.size) return
|
|
608
|
+
try { entry.stop() } catch { /* ignore */ }
|
|
609
|
+
advertSubs.delete(key)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* pool/connect 永久 hold;watch listener 归零且无 hold 时拆 REQ。
|
|
614
|
+
* @param {string} key 订阅键
|
|
615
|
+
* @param {{ rendezvousKey: string, roomSecret?: string }} bind advert 绑定
|
|
616
|
+
* @param {(bytes: Uint8Array, meta: object) => void} [listener] 额外监听
|
|
617
|
+
* @returns {() => void} 取消 listener;无 listener 时 no-op(hold 至 dispose)
|
|
618
|
+
*/
|
|
619
|
+
function ensureAdvertSubscription(key, bind, listener) {
|
|
620
|
+
if (!key) return () => { }
|
|
621
|
+
let entry = advertSubs.get(key)
|
|
622
|
+
if (!entry) {
|
|
623
|
+
/** @type {AdvertSubEntry} */
|
|
624
|
+
const created = {
|
|
625
|
+
/**
|
|
626
|
+
* @returns {void}
|
|
627
|
+
*/
|
|
628
|
+
stop: () => { }, held: false, listeners: new Set()
|
|
629
|
+
}
|
|
630
|
+
created.stop = subscribeNostrKind(resolveRelayUrls(), {
|
|
631
|
+
kind: NOSTR_ADVERT_KIND,
|
|
632
|
+
rendezvousKey: bind.rendezvousKey,
|
|
633
|
+
tagX: 'advert',
|
|
634
|
+
addressable: true,
|
|
635
|
+
/**
|
|
636
|
+
* @param {Uint8Array} bytes 加密 advert 载荷
|
|
637
|
+
* @param {object} meta relay 元数据
|
|
638
|
+
* @returns {Promise<void>}
|
|
639
|
+
*/
|
|
640
|
+
async onPayload(bytes, meta) {
|
|
641
|
+
await acceptNostrAdvert(bind.rendezvousKey, bytes, {
|
|
642
|
+
roomSecret: bind.roomSecret,
|
|
643
|
+
skipNodeHash: selfNodeHash || undefined,
|
|
644
|
+
meta,
|
|
645
|
+
})
|
|
646
|
+
for (const fn of created.listeners)
|
|
647
|
+
try { fn(bytes, meta) } catch { /* ignore */ }
|
|
648
|
+
},
|
|
649
|
+
})
|
|
650
|
+
entry = created
|
|
651
|
+
advertSubs.set(key, entry)
|
|
652
|
+
}
|
|
653
|
+
if (!listener) {
|
|
654
|
+
entry.held = true
|
|
655
|
+
return () => { }
|
|
656
|
+
}
|
|
657
|
+
entry.listeners.add(listener)
|
|
658
|
+
return () => {
|
|
659
|
+
entry.listeners.delete(listener)
|
|
660
|
+
releaseAdvertEntryIfIdle(key, entry)
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* @returns {() => void} no-op(network hold 至 dispose)
|
|
666
|
+
*/
|
|
445
667
|
function ensureNetworkAdvertSubscription() {
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
stopNetworkAdvertSub = subscribeNostrKind(resolveRelayUrls(), {
|
|
449
|
-
kind: NOSTR_ADVERT_KIND,
|
|
450
|
-
rendezvousKey,
|
|
451
|
-
tagX: 'advert',
|
|
452
|
-
addressable: true,
|
|
453
|
-
/**
|
|
454
|
-
* @param {Uint8Array} bytes 加密 advert 载荷
|
|
455
|
-
* @returns {Promise<void>}
|
|
456
|
-
*/
|
|
457
|
-
async onPayload(bytes) {
|
|
458
|
-
await acceptNostrAdvert(rendezvousKey, bytes)
|
|
459
|
-
},
|
|
668
|
+
return ensureAdvertSubscription(NETWORK_SUB_KEY, {
|
|
669
|
+
rendezvousKey: networkRendezvousKey(),
|
|
460
670
|
})
|
|
461
671
|
}
|
|
462
672
|
|
|
463
673
|
/**
|
|
464
674
|
* @param {string} roomSecret 房间密钥
|
|
465
|
-
* @
|
|
675
|
+
* @param {(bytes: Uint8Array, meta: object) => void} [listener] 额外 advert 监听
|
|
676
|
+
* @returns {() => void} 取消 listener
|
|
466
677
|
*/
|
|
467
|
-
function ensureGroupSubscription(roomSecret) {
|
|
678
|
+
function ensureGroupSubscription(roomSecret, listener) {
|
|
468
679
|
const key = String(roomSecret || '')
|
|
469
|
-
if (!key
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
680
|
+
if (!key) return () => { }
|
|
681
|
+
return ensureAdvertSubscription('group:' + key, {
|
|
682
|
+
rendezvousKey: groupRendezvousKey(key),
|
|
683
|
+
roomSecret: key,
|
|
684
|
+
}, listener)
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* @param {string} nodeHash 目标
|
|
689
|
+
* @param {(bytes: Uint8Array, meta: object) => void} [listener] 额外 advert 监听
|
|
690
|
+
* @returns {() => void} 取消 listener
|
|
691
|
+
*/
|
|
692
|
+
function ensureNodeAdvertSubscription(nodeHash, listener) {
|
|
693
|
+
const hash = normalizeHex64(nodeHash)
|
|
694
|
+
if (!isHex64(hash)) return () => { }
|
|
695
|
+
return ensureAdvertSubscription('node:' + hash, {
|
|
696
|
+
rendezvousKey: nodeRendezvousKey(hash),
|
|
697
|
+
}, listener)
|
|
484
698
|
}
|
|
485
699
|
|
|
486
700
|
return {
|
|
@@ -495,10 +709,14 @@ export function createNostrDiscoveryProvider(options = {}) {
|
|
|
495
709
|
const limit = Math.max(1, Number(options.limit) || 64)
|
|
496
710
|
if (options.roomSecret) {
|
|
497
711
|
ensureGroupSubscription(options.roomSecret)
|
|
498
|
-
return listNostrGroupVisibleNodeHashes(options.roomSecret)
|
|
712
|
+
return listNostrGroupVisibleNodeHashes(options.roomSecret)
|
|
713
|
+
.filter(hash => hash !== selfNodeHash)
|
|
714
|
+
.slice(0, limit)
|
|
499
715
|
}
|
|
500
716
|
ensureNetworkAdvertSubscription()
|
|
501
|
-
return listNostrVisibleNodeHashes()
|
|
717
|
+
return listNostrVisibleNodeHashes()
|
|
718
|
+
.filter(hash => hash !== selfNodeHash)
|
|
719
|
+
.slice(0, limit)
|
|
502
720
|
},
|
|
503
721
|
/**
|
|
504
722
|
* 挂上对该节点的内部 advert 订阅(建链由 registry dialer / ensureLinkToNode 完成)。
|
|
@@ -508,22 +726,7 @@ export function createNostrDiscoveryProvider(options = {}) {
|
|
|
508
726
|
async connectToNode(nodeHash) {
|
|
509
727
|
const hash = normalizeHex64(nodeHash)
|
|
510
728
|
if (!isHex64(hash)) return false
|
|
511
|
-
|
|
512
|
-
const rendezvousKey = nodeRendezvousKey(hash)
|
|
513
|
-
nodeAdvertSubs.set(hash, subscribeNostrKind(resolveRelayUrls(), {
|
|
514
|
-
kind: NOSTR_ADVERT_KIND,
|
|
515
|
-
rendezvousKey,
|
|
516
|
-
tagX: 'advert',
|
|
517
|
-
addressable: true,
|
|
518
|
-
/**
|
|
519
|
-
* @param {Uint8Array} bytes 加密 advert 载荷
|
|
520
|
-
* @returns {Promise<void>}
|
|
521
|
-
*/
|
|
522
|
-
async onPayload(bytes) {
|
|
523
|
-
await acceptNostrAdvert(rendezvousKey, bytes)
|
|
524
|
-
},
|
|
525
|
-
}))
|
|
526
|
-
}
|
|
729
|
+
ensureNodeAdvertSubscription(hash)
|
|
527
730
|
return true
|
|
528
731
|
},
|
|
529
732
|
/**
|
|
@@ -541,6 +744,7 @@ export function createNostrDiscoveryProvider(options = {}) {
|
|
|
541
744
|
if (abortController.signal.aborted) return
|
|
542
745
|
const beacon = await getBeacon?.()
|
|
543
746
|
if (!beacon?.nodeHash) return
|
|
747
|
+
noteSelfNodeHash(beacon.nodeHash)
|
|
544
748
|
const advertBody = beacon.advertBody || beacon.body || beacon
|
|
545
749
|
const bytes = encryptSignalPacket(rendezvousKey, { type: 'advert', body: advertBody })
|
|
546
750
|
const event = await signNostrEvent(
|
|
@@ -591,6 +795,7 @@ export function createNostrDiscoveryProvider(options = {}) {
|
|
|
591
795
|
async listenNodeSignals(localNodeHash, onSignal) {
|
|
592
796
|
const hash = normalizeHex64(localNodeHash)
|
|
593
797
|
if (!isHex64(hash)) throw new Error('nostr: invalid nodeHash')
|
|
798
|
+
noteSelfNodeHash(hash)
|
|
594
799
|
const rendezvousKey = nodeRendezvousKey(hash)
|
|
595
800
|
const existing = nodeSignalSubs.get(hash)
|
|
596
801
|
if (existing) existing()
|
|
@@ -615,20 +820,7 @@ export function createNostrDiscoveryProvider(options = {}) {
|
|
|
615
820
|
async watchNodeAdvert(nodeHash, onAdvert) {
|
|
616
821
|
const hash = normalizeHex64(nodeHash)
|
|
617
822
|
if (!isHex64(hash)) throw new Error('nostr: invalid nodeHash')
|
|
618
|
-
|
|
619
|
-
const stop = subscribeNostrKind(resolveRelayUrls(), {
|
|
620
|
-
kind: NOSTR_ADVERT_KIND,
|
|
621
|
-
rendezvousKey,
|
|
622
|
-
tagX: 'advert',
|
|
623
|
-
addressable: true,
|
|
624
|
-
/**
|
|
625
|
-
* @param {Uint8Array} bytes 加密 advert 载荷
|
|
626
|
-
* @param {object} meta relay 元数据
|
|
627
|
-
* @returns {void}
|
|
628
|
-
*/
|
|
629
|
-
onPayload: (bytes, meta) => onAdvert(bytes, meta),
|
|
630
|
-
})
|
|
631
|
-
return stop
|
|
823
|
+
return ensureNodeAdvertSubscription(hash, onAdvert)
|
|
632
824
|
},
|
|
633
825
|
/**
|
|
634
826
|
* @param {string} roomSecret 房间密钥
|
|
@@ -647,6 +839,7 @@ export function createNostrDiscoveryProvider(options = {}) {
|
|
|
647
839
|
if (abortController.signal.aborted) return
|
|
648
840
|
const beacon = await getBeacon?.()
|
|
649
841
|
if (!beacon?.nodeHash) return
|
|
842
|
+
noteSelfNodeHash(beacon.nodeHash)
|
|
650
843
|
const advertBody = beacon.advertBody || beacon.body || beacon
|
|
651
844
|
const bytes = encryptSignalPacket(rendezvousKey, { type: 'advert', body: advertBody })
|
|
652
845
|
const event = await signNostrEvent(
|
|
@@ -671,21 +864,7 @@ export function createNostrDiscoveryProvider(options = {}) {
|
|
|
671
864
|
* @returns {Promise<() => void>} 取消群 advert 监听
|
|
672
865
|
*/
|
|
673
866
|
async watchGroupAdverts(roomSecret, onAdvert) {
|
|
674
|
-
|
|
675
|
-
const rendezvousKey = groupRendezvousKey(key)
|
|
676
|
-
ensureGroupSubscription(key)
|
|
677
|
-
return subscribeNostrKind(resolveRelayUrls(), {
|
|
678
|
-
kind: NOSTR_ADVERT_KIND,
|
|
679
|
-
rendezvousKey,
|
|
680
|
-
tagX: 'advert',
|
|
681
|
-
addressable: true,
|
|
682
|
-
/**
|
|
683
|
-
* @param {Uint8Array} bytes 加密 advert 载荷
|
|
684
|
-
* @param {object} meta relay 元数据
|
|
685
|
-
* @returns {void}
|
|
686
|
-
*/
|
|
687
|
-
onPayload: (bytes, meta) => onAdvert(bytes, meta),
|
|
688
|
-
})
|
|
867
|
+
return ensureGroupSubscription(roomSecret, onAdvert)
|
|
689
868
|
},
|
|
690
869
|
/**
|
|
691
870
|
* 供 advert 解析路径写入可见 hash。
|
|
@@ -702,14 +881,9 @@ export function createNostrDiscoveryProvider(options = {}) {
|
|
|
702
881
|
* @returns {void}
|
|
703
882
|
*/
|
|
704
883
|
dispose() {
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
try { stop() } catch { /* ignore */ }
|
|
709
|
-
groupSubs.clear()
|
|
710
|
-
for (const stop of nodeAdvertSubs.values())
|
|
711
|
-
try { stop() } catch { /* ignore */ }
|
|
712
|
-
nodeAdvertSubs.clear()
|
|
884
|
+
for (const entry of advertSubs.values())
|
|
885
|
+
try { entry.stop() } catch { /* ignore */ }
|
|
886
|
+
advertSubs.clear()
|
|
713
887
|
for (const stop of nodeSignalSubs.values())
|
|
714
888
|
try { stop() } catch { /* ignore */ }
|
|
715
889
|
nodeSignalSubs.clear()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { normalizeHex64 } from '../core/hexIds.mjs'
|
|
2
|
+
|
|
3
|
+
/** @type {((nodeHash: string) => void) | null} */
|
|
4
|
+
let peerClueListener = null
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 由 link registry 注入:peer 首次可见时清 dial 冷却。
|
|
8
|
+
* @param {((nodeHash: string) => void) | null} listener 回调
|
|
9
|
+
* @returns {void}
|
|
10
|
+
*/
|
|
11
|
+
export function setDiscoveryPeerClueListener(listener) {
|
|
12
|
+
peerClueListener = listener
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* discovery accept 路径:peer 首次进入可见池时通知 registry。
|
|
17
|
+
* @param {string} nodeHash 对端
|
|
18
|
+
* @returns {void}
|
|
19
|
+
*/
|
|
20
|
+
export function noteDiscoveryPeerClue(nodeHash) {
|
|
21
|
+
const hash = normalizeHex64(nodeHash)
|
|
22
|
+
if (hash) peerClueListener?.(hash)
|
|
23
|
+
}
|
package/link/handshake.mjs
CHANGED
|
@@ -2,9 +2,9 @@ import { Buffer } from 'node:buffer'
|
|
|
2
2
|
import { randomBytes } from 'node:crypto'
|
|
3
3
|
|
|
4
4
|
import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
|
|
5
|
-
import { normalizeLanHosts } from '../discovery/lan_interfaces.mjs'
|
|
6
5
|
import { normalizeTcpPort } from '../core/tcp_port.mjs'
|
|
7
6
|
import { keyPairFromSeed, pubKeyHash, sign, verify } from '../crypto/crypto.mjs'
|
|
7
|
+
import { normalizeLanHosts } from '../discovery/lan_interfaces.mjs'
|
|
8
8
|
import { ensureNodeSeed, getNodeHash } from '../node/identity.mjs'
|
|
9
9
|
|
|
10
10
|
import { normalizeDtlsFingerprint } from './sdp_fingerprint.mjs'
|
|
@@ -153,10 +153,10 @@ export function buildAdvertMessage(rendezvousKey, ts, nodeHash, tcpPort = null,
|
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
/**
|
|
156
|
-
* 构造带签名的 discovery advert
|
|
156
|
+
* 构造带签名的 discovery advert(tcpPort / lanHosts 一并签入消息)。
|
|
157
157
|
* @param {string} rendezvousKey discovery 内部汇合键
|
|
158
158
|
* @param {number} [ts=Date.now()] 时间戳(毫秒)
|
|
159
|
-
* @param {{ secretKey?: Uint8Array, nodeHash?: string, nodePubKey?: string, tcpPort?: number, lanHosts?: unknown }
|
|
159
|
+
* @param {{ secretKey?: Uint8Array, nodeHash?: string, nodePubKey?: string, tcpPort?: number, lanHosts?: unknown } | null} [options] 签名身份;可选 LAN 监听端口与本机 IPv4 列表
|
|
160
160
|
* @returns {Promise<{ nodeHash: string, nodePubKey: string, ts: number, sig: string, tcpPort?: number, lanHosts?: string[] }>} 签名 advert
|
|
161
161
|
*/
|
|
162
162
|
export async function buildSignedAdvert(rendezvousKey, ts = Date.now(), options = null) {
|
|
@@ -34,6 +34,13 @@ function formatErrorReason(error) {
|
|
|
34
34
|
|
|
35
35
|
let cachedAvailable = null
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* @returns {boolean} 是否跑在 Deno
|
|
39
|
+
*/
|
|
40
|
+
function isDenoRuntime() {
|
|
41
|
+
return typeof globalThis.Deno !== 'undefined'
|
|
42
|
+
}
|
|
43
|
+
|
|
37
44
|
/**
|
|
38
45
|
* 探测 WebRTC(node-datachannel)是否可用。
|
|
39
46
|
* @returns {Promise<boolean>} 可用为 true
|
|
@@ -46,7 +53,12 @@ export async function canUseWebRtcLink() {
|
|
|
46
53
|
}
|
|
47
54
|
catch (error) {
|
|
48
55
|
cachedAvailable = false
|
|
49
|
-
|
|
56
|
+
const reason = formatErrorReason(error)
|
|
57
|
+
nodeDebug('p2p:webrtc unavailable', {
|
|
58
|
+
err: isDenoRuntime()
|
|
59
|
+
? `${reason} | deno: see docs/runtime.md (node-datachannel scripts only)`
|
|
60
|
+
: reason,
|
|
61
|
+
})
|
|
50
62
|
}
|
|
51
63
|
return cachedAvailable
|
|
52
64
|
}
|
package/link/rtc.mjs
CHANGED
|
@@ -4,15 +4,23 @@ import { toBytes } from '../core/bytes_codec.mjs'
|
|
|
4
4
|
import { getSignalingRuntimeConfig } from '../node/instance.mjs'
|
|
5
5
|
import { wrapRtcPeerConnectionForIceLocalHostname } from '../transport/rtc_ice_local_hostname.mjs'
|
|
6
6
|
|
|
7
|
+
/** @type {boolean} */
|
|
8
|
+
let exitCleanupHooked = false
|
|
9
|
+
|
|
7
10
|
/**
|
|
8
|
-
* 注册进程退出时销毁 libdatachannel
|
|
11
|
+
* 注册进程退出时销毁 libdatachannel 原生资源(首次成功加载后挂一次)。
|
|
9
12
|
* libdatachannel 的原生线程在 pc.close() 后仍需时间回收;进程退出时若原生资源未同步销毁,
|
|
10
13
|
* Windows 上会触发堆损坏(退出码 0xC0000374)。
|
|
14
|
+
* @returns {Promise<void>}
|
|
11
15
|
*/
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
})
|
|
16
|
+
async function ensureNodeDatachannelExitCleanup() {
|
|
17
|
+
if (exitCleanupHooked) return
|
|
18
|
+
exitCleanupHooked = true
|
|
19
|
+
const { cleanup = undefined } = await import('node-datachannel').catch(() => ({}))
|
|
20
|
+
process.on('exit', () => {
|
|
21
|
+
try { cleanup?.() } catch { /* already torn down */ }
|
|
22
|
+
})
|
|
23
|
+
}
|
|
16
24
|
|
|
17
25
|
/**
|
|
18
26
|
* 加载 node-datachannel polyfill,并按配置包装 RTCPeerConnection。
|
|
@@ -20,6 +28,7 @@ process.on('exit', () => {
|
|
|
20
28
|
*/
|
|
21
29
|
export async function loadNodeRtcPolyfill() {
|
|
22
30
|
const mod = await import('node-datachannel/polyfill')
|
|
31
|
+
await ensureNodeDatachannelExitCleanup()
|
|
23
32
|
const { iceLocalHostnamePolicy } = getSignalingRuntimeConfig()
|
|
24
33
|
return {
|
|
25
34
|
RTCPeerConnection: wrapRtcPeerConnectionForIceLocalHostname(mod.RTCPeerConnection, mod.RTCIceCandidate, iceLocalHostnamePolicy),
|
package/package.json
CHANGED
|
@@ -3,11 +3,13 @@ import { Buffer } from 'node:buffer'
|
|
|
3
3
|
import { compareHex64Asc, normalizeHex64 } from '../core/hexIds.mjs'
|
|
4
4
|
import { keyPairFromSeed } from '../crypto/crypto.mjs'
|
|
5
5
|
import { watchVerifiedNodeAdvert, setDiscoveryLinkDialer, prepareConnectToNode } from '../discovery/index.mjs'
|
|
6
|
+
import { setDiscoveryPeerClueListener } from '../discovery/peer_clue.mjs'
|
|
6
7
|
import { listLinkProviders } from '../link/providers/index.mjs'
|
|
7
8
|
import { ensureNodeSeed, getNodeHash } from '../node/identity.mjs'
|
|
8
9
|
import { nodeDebug, shortHash } from '../node/log.mjs'
|
|
9
10
|
import { loadPeerPoolView } from '../node/network.mjs'
|
|
10
11
|
import { createOverlayRouter } from '../overlay/index.mjs'
|
|
12
|
+
import { ms } from '../utils/duration.mjs'
|
|
11
13
|
import { emitSafe } from '../utils/emit_safe.mjs'
|
|
12
14
|
import { createLruMap } from '../utils/lru.mjs'
|
|
13
15
|
|
|
@@ -18,6 +20,11 @@ import { createOfferAnswerDial } from './offer_answer.mjs'
|
|
|
18
20
|
import { pickMeshEvictionVictim } from './peer_pool.mjs'
|
|
19
21
|
import { createRuntimeBootstrap } from './runtime_bootstrap.mjs'
|
|
20
22
|
|
|
23
|
+
/** dial exhausted 后的初始冷却。 */
|
|
24
|
+
const DIAL_COOLDOWN_BASE_MS = ms('30s')
|
|
25
|
+
/** dial 冷却上限。 */
|
|
26
|
+
const DIAL_COOLDOWN_MAX_MS = ms('10m')
|
|
27
|
+
|
|
21
28
|
/**
|
|
22
29
|
* 解析或从节点种子推导本地身份。
|
|
23
30
|
* @param {{ nodeHash?: string, nodePubKey?: string, secretKey?: Uint8Array } | undefined} localIdentity 可选的预置身份
|
|
@@ -84,6 +91,8 @@ export function createLinkRegistry(options = {}) {
|
|
|
84
91
|
const links = new Map()
|
|
85
92
|
/** @type {Map<string, Promise<object | null>>} */
|
|
86
93
|
const inflights = new Map()
|
|
94
|
+
/** @type {Map<string, { until: number, failures: number }> & { touch: (key: string, value: { until: number, failures: number }) => void }} */
|
|
95
|
+
const dialCooldown = createLruMap(1024)
|
|
87
96
|
/** @type {Map<string, ReturnType<typeof import('./offer_answer.mjs').createBufferedSignalSession>>} */
|
|
88
97
|
const signalSessions = new Map()
|
|
89
98
|
/** @type {Map<string, Set<string>>} */
|
|
@@ -265,6 +274,19 @@ export function createLinkRegistry(options = {}) {
|
|
|
265
274
|
if (!normalized || normalized === localIdentity.nodeHash) return null
|
|
266
275
|
if (links.has(normalized)) return links.get(normalized)
|
|
267
276
|
if (inflights.has(normalized)) return await inflights.get(normalized)
|
|
277
|
+
const cool = dialCooldown.get(normalized)
|
|
278
|
+
const now = Date.now()
|
|
279
|
+
if (cool) {
|
|
280
|
+
if (now < cool.until) {
|
|
281
|
+
nodeDebug('p2p:dial cooldown', {
|
|
282
|
+
peer: shortHash(normalized),
|
|
283
|
+
failures: cool.failures,
|
|
284
|
+
retryInMs: cool.until - now,
|
|
285
|
+
})
|
|
286
|
+
return null
|
|
287
|
+
}
|
|
288
|
+
dialCooldown.delete(normalized)
|
|
289
|
+
}
|
|
268
290
|
const task = (async () => {
|
|
269
291
|
nodeDebug('p2p:dial start', { peer: shortHash(normalized) })
|
|
270
292
|
await prepareConnectToNode(normalized)
|
|
@@ -289,6 +311,7 @@ export function createLinkRegistry(options = {}) {
|
|
|
289
311
|
if (provider.caps?.needsOfferAnswer) {
|
|
290
312
|
const link = await dialOfferAnswer(provider, normalized)
|
|
291
313
|
if (link) {
|
|
314
|
+
dialCooldown.delete(normalized)
|
|
292
315
|
nodeDebug('p2p:dial ok', { peer: shortHash(normalized), provider: provider.id })
|
|
293
316
|
return link
|
|
294
317
|
}
|
|
@@ -297,6 +320,7 @@ export function createLinkRegistry(options = {}) {
|
|
|
297
320
|
}
|
|
298
321
|
const link = await dialProvider(provider, normalized)
|
|
299
322
|
if (link) {
|
|
323
|
+
dialCooldown.delete(normalized)
|
|
300
324
|
nodeDebug('p2p:dial ok', { peer: shortHash(normalized), provider: provider.id })
|
|
301
325
|
return link
|
|
302
326
|
}
|
|
@@ -310,7 +334,10 @@ export function createLinkRegistry(options = {}) {
|
|
|
310
334
|
})
|
|
311
335
|
}
|
|
312
336
|
|
|
313
|
-
|
|
337
|
+
const failures = (cool?.failures || 0) + 1
|
|
338
|
+
const delay = Math.min(DIAL_COOLDOWN_MAX_MS, DIAL_COOLDOWN_BASE_MS * (2 ** Math.min(failures - 1, 5)))
|
|
339
|
+
dialCooldown.touch(normalized, { until: Date.now() + delay, failures })
|
|
340
|
+
nodeDebug('p2p:dial exhausted', { peer: shortHash(normalized), cooldownMs: delay, failures })
|
|
314
341
|
return null
|
|
315
342
|
})().finally(() => inflights.delete(normalized))
|
|
316
343
|
inflights.set(normalized, task)
|
|
@@ -351,6 +378,12 @@ export function createLinkRegistry(options = {}) {
|
|
|
351
378
|
})
|
|
352
379
|
exploreLinkHashes = meshKeepalive.exploreLinkHashes
|
|
353
380
|
setDiscoveryLinkDialer(ensureDirectLinkToNode)
|
|
381
|
+
setDiscoveryPeerClueListener(nodeHash => {
|
|
382
|
+
const hash = normalizeHex64(nodeHash)
|
|
383
|
+
if (!hash) return
|
|
384
|
+
dialCooldown.delete(hash)
|
|
385
|
+
recentAdverts.touch(hash, Date.now())
|
|
386
|
+
})
|
|
354
387
|
|
|
355
388
|
/**
|
|
356
389
|
*
|
|
@@ -619,6 +652,7 @@ export function createLinkRegistry(options = {}) {
|
|
|
619
652
|
return await watchVerifiedNodeAdvert(hash, async (verifiedNodeHash, body, meta) => {
|
|
620
653
|
applyAdvertPeerHints(verifiedNodeHash, body, meta)
|
|
621
654
|
recentAdverts.touch(verifiedNodeHash, Date.now())
|
|
655
|
+
dialCooldown.delete(verifiedNodeHash)
|
|
622
656
|
await Promise.resolve(onAdvert(verifiedNodeHash, body))
|
|
623
657
|
})
|
|
624
658
|
},
|
|
@@ -631,6 +665,7 @@ export function createLinkRegistry(options = {}) {
|
|
|
631
665
|
async shutdown() {
|
|
632
666
|
await meshKeepalive?.stop()
|
|
633
667
|
setDiscoveryLinkDialer(null)
|
|
668
|
+
setDiscoveryPeerClueListener(null)
|
|
634
669
|
await bootstrap.shutdown()
|
|
635
670
|
overlayRouter?.close()
|
|
636
671
|
overlayRouter = null
|
|
@@ -638,6 +673,7 @@ export function createLinkRegistry(options = {}) {
|
|
|
638
673
|
await link.close('registry-shutdown')
|
|
639
674
|
links.clear()
|
|
640
675
|
inflights.clear()
|
|
676
|
+
dialCooldown.clear()
|
|
641
677
|
for (const session of signalSessions.values()) session.clear()
|
|
642
678
|
signalSessions.clear()
|
|
643
679
|
},
|
|
@@ -116,7 +116,9 @@ export function createRuntimeBootstrap(deps) {
|
|
|
116
116
|
function registerDiscoveryDefaults() {
|
|
117
117
|
const providerIds = new Set(listDiscoveryProviders().map(provider => provider.id))
|
|
118
118
|
if (!providerIds.has('lan'))
|
|
119
|
-
registerDiscoveryProvider(createLanDiscoveryProvider(
|
|
119
|
+
registerDiscoveryProvider(createLanDiscoveryProvider({
|
|
120
|
+
localNodeHash: localIdentity.nodeHash,
|
|
121
|
+
}))
|
|
120
122
|
if (!providerIds.has('nostr'))
|
|
121
123
|
registerNostrProvider()
|
|
122
124
|
}
|
|
@@ -128,6 +130,7 @@ export function createRuntimeBootstrap(deps) {
|
|
|
128
130
|
unregisterDiscoveryProvider('nostr')
|
|
129
131
|
registerDiscoveryProvider(createNostrDiscoveryProvider({
|
|
130
132
|
getRelayUrls: resolveNostrRelayUrls,
|
|
133
|
+
localNodeHash: localIdentity.nodeHash,
|
|
131
134
|
}))
|
|
132
135
|
}
|
|
133
136
|
|