@steve02081504/fount-p2p 0.0.46 → 0.0.49

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.
@@ -0,0 +1,163 @@
1
+ import { randomBytes } from 'node:crypto'
2
+
3
+ import { isHex64 } from '../core/hexIds.mjs'
4
+ import { sendToNodeLink } from '../transport/link_registry.mjs'
5
+ import { attachNodeScopeFeature } from '../transport/node_scope/features.mjs'
6
+
7
+ import { getNodeHash } from './identity.mjs'
8
+
9
+ const MAX_TIMEOUT = 30 * 60 * 1000
10
+
11
+ /**
12
+ * @typedef {{ challenge: string, requesterNodeHash: string, expiresAt: number }} NetworkChallenge 挑战
13
+ */
14
+
15
+ /**
16
+ * 创建有界的挑战服务;发送方身份一律取自已认证的网络入口,不信任载荷自称。
17
+ * @param {{nodeHash: string, send: (peer: string, action: string, payload: object) => Promise<boolean>, now?: () => number}} options 依赖
18
+ * @returns {object} 挑战服务
19
+ */
20
+ export function createNetworkVerificationService({ nodeHash, send, now = Date.now }) {
21
+ const requests = new Map()
22
+ const proofs = new Map()
23
+ /**
24
+ * 清理已过期的挑战记录。
25
+ * @returns {void} 无返回值
26
+ */
27
+ function prune() {
28
+ for (const [id, entry] of requests) if (entry.expiresAt + 60000 < now()) requests.delete(id)
29
+ }
30
+ /**
31
+ * 取挑战快照,并把已过期的 pending 收敛为 failed。
32
+ * @param {object} entry 挑战状态
33
+ * @returns {object} 快照
34
+ */
35
+ function snapshot(entry) {
36
+ if (entry.status === 'pending' && now() >= entry.expiresAt) entry.status = 'failed', entry.reason = 'timeout'
37
+ return { ...entry }
38
+ }
39
+ return {
40
+ /**
41
+ * 新建一个挑战:限幅超时、限制在途数量,返回待验证快照。
42
+ * @param {{ timeoutMs?: number }} [options] 超时选项
43
+ * @returns {object} 待验证快照
44
+ */
45
+ create({ timeoutMs = 300000 } = {}) {
46
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_TIMEOUT) throw new Error('invalid timeoutMs')
47
+ prune()
48
+ if (requests.size >= 1024) throw new Error('too many verification requests')
49
+ const challenge = randomBytes(32).toString('hex')
50
+ const entry = { challenge, requesterNodeHash: nodeHash, expiresAt: now() + timeoutMs, status: 'pending' }
51
+ requests.set(challenge, entry)
52
+ return snapshot(entry)
53
+ },
54
+ /**
55
+ * 按挑战串读取快照;不存在返回 null。
56
+ * @param {string} challenge 挑战串
57
+ * @returns {object|null} 快照
58
+ */
59
+ get(challenge) {
60
+ prune()
61
+ const entry = requests.get(challenge)
62
+ return entry ? snapshot(entry) : null
63
+ },
64
+ /**
65
+ * 处理入站 verification_claim / verification_receipt 动作。
66
+ * @param {string} action 网络动作
67
+ * @param {object} payload 网络载荷
68
+ * @param {string} sender 已认证身份
69
+ */
70
+ async receive(action, payload, sender) {
71
+ if (!isHex64(sender) || !isHex64(payload?.challenge)) return
72
+ if (action === 'verification_claim') {
73
+ const entry = requests.get(payload.challenge)
74
+ if (!entry || now() >= entry.expiresAt || snapshot(entry).status === 'failed' || entry.expiresAt !== payload.expiresAt) return
75
+ if (entry.status === 'verified' && entry.nodeHash !== sender) return
76
+ entry.status = 'verified'
77
+ entry.nodeHash = sender
78
+ await send(sender, 'verification_receipt', { challenge: entry.challenge, expiresAt: entry.expiresAt })
79
+ }
80
+ if (action === 'verification_receipt') {
81
+ const proof = proofs.get(payload.challenge)
82
+ if (proof && proof.requesterNodeHash === sender && proof.expiresAt === payload.expiresAt && now() < proof.expiresAt)
83
+ proof.resolve({ status: 'verified', nodeHash })
84
+ }
85
+ },
86
+ /**
87
+ * 向请求方发起验证并等待回执(去重、并发上限、超时收敛为 failed)。
88
+ * @param {NetworkChallenge} request 挑战请求
89
+ * @returns {Promise<object>} 验证结果
90
+ */
91
+ async prove({ requesterNodeHash, challenge, expiresAt }) {
92
+ if (!isHex64(requesterNodeHash) || !isHex64(challenge) || !Number.isSafeInteger(expiresAt) || expiresAt <= now() || expiresAt > now() + MAX_TIMEOUT)
93
+ return { status: 'failed', reason: 'invalid challenge' }
94
+ if (proofs.has(challenge)) {
95
+ const existing = proofs.get(challenge)
96
+ if (existing.requesterNodeHash !== requesterNodeHash || existing.expiresAt !== expiresAt) return { status: 'failed', reason: 'challenge mismatch' }
97
+ return existing.promise
98
+ }
99
+ if (proofs.size >= 64) return { status: 'failed', reason: 'busy' }
100
+ let resolve
101
+ const promise = new Promise(done => { resolve = done })
102
+ proofs.set(challenge, { requesterNodeHash, expiresAt, resolve, promise })
103
+ const timer = setTimeout(() => resolve({ status: 'failed', reason: 'timeout' }), Math.min(expiresAt - now(), 15000))
104
+ try {
105
+ void send(requesterNodeHash, 'verification_claim', { challenge, expiresAt }).then(sent => {
106
+ if (!sent) resolve({ status: 'failed', reason: 'unreachable' })
107
+ }, () => resolve({ status: 'failed', reason: 'unreachable' }))
108
+ return await promise
109
+ }
110
+ finally { clearTimeout(timer); proofs.delete(challenge) }
111
+ },
112
+ }
113
+ }
114
+
115
+ let service
116
+ /**
117
+ * 把验证动作挂到正在运行的节点的 node scope 上。
118
+ * @returns {() => void} 卸载 dispose
119
+ */
120
+ export function attachNetworkVerification() {
121
+ return attachNodeScopeFeature('verification', wire => {
122
+ const current = getNetworkVerificationService()
123
+ const disposers = ['verification_claim', 'verification_receipt'].map(action => wire.on(action, (payload, sender) => {
124
+ void current.receive(action, payload, sender).catch(() => {})
125
+ }))
126
+ return () => { for (const dispose of disposers) dispose(); service = null }
127
+ })
128
+ }
129
+
130
+ /**
131
+ * 取当前节点的验证服务(懒创建单例)。
132
+ * @returns {ReturnType<typeof createNetworkVerificationService>} 验证服务
133
+ */
134
+ export function getNetworkVerificationService() {
135
+ if (!service) service = createNetworkVerificationService({ nodeHash: getNodeHash(), send: sendVerificationEnvelope })
136
+ return service
137
+ }
138
+
139
+ /**
140
+ * 经已认证链路投递验证消息;请求方就是本节点时直接走本地入口。
141
+ * @param {string} peer 目标身份
142
+ * @param {string} action 网络动作
143
+ * @param {object} payload 网络载荷
144
+ * @returns {Promise<boolean>} 是否已投递
145
+ */
146
+ async function sendVerificationEnvelope(peer, action, payload) {
147
+ if (peer === getNodeHash()) {
148
+ await getNetworkVerificationService().receive(action, payload, peer)
149
+ return true
150
+ }
151
+ return await sendToNodeLink(peer, { scope: 'node', action, payload })
152
+ }
153
+
154
+ /**
155
+ * 单次自证:证明本节点可达请求方并收到其回执。
156
+ * @param {NetworkChallenge} request 挑战请求
157
+ * @returns {Promise<object>} 验证结果
158
+ */
159
+ export async function proveNetworkVerification(request) {
160
+ const dispose = attachNetworkVerification()
161
+ try { return await getNetworkVerificationService().prove(request) }
162
+ finally { dispose() }
163
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve02081504/fount-p2p",
3
- "version": "0.0.46",
3
+ "version": "0.0.49",
4
4
  "description": "fount federation P2P layer — link, trust graph, mailbox, DAG, EVFS.",
5
5
  "keywords": [
6
6
  "network",
@@ -25,7 +25,9 @@
25
25
  "test:fount": "deno test -A --config deno.json test/fount/",
26
26
  "test:live": "node --test --test-concurrency=1 --test-force-exit test/live/*.test.mjs",
27
27
  "test:sim": "node --test --test-concurrency=1 --test-force-exit sim/test/*.test.mjs",
28
- "test:frontend": "node --test --test-concurrency=1 --test-force-exit test/frontend/*.test.mjs"
28
+ "test:frontend": "node --test --test-concurrency=1 --test-force-exit test/frontend/*.test.mjs",
29
+ "test:checks": "node --test --test-concurrency=1 --test-force-exit test/pure/text_lf.test.mjs test/pure/jsdoc_no_english.test.mjs test/pure/agents_md_english.test.mjs",
30
+ "check:text_lf:fix": "node scripts/checks/text_lf.mjs --fix .."
29
31
  },
30
32
  "bin": {
31
33
  "fount-p2p": "infra/cli.mjs"
package/sim/AGENTS.md CHANGED
@@ -15,7 +15,7 @@ In-process co-evolution of **tunables** (`*.tunables.json`) against an **attack
15
15
  - **`PARAM_SPACE` ↔ defaults:** every `PARAM_SPACE` key must exist in `loadDefaultTunables()`; clear both sides when deleting a key.
16
16
  - **`socialRepHideThreshold`:** hide when `score < threshold`. Default `0` (suppress negatives only). Raise the threshold to raise `falsePositiveRate` — never use a negative threshold for "stricter".
17
17
  - **Heuristic proxy** (not the real path): `model.mjs`, `discovery.mjs`, `transport.mjs`, `integrity.mjs` — analytical "params → defense" only.
18
- - **Mesh-first:** explore slots and cold-start join are mandatory; fanout/trust stay separate from link presence. Policy: [docs/mesh.md](../docs/mesh.md). Wiring: [cold_start.md](cold_start.md).
18
+ - **Mesh-first:** explore slots and cold-start join are mandatory; fanout/trust stay separate from link presence. Policy: [docs/mesh.md](../docs/mesh.md). Wiring: [docs/cold_start.md](docs/cold_start.md).
19
19
 
20
20
  ## Anti-drift
21
21
 
@@ -1,6 +1,6 @@
1
1
  # Sim cold-start (K = 0)
2
2
 
3
- Mesh policy: [docs/mesh.md](../docs/mesh.md). Day-to-day sim rules: [AGENTS.md](AGENTS.md).
3
+ Mesh policy: [mesh.md](../../docs/mesh.md). Day-to-day sim rules: [AGENTS.md](../AGENTS.md).
4
4
 
5
5
  When the observer has no acquaintances (`trustedPeers=[]`), the sim must still join via discovery explore slots — not idle.
6
6
 
@@ -25,6 +25,24 @@ import { createRuntimeBootstrap } from './runtime_bootstrap.mjs'
25
25
  const DIAL_COOLDOWN_BASE_MS = ms('30s')
26
26
  /** dial 冷却上限。 */
27
27
  const DIAL_COOLDOWN_MAX_MS = ms('10m')
28
+ /**
29
+ * 单次确保直连的整体上限:覆盖全部 provider 尝试(含 offer/answer 握手)后必须结算。
30
+ * 必须大于 nostr 入队 publish 等待上限,让「relay 连不上」走 provider 自身的失败路径,
31
+ * 而不是在这里被掐断(否则拿不到 provider 级诊断)。见 fount-p2p#37。
32
+ */
33
+ export const LINK_DIAL_DEADLINE_MS = ms('90s')
34
+
35
+ /** 测试可缩短的单次直连上限。 */
36
+ let dialDeadlineMs = LINK_DIAL_DEADLINE_MS
37
+
38
+ /**
39
+ * 覆盖单次直连上限(测试用;传 null 恢复默认)。
40
+ * @param {number | null} value 毫秒上限;null 恢复默认
41
+ * @returns {void}
42
+ */
43
+ export function setLinkDialDeadlineMsForTests(value) {
44
+ dialDeadlineMs = value == null ? LINK_DIAL_DEADLINE_MS : value
45
+ }
28
46
 
29
47
  /**
30
48
  * 解析或从节点种子推导本地身份。
@@ -263,8 +281,58 @@ export function createLinkRegistry(options = {}) {
263
281
  return links.get(remoteNodeHash) ?? null
264
282
  }
265
283
 
284
+ /**
285
+ * 记一次 dial 失败并进入指数退避。
286
+ * @param {string} normalized 远端节点 64 hex
287
+ * @returns {number} 本次冷却毫秒
288
+ */
289
+ function noteDialFailure(normalized) {
290
+ const failures = (dialCooldown.get(normalized)?.failures || 0) + 1
291
+ const delay = Math.min(DIAL_COOLDOWN_MAX_MS, DIAL_COOLDOWN_BASE_MS * (2 ** Math.min(failures - 1, 5)))
292
+ dialCooldown.touch(normalized, { until: Date.now() + delay, failures })
293
+ return delay
294
+ }
295
+
296
+ /**
297
+ * 为一个已登记的直连尝试套上整体上限:超时即返回 null(调用方不再等),
298
+ * 后台尝试照旧收敛,成功仍会注册链路。
299
+ *
300
+ * 超时后立刻从 inflights 摘除,后续 dial 走新尝试而不是复用这个卡住的 promise。
301
+ * @param {string} normalized 远端节点 64 hex
302
+ * @param {Promise<object | null>} task 进行中的 dial
303
+ * @returns {Promise<object | null>} dial 结果;超时 null
304
+ */
305
+ async function awaitDialAttempt(normalized, task) {
306
+ if (dialDeadlineMs <= 0) return await task
307
+ /** @type {boolean} */
308
+ let expired = false
309
+ /** @type {ReturnType<typeof setTimeout> | null} */
310
+ let timer = null
311
+ const timeout = new Promise(resolve => {
312
+ timer = setTimeout(() => {
313
+ expired = true
314
+ resolve(null)
315
+ }, dialDeadlineMs)
316
+ timer.unref?.()
317
+ })
318
+ const link = await Promise.race([task, timeout])
319
+ if (timer) clearTimeout(timer)
320
+ if (!expired) return link
321
+ // 超时:调用方不再等,但后台尝试仍会自行收敛(成功照样注册链路,失败自己记冷却)。
322
+ if (inflights.get(normalized) === task) inflights.delete(normalized)
323
+ const cooldownMs = noteDialFailure(normalized)
324
+ nodeDebug('p2p:dial deadline', {
325
+ peer: shortHash(normalized),
326
+ deadlineMs: dialDeadlineMs,
327
+ cooldownMs,
328
+ })
329
+ return null
330
+ }
331
+
266
332
  /**
267
333
  * 按 level 降序尝试各 LinkProvider,不可用/不可达/失败则回落。
334
+ * 整体有 LINK_DIAL_DEADLINE_MS 上限:卡住的 provider 既不能让调用方永久等待,
335
+ * 也不能把坏 promise 留在 inflights 里毒化后续 dial。
268
336
  * @param {string} remoteNodeHash 远端节点 64 hex
269
337
  * @returns {Promise<object | null>} 链路实例;失败时 null
270
338
  */
@@ -274,7 +342,7 @@ export function createLinkRegistry(options = {}) {
274
342
  const normalized = remoteNodeHash
275
343
  if (!normalized || normalized === localIdentity.nodeHash) return null
276
344
  if (links.has(normalized)) return links.get(normalized)
277
- if (inflights.has(normalized)) return await inflights.get(normalized)
345
+ if (inflights.has(normalized)) return await awaitDialAttempt(normalized, inflights.get(normalized))
278
346
  const cool = dialCooldown.get(normalized)
279
347
  const now = Date.now()
280
348
  if (cool) {
@@ -335,14 +403,12 @@ export function createLinkRegistry(options = {}) {
335
403
  })
336
404
  }
337
405
 
338
- const failures = (cool?.failures || 0) + 1
339
- const delay = Math.min(DIAL_COOLDOWN_MAX_MS, DIAL_COOLDOWN_BASE_MS * (2 ** Math.min(failures - 1, 5)))
340
- dialCooldown.touch(normalized, { until: Date.now() + delay, failures })
341
- nodeDebug('p2p:dial exhausted', { peer: shortHash(normalized), cooldownMs: delay, failures })
406
+ const cooldownMs = noteDialFailure(normalized)
407
+ nodeDebug('p2p:dial exhausted', { peer: shortHash(normalized), cooldownMs })
342
408
  return null
343
409
  })().finally(() => inflights.delete(normalized))
