@steve02081504/fount-p2p 0.0.28 → 0.0.30
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/discovery/bt/index.mjs +1 -3
- package/discovery/nostr.mjs +193 -24
- package/files/chunk/store.mjs +3 -2
- package/index.mjs +2 -0
- package/infra/priority.mjs +1 -3
- package/infra/service.mjs +2 -6
- package/link/providers/ble_gatt.mjs +2 -6
- package/link/providers/lan_tcp.mjs +3 -9
- package/node/handles.mjs +49 -0
- package/node/instance.mjs +7 -4
- package/node/reputation_sync.mjs +2 -6
- package/package.json +1 -1
- package/transport/group_link_set.mjs +2 -6
- package/transport/link_registry.mjs +1 -3
- package/transport/offer_answer.mjs +1 -3
- package/transport/runtime_bootstrap.mjs +2 -6
- package/utils/async_mutex.mjs +1 -3
- package/utils/ttl_map.mjs +1 -3
package/discovery/bt/index.mjs
CHANGED
|
@@ -297,9 +297,7 @@ export function createBluetoothDiscoveryProvider() {
|
|
|
297
297
|
cleanup()
|
|
298
298
|
reject(new Error('p2p: bt signal peripheral timeout'))
|
|
299
299
|
}, 8_000)
|
|
300
|
-
/**
|
|
301
|
-
* @returns {void}
|
|
302
|
-
*/
|
|
300
|
+
/** 清理信号超时计时器并移除 discover 监听器 */
|
|
303
301
|
function cleanup() {
|
|
304
302
|
clearTimeout(deadline)
|
|
305
303
|
noble.removeListener('discover', onDiscover)
|
package/discovery/nostr.mjs
CHANGED
|
@@ -34,6 +34,8 @@ export const NOSTR_CLOSE_GRACE_MS = 1_000
|
|
|
34
34
|
export const NOSTR_PUBLISH_OK_TIMEOUT_MS = 3_000
|
|
35
35
|
/** 共享 relay 会话断线后重连间隔。 */
|
|
36
36
|
export const NOSTR_RECONNECT_DELAY_MS = 500
|
|
37
|
+
/** 无 sub/publish 工作时共享 relay 空闲回收延迟(给连续 send 复用窗口)。 */
|
|
38
|
+
export const NOSTR_IDLE_DROP_MS = 2_000
|
|
37
39
|
|
|
38
40
|
/** Nostr network advert 事件 kind(addressable,可存储)。 */
|
|
39
41
|
export const NOSTR_ADVERT_KIND = 30787
|
|
@@ -241,6 +243,7 @@ function connectRelay(relayUrl, timeoutMs = NOSTR_CONNECT_TIMEOUT_MS, signal) {
|
|
|
241
243
|
reject(error)
|
|
242
244
|
}
|
|
243
245
|
/**
|
|
246
|
+
* 收到 abort 信号时失败
|
|
244
247
|
* @returns {void}
|
|
245
248
|
*/
|
|
246
249
|
const onAbort = () => fail(new Error('nostr: aborted'))
|
|
@@ -265,9 +268,10 @@ function connectRelay(relayUrl, timeoutMs = NOSTR_CONNECT_TIMEOUT_MS, signal) {
|
|
|
265
268
|
* @param {string} relayUrl 中继 URL
|
|
266
269
|
* @param {object} event 待发布事件
|
|
267
270
|
* @param {AbortSignal} [signal] 取消信号
|
|
271
|
+
* @param {() => boolean} [isCurrent] 该尝试是否仍是 publishRequest 的当前尝试(断线重发后过期)
|
|
268
272
|
* @returns {Promise<boolean>} relay 是否接受 EVENT
|
|
269
273
|
*/
|
|
270
|
-
function publishEventOnRelay(ws, relayUrl, event, signal) {
|
|
274
|
+
function publishEventOnRelay(ws, relayUrl, event, signal, isCurrent) {
|
|
271
275
|
return new Promise((resolve, reject) => {
|
|
272
276
|
if (signal?.aborted) {
|
|
273
277
|
reject(new Error('nostr: aborted'))
|
|
@@ -285,10 +289,13 @@ function publishEventOnRelay(ws, relayUrl, event, signal) {
|
|
|
285
289
|
clearTimeout(timer)
|
|
286
290
|
signal?.removeEventListener('abort', onAbort)
|
|
287
291
|
ws.off('message', onMessage)
|
|
292
|
+
// 过期的 in-flight 尝试:只清理自身监听器/定时器,不结算,避免影响重发后的当前尝试。
|
|
293
|
+
if (!isCurrent()) return
|
|
288
294
|
if (error) reject(error)
|
|
289
295
|
else resolve(ok)
|
|
290
296
|
}
|
|
291
297
|
/**
|
|
298
|
+
* 收到 abort 信号时以失败结束
|
|
292
299
|
* @returns {void}
|
|
293
300
|
*/
|
|
294
301
|
const onAbort = () => finish(false, new Error('nostr: aborted'))
|
|
@@ -331,42 +338,93 @@ function publishEventOnRelay(ws, relayUrl, event, signal) {
|
|
|
331
338
|
* @returns {Promise<void>}
|
|
332
339
|
*/
|
|
333
340
|
async function publishEvent(relayUrls, event, signal) {
|
|
334
|
-
|
|
341
|
+
const urls = dedupeRelayUrls(relayUrls)
|
|
342
|
+
if (!urls.length) throw new Error('nostr: no relay')
|
|
335
343
|
let published = false
|
|
336
344
|
let lastError = null
|
|
337
|
-
await Promise.allSettled(
|
|
338
|
-
if (signal?.aborted) throw new Error('nostr: aborted')
|
|
339
|
-
const ws = await connectRelay(relayUrl, NOSTR_CONNECT_TIMEOUT_MS, signal)
|
|
345
|
+
await Promise.allSettled(urls.map(async relayUrl => {
|
|
340
346
|
try {
|
|
341
|
-
if (signal
|
|
342
|
-
const ok = await publishEventOnRelay(ws, relayUrl, event, signal)
|
|
343
|
-
if (ok) published = true
|
|
347
|
+
if (await publishViaSharedRelay(relayUrl, event, signal)) published = true
|
|
344
348
|
}
|
|
345
349
|
catch (error) {
|
|
346
350
|
lastError = error
|
|
347
|
-
throw error
|
|
348
|
-
}
|
|
349
|
-
finally {
|
|
350
|
-
dropWebSocket(ws)
|
|
351
351
|
}
|
|
352
352
|
}))
|
|
353
353
|
if (!published) throw lastError || new Error('nostr: no relay accepted publish')
|
|
354
354
|
}
|
|
355
355
|
|
|
356
356
|
/**
|
|
357
|
-
*
|
|
358
|
-
*
|
|
357
|
+
* 一次共享 relay publish 请求:入队(pending)与已派发(inflight)时共用同一结构。
|
|
358
|
+
* attempt 标识每次实际发送尝试,断线重发会递增;旧的 in-flight 尝试据此自检为过期后自我清理,
|
|
359
|
+
* 不再结算当前尝试。
|
|
360
|
+
* @typedef {{
|
|
361
|
+
* event: object,
|
|
362
|
+
* signal: AbortSignal | undefined,
|
|
363
|
+
* attempt: number,
|
|
364
|
+
* onAbort: (() => void) | null,
|
|
365
|
+
* removeAbort: (() => void) | null,
|
|
366
|
+
* resolve: (ok: boolean) => void,
|
|
367
|
+
* reject: (err: Error) => void,
|
|
368
|
+
* }} NostrPublishRequest
|
|
369
|
+
*/
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* 共享 relay 会话:多 SUB / publish 复用同一 URL 的 WebSocket,避免 signal/presence/advert 各建一池、
|
|
373
|
+
* 以及每次 send 重开一条连接(内存泄漏)。
|
|
374
|
+
* 仍有活跃 sub 或待发 publish 时断线会自动重连并重发 REQ / 重发 EVENT。
|
|
359
375
|
* @typedef {{
|
|
360
376
|
* ws: import('ws').WebSocket | null,
|
|
361
377
|
* connecting: boolean,
|
|
362
378
|
* reconnectTimer: ReturnType<typeof setTimeout> | null,
|
|
379
|
+
* idleTimer: ReturnType<typeof setTimeout> | null,
|
|
363
380
|
* subs: Map<string, { filter: object, onEvent: (event: object, relayUrl: string) => void }>,
|
|
381
|
+
* pendingPublishes: Array<NostrPublishRequest>,
|
|
382
|
+
* inflightPublishes: Array<NostrPublishRequest>,
|
|
364
383
|
* }} SharedRelaySession
|
|
365
384
|
*/
|
|
366
385
|
|
|
367
386
|
/** @type {Map<string, SharedRelaySession>} */
|
|
368
387
|
const sharedRelaySessions = new Map()
|
|
369
388
|
|
|
389
|
+
/**
|
|
390
|
+
* 会话是否仍有需要连接的工作(sub 或待发 publish)。
|
|
391
|
+
* @param {SharedRelaySession} session 会话
|
|
392
|
+
* @returns {boolean} 是否有活跃工作
|
|
393
|
+
*/
|
|
394
|
+
function hasPendingWork(session) {
|
|
395
|
+
return session.subs.size || session.pendingPublishes.length || session.inflightPublishes.length
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* 取消会话的空闲回收定时器。
|
|
400
|
+
* @param {SharedRelaySession} session 会话
|
|
401
|
+
* @returns {void}
|
|
402
|
+
*/
|
|
403
|
+
function clearIdleDrop(session) {
|
|
404
|
+
if (!session.idleTimer) return
|
|
405
|
+
clearTimeout(session.idleTimer)
|
|
406
|
+
session.idleTimer = null
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* 会话无 sub 时,待 publish 清空后延迟回收空闲 socket(给连续 send 复用窗口)。
|
|
411
|
+
* @param {string} relayUrl 中继 URL
|
|
412
|
+
* @param {SharedRelaySession} session 会话
|
|
413
|
+
* @returns {void}
|
|
414
|
+
*/
|
|
415
|
+
function scheduleIdleDrop(relayUrl, session) {
|
|
416
|
+
if (session.subs.size || session.idleTimer) return
|
|
417
|
+
session.idleTimer = setTimeout(() => {
|
|
418
|
+
session.idleTimer = null
|
|
419
|
+
if (!isLiveSharedSession(relayUrl, session) || hasPendingWork(session)) return
|
|
420
|
+
sharedRelaySessions.delete(relayUrl)
|
|
421
|
+
clearSharedRelayReconnect(session)
|
|
422
|
+
if (session.ws) dropWebSocket(session.ws)
|
|
423
|
+
session.ws = null
|
|
424
|
+
}, NOSTR_IDLE_DROP_MS)
|
|
425
|
+
session.idleTimer.unref?.()
|
|
426
|
+
}
|
|
427
|
+
|
|
370
428
|
/**
|
|
371
429
|
* @param {string} relayUrl 中继 URL
|
|
372
430
|
* @param {SharedRelaySession} session 会话
|
|
@@ -408,7 +466,14 @@ function attachSharedRelaySocket(relayUrl, session, ws) {
|
|
|
408
466
|
ws.once('close', () => {
|
|
409
467
|
if (!isLiveSharedSession(relayUrl, session)) return
|
|
410
468
|
session.ws = null
|
|
411
|
-
|
|
469
|
+
// 尚未确认的 inflight publish 重新入队,重连后由 flushPendingPublishes 重发。
|
|
470
|
+
if (session.inflightPublishes.length) {
|
|
471
|
+
session.pendingPublishes.push(...session.inflightPublishes)
|
|
472
|
+
for (const publishRequest of session.inflightPublishes)
|
|
473
|
+
attachQueuedAbort(session, publishRequest)
|
|
474
|
+
session.inflightPublishes = []
|
|
475
|
+
}
|
|
476
|
+
if (!hasPendingWork(session)) {
|
|
412
477
|
sharedRelaySessions.delete(relayUrl)
|
|
413
478
|
return
|
|
414
479
|
}
|
|
@@ -416,6 +481,8 @@ function attachSharedRelaySocket(relayUrl, session, ws) {
|
|
|
416
481
|
})
|
|
417
482
|
for (const [subId, sub] of session.subs)
|
|
418
483
|
try { ws.send(JSON.stringify(['REQ', subId, sub.filter])) } catch { /* ignore */ }
|
|
484
|
+
flushPendingPublishes(relayUrl, session, ws)
|
|
485
|
+
if (!hasPendingWork(session)) scheduleIdleDrop(relayUrl, session)
|
|
419
486
|
}
|
|
420
487
|
|
|
421
488
|
/**
|
|
@@ -427,22 +494,19 @@ function attachSharedRelaySocket(relayUrl, session, ws) {
|
|
|
427
494
|
function scheduleSharedRelayConnect(relayUrl, session, delayMs = 0) {
|
|
428
495
|
if (!isLiveSharedSession(relayUrl, session) || session.connecting || session.ws) return
|
|
429
496
|
clearSharedRelayReconnect(session)
|
|
430
|
-
/**
|
|
431
|
-
* @returns {void}
|
|
432
|
-
*/
|
|
497
|
+
/** 启动/恢复共享会话连接 */
|
|
433
498
|
const start = () => {
|
|
434
499
|
session.reconnectTimer = null
|
|
435
500
|
if (!isLiveSharedSession(relayUrl, session) || session.connecting || session.ws) return
|
|
436
|
-
if (!session
|
|
501
|
+
if (!hasPendingWork(session)) {
|
|
437
502
|
sharedRelaySessions.delete(relayUrl)
|
|
438
503
|
return
|
|
439
504
|
}
|
|
440
505
|
session.connecting = true
|
|
441
506
|
void connectRelay(relayUrl, NOSTR_CONNECT_TIMEOUT_MS).then(ws => {
|
|
442
507
|
session.connecting = false
|
|
443
|
-
if (!isLiveSharedSession(relayUrl, session) || !session
|
|
444
|
-
|
|
445
|
-
sharedRelaySessions.delete(relayUrl)
|
|
508
|
+
if (!isLiveSharedSession(relayUrl, session) || !hasPendingWork(session)) {
|
|
509
|
+
sharedRelaySessions.delete(relayUrl)
|
|
446
510
|
dropWebSocket(ws)
|
|
447
511
|
return
|
|
448
512
|
}
|
|
@@ -454,7 +518,7 @@ function scheduleSharedRelayConnect(relayUrl, session, delayMs = 0) {
|
|
|
454
518
|
url: relayUrl,
|
|
455
519
|
err: String(error?.message || error),
|
|
456
520
|
})
|
|
457
|
-
if (!session
|
|
521
|
+
if (!hasPendingWork(session)) {
|
|
458
522
|
sharedRelaySessions.delete(relayUrl)
|
|
459
523
|
return
|
|
460
524
|
}
|
|
@@ -483,7 +547,10 @@ function acquireSharedRelay(relayUrl) {
|
|
|
483
547
|
ws: null,
|
|
484
548
|
connecting: false,
|
|
485
549
|
reconnectTimer: null,
|
|
550
|
+
idleTimer: null,
|
|
486
551
|
subs: new Map(),
|
|
552
|
+
pendingPublishes: [],
|
|
553
|
+
inflightPublishes: [],
|
|
487
554
|
}
|
|
488
555
|
sharedRelaySessions.set(relayUrl, session)
|
|
489
556
|
scheduleSharedRelayConnect(relayUrl, session)
|
|
@@ -502,9 +569,10 @@ function releaseSharedRelaySub(relayUrl, subscriptionId) {
|
|
|
502
569
|
const { ws } = session
|
|
503
570
|
if (ws?.readyState === WebSocket.OPEN)
|
|
504
571
|
try { ws.send(JSON.stringify(['CLOSE', subscriptionId])) } catch { /* ignore */ }
|
|
505
|
-
if (session
|
|
572
|
+
if (hasPendingWork(session)) return
|
|
506
573
|
sharedRelaySessions.delete(relayUrl)
|
|
507
574
|
clearSharedRelayReconnect(session)
|
|
575
|
+
clearIdleDrop(session)
|
|
508
576
|
if (ws) dropWebSocket(ws)
|
|
509
577
|
}
|
|
510
578
|
|
|
@@ -518,11 +586,112 @@ function releaseSharedRelaySub(relayUrl, subscriptionId) {
|
|
|
518
586
|
*/
|
|
519
587
|
function registerSharedRelaySub(session, subscriptionId, filter, onEvent) {
|
|
520
588
|
session.subs.set(subscriptionId, { filter, onEvent })
|
|
589
|
+
clearIdleDrop(session)
|
|
521
590
|
const { ws } = session
|
|
522
591
|
if (ws?.readyState !== WebSocket.OPEN) return
|
|
523
592
|
try { ws.send(JSON.stringify(['REQ', subscriptionId, filter])) } catch { /* ignore */ }
|
|
524
593
|
}
|
|
525
594
|
|
|
595
|
+
/**
|
|
596
|
+
* 将已入队的 publish 逐个派发到已打开 socket 上;复用 publishEventOnRelay 等 OK 回执。
|
|
597
|
+
* 每个 publish 独立携带自己的 message/abort 监听,socket 共享安全。
|
|
598
|
+
* @param {string} relayUrl 中继 URL
|
|
599
|
+
* @param {SharedRelaySession} session 会话
|
|
600
|
+
* @param {import('ws').WebSocket} socket 已打开连接
|
|
601
|
+
* @returns {void}
|
|
602
|
+
*/
|
|
603
|
+
function flushPendingPublishes(relayUrl, session, socket) {
|
|
604
|
+
const pending = session.pendingPublishes
|
|
605
|
+
session.pendingPublishes = []
|
|
606
|
+
for (const publishRequest of pending) {
|
|
607
|
+
publishRequest.removeAbort?.()
|
|
608
|
+
publishRequest.onAbort = null
|
|
609
|
+
publishRequest.removeAbort = null
|
|
610
|
+
const attempt = ++publishRequest.attempt
|
|
611
|
+
session.inflightPublishes.push(publishRequest)
|
|
612
|
+
void publishEventOnRelay(socket, relayUrl, publishRequest.event, publishRequest.signal, () => publishRequest.attempt === attempt)
|
|
613
|
+
.then(
|
|
614
|
+
ok => settleInflightPublish(relayUrl, session, publishRequest, attempt, () => publishRequest.resolve(ok)),
|
|
615
|
+
error => settleInflightPublish(relayUrl, session, publishRequest, attempt, () => publishRequest.reject(error)),
|
|
616
|
+
)
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* 为队列中的 publish 挂上 abort 处理:先移除监听器(释放 signal 对回调/event/session 的引用),
|
|
622
|
+
* 再从队列删除并 reject。
|
|
623
|
+
* @param {SharedRelaySession} session 会话
|
|
624
|
+
* @param {SharedRelaySession['pendingPublishes'][number]} publishRequest 待发请求
|
|
625
|
+
* @returns {void}
|
|
626
|
+
*/
|
|
627
|
+
function attachQueuedAbort(session, publishRequest) {
|
|
628
|
+
const { signal, reject } = publishRequest
|
|
629
|
+
/**
|
|
630
|
+
* 处理 abort 事件
|
|
631
|
+
*/
|
|
632
|
+
publishRequest.onAbort = () => {
|
|
633
|
+
publishRequest.removeAbort?.()
|
|
634
|
+
const pendingIndex = session.pendingPublishes.indexOf(publishRequest)
|
|
635
|
+
if (pendingIndex < 0) return
|
|
636
|
+
session.pendingPublishes.splice(pendingIndex, 1)
|
|
637
|
+
reject(new Error('nostr: aborted'))
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* 移除 abort 监听器
|
|
641
|
+
*/
|
|
642
|
+
publishRequest.removeAbort = () => {
|
|
643
|
+
if (signal) signal.removeEventListener('abort', publishRequest.onAbort)
|
|
644
|
+
}
|
|
645
|
+
if (signal) signal.addEventListener('abort', publishRequest.onAbort, { once: true })
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* publish 在 socket 上得到 OK / 失败 / abort 后结算:从 inflight 移除并 settle。
|
|
650
|
+
* 仅结算仍与当前 attempt 匹配的请求:断线重发后 attempt 已递增,旧尝试的结算在此短路,
|
|
651
|
+
* 避免提前结算重发后的当前尝试。已被 requeue(断线重发)的请求不在 inflight,跳过结算。
|
|
652
|
+
* @param {string} relayUrl 中继 URL
|
|
653
|
+
* @param {SharedRelaySession} session 会话
|
|
654
|
+
* @param {SharedRelaySession['inflightPublishes'][number]} publishRequest 已发送请求
|
|
655
|
+
* @param {number} attempt 本次发送的 attempt 标识
|
|
656
|
+
* @param {() => void} settle 结算回调(resolve/reject)
|
|
657
|
+
* @returns {void}
|
|
658
|
+
*/
|
|
659
|
+
function settleInflightPublish(relayUrl, session, publishRequest, attempt, settle) {
|
|
660
|
+
if (publishRequest.attempt !== attempt) return
|
|
661
|
+
const inflightIndex = session.inflightPublishes.indexOf(publishRequest)
|
|
662
|
+
if (inflightIndex < 0) return
|
|
663
|
+
session.inflightPublishes.splice(inflightIndex, 1)
|
|
664
|
+
settle()
|
|
665
|
+
if (!hasPendingWork(session)) scheduleIdleDrop(relayUrl, session)
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* 通过共享 relay 会话发布 EVENT:复用已打开 socket,避免每次 send 重开一条连接(内存泄漏)。
|
|
670
|
+
* 无现成连接时入队并触发连接,连上后由 flushPendingPublishes 统一派发。
|
|
671
|
+
* @param {string} relayUrl 中继 URL
|
|
672
|
+
* @param {object} event 待发布事件
|
|
673
|
+
* @param {AbortSignal} [signal] 取消信号
|
|
674
|
+
* @returns {Promise<boolean>} relay 是否接受 EVENT
|
|
675
|
+
*/
|
|
676
|
+
function publishViaSharedRelay(relayUrl, event, signal) {
|
|
677
|
+
return new Promise((resolve, reject) => {
|
|
678
|
+
if (signal?.aborted) {
|
|
679
|
+
reject(new Error('nostr: aborted'))
|
|
680
|
+
return
|
|
681
|
+
}
|
|
682
|
+
const session = acquireSharedRelay(relayUrl)
|
|
683
|
+
clearIdleDrop(session)
|
|
684
|
+
/** @type {SharedRelaySession['pendingPublishes'][number]} */
|
|
685
|
+
const publishRequest = { event, signal, attempt: 0, resolve, reject, onAbort: null, removeAbort: null }
|
|
686
|
+
attachQueuedAbort(session, publishRequest)
|
|
687
|
+
session.pendingPublishes.push(publishRequest)
|
|
688
|
+
if (session.ws?.readyState === WebSocket.OPEN)
|
|
689
|
+
flushPendingPublishes(relayUrl, session, session.ws)
|
|
690
|
+
else
|
|
691
|
+
scheduleSharedRelayConnect(relayUrl, session)
|
|
692
|
+
})
|
|
693
|
+
}
|
|
694
|
+
|
|
526
695
|
/**
|
|
527
696
|
* 内部:按 rendezvous 键订阅 Nostr kind(topic 不导出)。多订阅共享每 URL 一条连接。
|
|
528
697
|
* @param {string[]} relayUrls 中继 URL 列表
|
package/files/chunk/store.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { dirname, join } from 'node:path'
|
|
|
5
5
|
import { pipeline } from 'node:stream/promises'
|
|
6
6
|
|
|
7
7
|
import { isHex64 } from '../../core/hexIds.mjs'
|
|
8
|
+
import { trackFileStream } from '../../node/handles.mjs'
|
|
8
9
|
import { getNodeDir } from '../../node/instance.mjs'
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -50,7 +51,7 @@ export async function getChunk(hash) {
|
|
|
50
51
|
* @returns {import('node:fs').ReadStream} 可读流
|
|
51
52
|
*/
|
|
52
53
|
export function createChunkReadStream(hash) {
|
|
53
|
-
return fs.createReadStream(chunkStorePath(hash))
|
|
54
|
+
return trackFileStream(fs.createReadStream(chunkStorePath(hash)))
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
/**
|
|
@@ -72,5 +73,5 @@ export async function putChunk(hash, data) {
|
|
|
72
73
|
export async function putChunkFromStream(hash, readable) {
|
|
73
74
|
const filePath = chunkStorePath(hash)
|
|
74
75
|
await fsp.mkdir(dirname(filePath), { recursive: true })
|
|
75
|
-
await pipeline(readable, fs.createWriteStream(filePath))
|
|
76
|
+
await pipeline(readable, trackFileStream(fs.createWriteStream(filePath)))
|
|
76
77
|
}
|
package/index.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { registerLinkProvider } from './link/providers/index.mjs'
|
|
12
12
|
import { ensureNodeDefaults, getNodeHash } from './node/identity.mjs'
|
|
13
13
|
import {
|
|
14
|
+
closeNode,
|
|
14
15
|
getNodeDir,
|
|
15
16
|
initNode,
|
|
16
17
|
isNodeInitialized,
|
|
@@ -58,6 +59,7 @@ import {
|
|
|
58
59
|
export {
|
|
59
60
|
attachReputationSyncWire,
|
|
60
61
|
attachNodeScopeDefaultFeatures,
|
|
62
|
+
closeNode,
|
|
61
63
|
configureLinkRegistry,
|
|
62
64
|
createGroupLinkSet,
|
|
63
65
|
createScopedLinkRoom,
|
package/infra/priority.mjs
CHANGED
|
@@ -28,9 +28,7 @@ export function getInfraPriority() {
|
|
|
28
28
|
return { ...priorityConfig }
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
/**
|
|
32
|
-
* @returns {void}
|
|
33
|
-
*/
|
|
31
|
+
/** 将优先级配置应用到 link registry */
|
|
34
32
|
export function applyPriorityToRegistry() {
|
|
35
33
|
const registry = getLinkRegistry()
|
|
36
34
|
if (!priorityConfig.useLocalReputation) {
|
package/infra/service.mjs
CHANGED
|
@@ -42,9 +42,7 @@ export function consumeOverlayRateToken(buckets, sender, now, limits) {
|
|
|
42
42
|
return true
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/**
|
|
46
|
-
* @returns {void}
|
|
47
|
-
*/
|
|
45
|
+
/** 安装 overlay 速率门 */
|
|
48
46
|
function installOverlayRateLimit() {
|
|
49
47
|
const limits = {
|
|
50
48
|
perMin: Math.max(1, Number(infraTunables.overlayRatePerMin) || 120),
|
|
@@ -56,9 +54,7 @@ function installOverlayRateLimit() {
|
|
|
56
54
|
})
|
|
57
55
|
}
|
|
58
56
|
|
|
59
|
-
/**
|
|
60
|
-
* @returns {void}
|
|
61
|
-
*/
|
|
57
|
+
/** 移除 overlay 速率门并清空桶 */
|
|
62
58
|
function removeOverlayRateLimit() {
|
|
63
59
|
clearOverlayRateGate()
|
|
64
60
|
overlayRateBuckets.clear()
|
|
@@ -80,9 +80,7 @@ async function dialBleGatt(options) {
|
|
|
80
80
|
cleanup()
|
|
81
81
|
reject(new Error('p2p: ble_gatt peripheral scan timeout'))
|
|
82
82
|
}, 8_000)
|
|
83
|
-
/**
|
|
84
|
-
* @returns {void}
|
|
85
|
-
*/
|
|
83
|
+
/** 清理扫描超时计时器与监听器并停止 BLE 扫描 */
|
|
86
84
|
function cleanup() {
|
|
87
85
|
clearTimeout(deadline)
|
|
88
86
|
noble.removeListener('discover', onDiscover)
|
|
@@ -249,9 +247,7 @@ export function createBleGattLinkProvider() {
|
|
|
249
247
|
sessionInbound = handler
|
|
250
248
|
return () => { sessionInbound = null }
|
|
251
249
|
},
|
|
252
|
-
/**
|
|
253
|
-
* @returns {void}
|
|
254
|
-
*/
|
|
250
|
+
/** 关闭入站 transport */
|
|
255
251
|
closeTransport() {
|
|
256
252
|
activeInbound = null
|
|
257
253
|
sessionInbound = null
|
|
@@ -35,9 +35,7 @@ function connectTcp(host, port, timeoutMs = LAN_TCP_CONNECT_TIMEOUT_MS) {
|
|
|
35
35
|
conn.off('connect', onConnect)
|
|
36
36
|
reject(error)
|
|
37
37
|
}
|
|
38
|
-
/**
|
|
39
|
-
* @returns {void}
|
|
40
|
-
*/
|
|
38
|
+
/** TCP 连接成功并返回 socket */
|
|
41
39
|
function onConnect() {
|
|
42
40
|
clearTimeout(timer)
|
|
43
41
|
conn.off('error', onError)
|
|
@@ -111,9 +109,7 @@ function attachLengthPrefix(socket, onPayload) {
|
|
|
111
109
|
header.writeUInt32BE(body.length)
|
|
112
110
|
socket.write(Buffer.concat([header, body]))
|
|
113
111
|
},
|
|
114
|
-
/**
|
|
115
|
-
* @returns {void}
|
|
116
|
-
*/
|
|
112
|
+
/** 仅移除 data 监听器 */
|
|
117
113
|
destroy() {
|
|
118
114
|
socket.off('data', onData)
|
|
119
115
|
},
|
|
@@ -149,9 +145,7 @@ function createTcpPipe(options) {
|
|
|
149
145
|
sendFrame(_action, frame) {
|
|
150
146
|
codec.write(frame)
|
|
151
147
|
},
|
|
152
|
-
/**
|
|
153
|
-
* @returns {void}
|
|
154
|
-
*/
|
|
148
|
+
/** 关闭 transport 与 socket */
|
|
155
149
|
closeTransport() {
|
|
156
150
|
codec.destroy()
|
|
157
151
|
socket.destroy()
|
package/node/handles.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 节点生命周期内打开的文件流注册表。
|
|
3
|
+
*
|
|
4
|
+
* 文件流(chunk 读流 / 写流)在 Windows 上若未 destroy 会占用文件句柄,
|
|
5
|
+
* 导致节点目录删除失败。这里集中跟踪,`closeNode()` 通过
|
|
6
|
+
* `closeAllFileStreams()` 一次性释放全部仍打开的流。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** @type {Set<import('node:stream').Readable | import('node:stream').Writable>} */
|
|
10
|
+
const streams = new Set()
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 跟踪一个文件流:销毁/关闭/出错时自动从注册表移除。
|
|
14
|
+
* @template {import('node:stream').Readable | import('node:stream').Writable} T
|
|
15
|
+
* @param {T} stream 文件流
|
|
16
|
+
* @returns {T} 原流
|
|
17
|
+
*/
|
|
18
|
+
export function trackFileStream(stream) {
|
|
19
|
+
streams.add(stream)
|
|
20
|
+
/** @returns {void} */
|
|
21
|
+
const forget = () => streams.delete(stream)
|
|
22
|
+
stream.once('close', forget)
|
|
23
|
+
stream.once('error', forget)
|
|
24
|
+
return stream
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 关闭并销毁所有仍在打开的文件流(测试 teardown / 节点关闭)。
|
|
29
|
+
* @returns {Promise<number>} 被销毁的流数量
|
|
30
|
+
*/
|
|
31
|
+
export async function closeAllFileStreams() {
|
|
32
|
+
const targeted = [...streams]
|
|
33
|
+
const pending = []
|
|
34
|
+
for (const stream of targeted) {
|
|
35
|
+
if (!stream.closed)
|
|
36
|
+
pending.push(new Promise(resolve => stream.once('close', resolve)))
|
|
37
|
+
stream.destroy()
|
|
38
|
+
}
|
|
39
|
+
await Promise.all(pending)
|
|
40
|
+
for (const stream of targeted) streams.delete(stream)
|
|
41
|
+
return targeted.length
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @returns {boolean} 是否仍有未释放的文件流(测试断言用)
|
|
46
|
+
*/
|
|
47
|
+
export function hasOpenFileStreams() {
|
|
48
|
+
return streams.size > 0
|
|
49
|
+
}
|
package/node/instance.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
|
|
3
3
|
import { createFsEntityStore } from './entity_store.mjs'
|
|
4
|
+
import { closeAllFileStreams } from './handles.mjs'
|
|
4
5
|
import { defaultSignalingRuntimeConfig, resolveSignalingRuntimeConfig } from './signaling_config.mjs'
|
|
5
6
|
|
|
6
7
|
/** @typedef {{ warn?: (...args: unknown[]) => void, error?: (...args: unknown[]) => void, info?: (...args: unknown[]) => void, log?: (...args: unknown[]) => void }} NodeLogger */
|
|
@@ -37,7 +38,7 @@ export function getRtcPolyfillCacheEpoch() {
|
|
|
37
38
|
* @returns {NodeRuntime} 初始化后的运行时
|
|
38
39
|
*/
|
|
39
40
|
export function initNode(options) {
|
|
40
|
-
if (runtime) throw new Error('p2p: initNode already called — use setNodeLogger / setSignalingRuntimeConfig or
|
|
41
|
+
if (runtime) throw new Error('p2p: initNode already called — use setNodeLogger / setSignalingRuntimeConfig or closeNode')
|
|
41
42
|
if (options?.logger !== undefined || options?.signaling !== undefined)
|
|
42
43
|
throw new Error('p2p: initNode only accepts nodeDir/entityStore — use setNodeLogger / setSignalingRuntimeConfig')
|
|
43
44
|
const nodeDir = path.resolve(options.nodeDir)
|
|
@@ -138,11 +139,13 @@ export function onNodeChange(listener) {
|
|
|
138
139
|
}
|
|
139
140
|
|
|
140
141
|
/**
|
|
141
|
-
*
|
|
142
|
-
*
|
|
142
|
+
* 关闭节点:释放全部文件句柄(chunk 读/写流等)并清空运行时与监听器。
|
|
143
|
+
* 之后可用 initNode 重新引导。
|
|
144
|
+
* @returns {Promise<void>}
|
|
143
145
|
*/
|
|
144
|
-
export function
|
|
146
|
+
export async function closeNode() {
|
|
145
147
|
runtime = null
|
|
146
148
|
changeListeners.clear()
|
|
147
149
|
rtcPolyfillCacheEpoch++
|
|
150
|
+
await closeAllFileStreams()
|
|
148
151
|
}
|
package/node/reputation_sync.mjs
CHANGED
|
@@ -78,9 +78,7 @@ function normalizeHashList(list) {
|
|
|
78
78
|
.filter(id => isHex64(id)))]
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
/**
|
|
82
|
-
* @returns {void}
|
|
83
|
-
*/
|
|
81
|
+
/** 将当前 sync 配置写盘 */
|
|
84
82
|
function persistSyncConfig() {
|
|
85
83
|
writeNodeJsonSync(SYNC_DATA_NAME, loadSyncConfig())
|
|
86
84
|
}
|
|
@@ -304,9 +302,7 @@ export async function pullReputationFromNode(nodeHash) {
|
|
|
304
302
|
return await resultPromise
|
|
305
303
|
}
|
|
306
304
|
|
|
307
|
-
/**
|
|
308
|
-
* @returns {void}
|
|
309
|
-
*/
|
|
305
|
+
/** 重置 sync 运行时(测试用) */
|
|
310
306
|
export function resetReputationSyncForTests() {
|
|
311
307
|
syncConfig = null
|
|
312
308
|
detachReputationSyncWire()
|
package/package.json
CHANGED
|
@@ -99,9 +99,7 @@ export function createGroupLinkSet(options) {
|
|
|
99
99
|
scheduleDial()
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
/**
|
|
103
|
-
* @returns {void}
|
|
104
|
-
*/
|
|
102
|
+
/** 按成员选择目标并拨号 */
|
|
105
103
|
function selectAndDial() {
|
|
106
104
|
if (!autoconnectEnabled || !active) return
|
|
107
105
|
const targets = dialAll
|
|
@@ -119,9 +117,7 @@ export function createGroupLinkSet(options) {
|
|
|
119
117
|
void registry.ensureLinkToNode(nodeHash).catch(() => null)
|
|
120
118
|
}
|
|
121
119
|
|
|
122
|
-
/**
|
|
123
|
-
* @returns {void}
|
|
124
|
-
*/
|
|
120
|
+
/** 延迟调度一次拨号 */
|
|
125
121
|
function scheduleDial() {
|
|
126
122
|
if (!autoconnectEnabled || !active || dialTimer) return
|
|
127
123
|
dialTimer = setTimeout(() => { dialTimer = null; selectAndDial() }, 200)
|
|
@@ -719,9 +719,7 @@ export function configureLinkRegistry(options = {}) {
|
|
|
719
719
|
pendingRegistryOptions = { ...pendingRegistryOptions, ...options }
|
|
720
720
|
}
|
|
721
721
|
|
|
722
|
-
/**
|
|
723
|
-
* @returns {void}
|
|
724
|
-
*/
|
|
722
|
+
/** 重置 link registry 运行时(测试用) */
|
|
725
723
|
export function resetLinkRegistryForTests() {
|
|
726
724
|
defaultRegistry = null
|
|
727
725
|
pendingRegistryOptions = null
|
|
@@ -111,9 +111,7 @@ export function createRuntimeBootstrap(deps) {
|
|
|
111
111
|
?? mergeSignalingRelayUrls(getNodeTransportSettings().relayUrls)
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
/**
|
|
115
|
-
* @returns {void}
|
|
116
|
-
*/
|
|
114
|
+
/** 注册默认 discovery provider(lan/nostr) */
|
|
117
115
|
function registerDiscoveryDefaults() {
|
|
118
116
|
const providerIds = new Set(listDiscoveryProviders().map(provider => provider.id))
|
|
119
117
|
if (!providerIds.has('lan'))
|
|
@@ -124,9 +122,7 @@ export function createRuntimeBootstrap(deps) {
|
|
|
124
122
|
registerNostrProvider()
|
|
125
123
|
}
|
|
126
124
|
|
|
127
|
-
/**
|
|
128
|
-
* @returns {void}
|
|
129
|
-
*/
|
|
125
|
+
/** 注册/替换 nostr discovery provider */
|
|
130
126
|
function registerNostrProvider() {
|
|
131
127
|
unregisterDiscoveryProvider('nostr')
|
|
132
128
|
registerDiscoveryProvider(createNostrDiscoveryProvider({
|
package/utils/async_mutex.mjs
CHANGED
|
@@ -42,9 +42,7 @@ export async function withAsyncMutex(key, criticalSection) {
|
|
|
42
42
|
const lockKey = key
|
|
43
43
|
const state = mutexState(lockKey)
|
|
44
44
|
return new Promise((resolve, reject) => {
|
|
45
|
-
/**
|
|
46
|
-
* @returns {void}
|
|
47
|
-
*/
|
|
45
|
+
/** 执行临界区并最终释放锁 */
|
|
48
46
|
const run = () => {
|
|
49
47
|
Promise.resolve()
|
|
50
48
|
.then(criticalSection)
|