344
410
  inflights.set(normalized, task)
345
- return await task
411
+ return await awaitDialAttempt(normalized, task)
346
412
  }
347
413
 
348
414
  meshKeepalive = createMeshKeepalive({
@@ -387,9 +453,12 @@ export function createLinkRegistry(options = {}) {
387
453
 
388
454
  /**
389
455
  * 启动 discovery/link runtime 并开启 mesh keepalive。
456
+ * 顺带重启 peer health 订阅:`shutdown()` 会停掉它,重启运行时后必须重新挂上,
457
+ * 否则 getPeerHealth / listPeerHealth 会永远为空(见 fount-p2p#38)。
390
458
  */
391
459
  const ensureRuntimeWithMesh = async () => {
392
460
  await bootstrap.ensureRuntime()
461
+ peerHealth.start()
393
462
  meshKeepalive?.start()
394
463
  }
395
464
 
@@ -19,6 +19,7 @@ import { emitSafe } from '../utils/emit_safe.mjs'
19
19
  * getPeerHealth: (nodeHash: string) => PeerHealthEntry | null
20
20
  * listPeerHealth: () => PeerHealthEntry[]
21
21
  * onPeerHealth: (listener: (nodeHash: string, entry: PeerHealthEntry) => void) => () => void
22
+ * start: () => void
22
23
  * stop: () => void
23
24
  * }} peer health 聚合器
24
25
  */
@@ -29,6 +30,10 @@ export function createPeerHealthTracker(registry) {
29
30
  const cleanups = new Map()
30
31
  /** @type {Set<(nodeHash: string, entry: PeerHealthEntry) => void>} */
31
32
  const listeners = new Set()
33
+ /** @type {(() => void) | null} registry link up 退订 */
34
+ let stopUp = null
35
+ /** @type {(() => void) | null} registry link down 退订 */
36
+ let stopDown = null
32
37
 
33
38
  /**
34
39
  * 合并补丁并通知订阅方。
@@ -54,37 +59,48 @@ export function createPeerHealthTracker(registry) {
54
59
  emitSafe(listeners, nodeHash, entry)
55
60
  }
56
61
 
57
- const stopUp = registry.onLinkUp?.((nodeHash, link) => {
58
- if (!nodeHash) return
59
- const stopRtt = link?.onRtt?.(() => {
60
- const stats = link.stats?.() ?? {}
62
+ /**
63
+ * 订阅 registry 的链路事件(幂等)。
64
+ * `stop()` 之后可用它重新挂上监听:registry 的 link up/down 桶在 shutdown 时不会被清空,
65
+ * 所以重启运行时后健康记录能自然恢复。
66
+ * @returns {void}
67
+ */
68
+ function start() {
69
+ if (stopUp) return
70
+ stopUp = registry.onLinkUp?.((nodeHash, link) => {
71
+ if (!nodeHash) return
72
+ const stopRtt = link?.onRtt?.(() => {
73
+ const stats = link.stats?.() ?? {}
74
+ update(nodeHash, {
75
+ rttMs: stats.rttMs ?? null,
76
+ avgRttMs: stats.avgRttMs ?? null,
77
+ lastSeenAt: Date.now(),
78
+ })
79
+ }) ?? null
80
+ const stopLinkDown = link?.onDown?.(() => {
81
+ cleanups.get(nodeHash)?.()
82
+ cleanups.delete(nodeHash)
83
+ update(nodeHash, { connected: false, lastSeenAt: Date.now() })
84
+ }) ?? null
61
85
  update(nodeHash, {
62
- rttMs: stats.rttMs ?? null,
63
- avgRttMs: stats.avgRttMs ?? null,
86
+ connected: true,
87
+ source: link?.providerId ?? null,
64
88
  lastSeenAt: Date.now(),
65
89
  })
90
+ cleanups.set(nodeHash, () => {
91
+ stopRtt?.()
92
+ stopLinkDown?.()
93
+ })
66
94
  }) ?? null
67
- const stopDown = link?.onDown?.(() => {
95
+ stopDown = registry.onLinkDown?.(nodeHash => {
96
+ if (!nodeHash) return
68
97
  cleanups.get(nodeHash)?.()
69
98
  cleanups.delete(nodeHash)
70
99
  update(nodeHash, { connected: false, lastSeenAt: Date.now() })
71
100
  }) ?? null
72
- update(nodeHash, {
73
- connected: true,
74
- source: link?.providerId ?? null,
75
- lastSeenAt: Date.now(),
76
- })
77
- cleanups.set(nodeHash, () => {
78
- stopRtt?.()
79
- stopDown?.()
80
- })
81
- }) ?? null
82
- const stopDown = registry.onLinkDown?.(nodeHash => {
83
- if (!nodeHash) return
84
- cleanups.get(nodeHash)?.()
85
- cleanups.delete(nodeHash)
86
- update(nodeHash, { connected: false, lastSeenAt: Date.now() })
87
- }) ?? null
101
+ }
102
+
103
+ start()
88
104
 
89
105
  return {
90
106
  /**
@@ -108,17 +124,19 @@ export function createPeerHealthTracker(registry) {
108
124
  listeners.add(listener)
109
125
  return () => listeners.delete(listener)
110
126
  },
127
+ start,
111
128
  /**
112
- * 停止监听并清空聚合。
129
+ * 停止监听并清空聚合(listen 订阅保留,listeners 订阅者保留)。
113
130
  * @returns {void}
114
131
  */
115
132
  stop() {
116
133
  stopUp?.()
117
134
  stopDown?.()
135
+ stopUp = null
136
+ stopDown = null
118
137
  for (const cleanup of cleanups.values()) cleanup()
119
138
  cleanups.clear()
120
139
  entries.clear()
121
- listeners.clear()
122
140
  },
123
141
  }
124
142
  }
@@ -28,6 +28,15 @@ import { createWebRtcLinkProvider } from '../link/providers/webrtc.mjs'
28
28
  import { getSignalingRuntimeConfig, onNodeChange } from '../node/instance.mjs'
29
29
  import { isConnectivityDebug, nodeDebug, shortHash } from '../node/log.mjs'
30
30
 
31
+ /**
32
+ * @param {import('../link/providers/index.mjs').LinkProvider} provider 链路提供者
33
+ * @returns {boolean} 是否由本 registry 自己持有(lan_tcp / ble_gatt 走各自的快速监听路径)
34
+ */
35
+ function providerIsRegistryOwned(provider) {
36
+ const { id } = provider
37
+ return id.startsWith('lan_tcp') || id.startsWith('ble_gatt')
38
+ }
39
+
31
40
  /**
32
41
  * @param {import('../link/providers/index.mjs').LinkProvider} provider 链路提供者
33
42
  * @returns {boolean} 是否使用原生 probe 路径
@@ -36,6 +45,16 @@ export function providerHasNativeProbe(provider) {
36
45
  return provider.caps?.probe === 'native'
37
46
  }
38
47
 
48
+ /**
49
+ * @param {import('../link/providers/index.mjs').LinkProvider} provider 链路提供者
50
+ * @returns {boolean} 是否需要在 runtime 暖机时补挂监听
51
+ */
52
+ export function providerNeedsListening(provider) {
53
+ if (providerIsRegistryOwned(provider)) return false
54
+ if (providerHasNativeProbe(provider)) return false
55
+ return !!provider.ensureListening
56
+ }
57
+
39
58
  /**
40
59
  * @param {import('../link/providers/index.mjs').LinkProvider | null} ownedLanTcp 本 registry 持有的 lan_tcp
41
60
  * @returns {import('../link/providers/index.mjs').LinkProvider[]} 可快速启动监听的 provider 列表
@@ -45,10 +64,8 @@ export function collectFastListenProviders(ownedLanTcp) {
45
64
  const listenProviders = []
46
65
  if (ownedLanTcp) listenProviders.push(ownedLanTcp)
47
66
  for (const provider of listLinkProviders()) {
48
- const { id } = provider
49
- if (id.startsWith('lan_tcp') || id.startsWith('ble_gatt')) continue
50
- if (!provider.ensureListening) continue
51
- if (providerHasNativeProbe(provider)) continue
67
+ if (!providerNeedsListening(provider)) continue
68
+ // 快速路径只接同步 isAvailable:要等异步探测的 provider 交给 startEnabledProviderListening。
52
69
  if (provider.isAvailable)
53
70
  try {
54
71
  const available = provider.isAvailable()
@@ -202,6 +219,24 @@ export function createRuntimeBootstrap(deps) {
202
219
  return encryptAdvertForScope('network', localIdentity, body)
203
220
  }
204
221
 
222
+ /**
223
+ * 为所有「已注册、已启用、尚未监听」的 link provider 启动监听。
224
+ * `reconcileLinkProviders()` 重新注册的 provider 只有走到这里才会拿到 onInbound/localIdentity,
225
+ * 否则它会静默丢弃全部入站 link-open(见 fount-p2p#38)。
226
+ *
227
+ * 刻意不看 `isAvailable()`:relay 池为空/未探测时 nostr 的 `isAvailable()` 会是 false,而 relay 列表
228
+ * 是在发送时解析的;先挂上监听才能收到入站 link-open,暂时不可用不该让它变哑巴。
229
+ * @returns {Promise<void>}
230
+ */
231
+ async function startEnabledProviderListening() {
232
+ if (!autoRegisterLinkProviders) return
233
+ await Promise.all(listLinkProviders()
234
+ .filter(provider => isChannelEnabled(provider.id.split(':')[0]))
235
+ .filter(provider => providerNeedsListening(provider))
236
+ .filter(provider => !stopLinkListeners.has(provider.id))
237
+ .map(provider => startProviderListening(provider)))
238
+ }
239
+
205
240
  /**
206
241
  * @param {number} gen 启动世代
207
242
  * @returns {Promise<void>}
@@ -212,6 +247,8 @@ export function createRuntimeBootstrap(deps) {
212
247
  signalListenReady = (async () => {
213
248
  await lanListenReady.catch(() => { })
214
249
  if (generation !== gen || !isLive()) return
250
+ await startEnabledProviderListening().catch(() => { })
251
+ if (generation !== gen || !isLive()) return
215
252
  if (!listDiscoveryProviders().length) return
216
253
  stopSignalListener = await listenNodeSignals(localIdentity.nodeHash, bytes => {
217
254
  void handleIncomingSignal(bytes).catch(() => { })
@@ -324,11 +361,9 @@ export function createRuntimeBootstrap(deps) {
324
361
  reconcileLinkProviders()
325
362
  reconcileDiscoveryProviders()
326
363
  if (generation !== gen || !isLive()) return
327
- if (ownedLanTcp && !stopLinkListeners.has(ownedLanTcp.id))
328
- await startProviderListening(ownedLanTcp)
329
- if (ownedBleGatt && !stopLinkListeners.has(ownedBleGatt.id)
330
- && await Promise.resolve(ownedBleGatt.isAvailable()))
331
- await startProviderListening(ownedBleGatt)
364
+ // 覆盖本轮新注册/重新注册的 provider(例如关掉再打开 nostr):只补 ownedLanTcp/BLE
365
+ // 会让新 provider 永远拿不到 onInbound(见 fount-p2p#38)。
366
+ await startEnabledProviderListening()
332
367
  signalListenReady = (async () => {
333
368
  if (generation !== gen || !isLive()) return
334
369
  if (!listDiscoveryProviders().length) return
package/pages/index.html DELETED
@@ -1,36 +0,0 @@
1
- <!doctype html>
2
- <html lang="zh-CN">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>fount 人口统计</title>
7
- <style>
8
- :root { color-scheme: light dark; }
9
- body { font-family: system-ui, sans-serif; max-width: 720px; margin: 3rem auto; padding: 0 1rem; line-height: 1.6; }
10
- h1 { font-size: 1.4rem; }
11
- .card { border: 1px solid #8884; border-radius: 8px; padding: 1rem 1.25rem; margin: 1rem 0; }
12
- .metric { font-size: 2rem; font-weight: 700; }
13
- label { display: flex; align-items: center; gap: .5rem; margin: 1rem 0; }
14
- #status { opacity: .7; font-size: .9rem; }
15
- code { background: #8882; padding: .1em .4em; border-radius: 4px; }
16
- </style>
17
- </head>
18
- <body>
19
- <h1>fount 节点人口统计</h1>
20
- <p>基于 Nostr census 事件(kind 30789,<code>t=fount</code> / <code>x=census</code>)的采样估计。监听逻辑由包内 <code>census_monitor</code> 封装:自动监听默认/传入 relay 并经 NIP-66 发现更多,取人口最大的 relay 作为显示源。在线节点数 = <code>Σ(1/p)</code>(Horvitz–Thompson 估计)。</p>
21
- <label>
22
- <input type="checkbox" id="toggle" checked>
23
- 人口统计(census)
24
- </label>
25
- <div class="card">
26
- <div>在线节点数估计</div>
27
- <div class="metric" id="estimate">0</div>
28
- <div id="status">初始化…</div>
29
- </div>
30
- <div class="card">
31
- <div>采样事件数 <span id="sample-size">0</span> · 窗口内事件 <span id="window-events">0</span></div>
32
- </div>
33
- <footer id="footer" style="opacity:.6;font-size:.85rem">通过 URL 参数 ?relay=wss://... 指定中继;缺省监听默认 relay。</footer>
34
- <script type="module" src="./index.mjs"></script>
35
- </body>
36
- </html>
package/pages/index.mjs DELETED
@@ -1,66 +0,0 @@
1
- /**
2
- * fount 人口统计前端(薄壳,逻辑由包内 `census_monitor` 封装)。
3
- * - `createPopulationMonitor` 自动监听全部默认(或传入)relay、经 NIP-66 发现更多,
4
- * 并取人口估计最大的 relay 作为显示源,把 `{ estimate, sampleSize, eventsInWindow, relayUrl, relays }`
5
- * 快照连同 relay 地址传给 onUpdate。
6
- * - 页面只负责:导入监控器、传显示函数与可选 relay 地址。
7
- */
8
- import { createPopulationMonitor } from 'https://esm.sh/@steve02081504/fount-p2p/discovery/nostr/census_monitor.mjs'
9
-
10
- const statusEl = document.querySelector('#status')
11
- const toggleEl = document.querySelector('#toggle')
12
-
13
- const params = new URLSearchParams(location.search)
14
- const relayUrl = params.get('relay')
15
-
16
- /** @type {{ stop: () => void } | null} */
17
- let monitor = null
18
-
19
- /**
20
- * @param {{ estimate: number, sampleSize: number, eventsInWindow: number }} snapshot 快照
21
- * @returns {void}
22
- */
23
- function render(snapshot) {
24
- document.querySelector('#estimate').textContent = snapshot.estimate.toLocaleString('zh-CN', { maximumFractionDigits: 1 })
25
- document.querySelector('#sample-size').textContent = String(snapshot.sampleSize)
26
- document.querySelector('#window-events').textContent = String(snapshot.eventsInWindow)
27
- }
28
-
29
- /** @returns {void} 启动 live 监控(默认/传入 relay 全交给库) */
30
- function startLive() {
31
- if (monitor) return
32
- try {
33
- monitor = createPopulationMonitor({
34
- relays: relayUrl ? [relayUrl] : undefined,
35
- /**
36
- * @param {{ estimate: number, sampleSize: number, eventsInWindow: number, relayUrl: string, relays: number }} snapshot 人口快照
37
- * @returns {void}
38
- */
39
- onUpdate: snapshot => {
40
- render(snapshot)
41
- statusEl.textContent = `监听 ${snapshot.relayUrl}(census,共 ${snapshot.relays} 个 relay)`
42
- },
43
- })
44
- }
45
- catch (error) {
46
- statusEl.textContent = `中继配置无效:${String(error?.message || error)}`
47
- }
48
- }
49
-
50
- /** @returns {void} 停止监控 */
51
- function stopLive() {
52
- monitor?.stop()
53
- monitor = null
54
- }
55
-
56
- toggleEl.addEventListener('change', () => {
57
- if (!toggleEl.checked) {
58
- stopLive()
59
- render({ estimate: 0, sampleSize: 0, eventsInWindow: 0 })
60
- statusEl.textContent = '已关闭'
61
- return
62
- }
63
- startLive()
64
- })
65
-
66
- if (toggleEl.checked) startLive()