@steve02081504/fount-p2p 0.0.36 → 0.0.37

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/pages/app.mjs ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * fount 人口统计前端(独立实现,不依赖包内代码)。
3
+ * - 浏览器原生 WebSocket 订阅 Nostr 中继 kind=30789 / #t=fount / #x=census。
4
+ * - 本地用 @noble 验签(懒加载,仅 live 模式需要)。
5
+ * - 在线节点数估计 = Σ(1/p)(HT)。
6
+ * - `?demo=1`(或无 relay 参数)进入演示模式:喂入 20 条 p=0.1 的样本 → 估计 200。
7
+ */
8
+ const CENSUS_KIND = 30789
9
+ const CENSUS_TTL_MS = 10 * 60_000
10
+ const CENSUS_SUB_ID = 'census'
11
+ const CENSUS_MIN_P = 0.001
12
+ /** relay 帧 content 最大长度(超出直接丢弃,避免先 Base64 解码再被拒)。 */
13
+ const MAX_FRAME_BYTES = 16 * 1024
14
+ /** 并发验签任务上限;达上限时多余帧直接丢弃。 */
15
+ const MAX_INFLIGHT_VERIFICATIONS = 8
16
+
17
+ const statusEl = document.querySelector('#status')
18
+ const toggleEl = document.querySelector('#toggle')
19
+
20
+ const params = new URLSearchParams(location.search)
21
+ const relayUrl = params.get('relay')
22
+ const demoMode = params.get('demo') === '1' || !relayUrl
23
+
24
+ /** @type {Map<string, { p: number, at: number }>} */
25
+ const events = new Map()
26
+ let webSocket = null
27
+ let enabled = true
28
+ let inflightVerifications = 0
29
+
30
+ /**
31
+ * HT 估计;遍历时逐出过期事件,避免窗口 Map 持续增长。
32
+ * @returns {{ estimate: number, sampleSize: number }} 估计与采样数
33
+ */
34
+ function estimatePopulation() {
35
+ let total = 0
36
+ let sampleSize = 0
37
+ const now = Date.now()
38
+ for (const [hash, { p, at }] of events) {
39
+ if (!Number.isFinite(at) || now - at > CENSUS_TTL_MS) { events.delete(hash); continue }
40
+ total += 1 / p
41
+ sampleSize++
42
+ }
43
+ return { estimate: total, sampleSize }
44
+ }
45
+
46
+ let noblePromise = null
47
+ /** @returns {Promise<[object, object]>} noble 模块 */
48
+ function loadNoble() {
49
+ return noblePromise ||= Promise.all([
50
+ import('https://esm.sh/@noble/curves@1/ed25519.js'),
51
+ import('https://esm.sh/@noble/hashes@1/sha2.js'),
52
+ ])
53
+ }
54
+
55
+ /**
56
+ * 校验 census 包:nodeHash=sha256(nodePubKey) 且 Ed25519 签名匹配 `fount-census\0ts\0nodeHash\0p`。
57
+ * @param {object} packet 原始包
58
+ * @returns {Promise<{ nodeHash: string, p: number, ts: number } | null>} 验签结果
59
+ */
60
+ async function verifyCensusPacket(packet) {
61
+ const nodeHash = /^[\da-f]{64}$/u.test(String(packet?.nodeHash ?? '')) ? packet.nodeHash : null
62
+ const nodePubKey = /^[\da-f]{64}$/u.test(String(packet?.nodePubKey ?? '')) ? packet.nodePubKey : null
63
+ const sig = /^[\da-f]{128}$/u.test(String(packet?.sig ?? '')) ? packet.sig : null
64
+ const ts = Number(packet?.ts)
65
+ const p = Number(packet?.p)
66
+ if (!nodeHash || !nodePubKey || !sig || !Number.isFinite(ts)) return null
67
+ if (Math.abs(Date.now() - ts) > CENSUS_TTL_MS) return null
68
+ if (!Number.isFinite(p) || p < CENSUS_MIN_P || p > 1) return null
69
+ const [{ ed25519 }, { sha256 }] = await loadNoble()
70
+ const pubBytes = new Uint8Array(nodePubKey.match(/.{2}/gu).map(hex => parseInt(hex, 16)))
71
+ if ([...sha256(pubBytes)].map(byte => byte.toString(16).padStart(2, '0')).join('') !== nodeHash) return null
72
+ const ok = ed25519.verify(
73
+ new Uint8Array(sig.match(/.{2}/gu).map(hex => parseInt(hex, 16))),
74
+ new TextEncoder().encode(`fount-census\0${ts}\0${nodeHash}\0${p}`),
75
+ pubBytes,
76
+ )
77
+ return ok ? { nodeHash, p, ts } : null
78
+ }
79
+
80
+ /** @param {{ nodeHash: string, p: number, ts: number }} verified 已验签 census 包 @returns {void} */
81
+ function ingest(verified) {
82
+ const existing = events.get(verified.nodeHash)
83
+ if (existing && verified.ts < existing.at) return
84
+ events.set(verified.nodeHash, { p: verified.p, at: verified.ts })
85
+ render()
86
+ }
87
+
88
+ /** @returns {void} 渲染人口估计 */
89
+ function render() {
90
+ const { estimate, sampleSize } = estimatePopulation()
91
+ document.querySelector('#estimate').textContent = estimate.toLocaleString('zh-CN', { maximumFractionDigits: 1 })
92
+ document.querySelector('#sample-size').textContent = String(sampleSize)
93
+ document.querySelector('#window-events').textContent = String(events.size)
94
+ }
95
+
96
+ /** @returns {void} 注入演示数据 */
97
+ function seedDemo() {
98
+ events.clear()
99
+ for (let index = 0; index < 20; index++)
100
+ events.set((index + 1).toString(16).padStart(2, '0').repeat(32), { p: 0.1, at: Date.now() })
101
+ statusEl.textContent = '演示模式:20 条 p=0.1 样本(HT 估计 = 200)'
102
+ document.querySelector('#footer').textContent = '演示模式样本为虚构数据;添加 ?relay=wss://... 进入 live 模式。'
103
+ }
104
+
105
+ /** @returns {void} 连接中继并订阅 */
106
+ function connectRelay() {
107
+ if (webSocket) {
108
+ try { webSocket.close() } catch { /* ignore */ }
109
+ webSocket = null
110
+ }
111
+ if (!relayUrl || !enabled) return
112
+ statusEl.textContent = `连接 ${relayUrl} …`
113
+ const connection = new WebSocket(relayUrl)
114
+ webSocket = connection
115
+ /** WebSocket 已连接后发送 REQ 订阅 census 事件。 */
116
+ connection.onopen = () => {
117
+ if (connection !== webSocket || !enabled) return
118
+ connection.send(JSON.stringify(['REQ', CENSUS_SUB_ID, { kinds: [CENSUS_KIND], '#t': ['fount'], '#x': ['census'] }]))
119
+ statusEl.textContent = `监听 ${relayUrl}(kind ${CENSUS_KIND})`
120
+ }
121
+ /**
122
+ * @param {MessageEvent} rawMessage 中继消息事件
123
+ * @returns {void}
124
+ */
125
+ connection.onmessage = rawMessage => {
126
+ if (connection !== webSocket || !enabled) return
127
+ if (inflightVerifications >= MAX_INFLIGHT_VERIFICATIONS) return
128
+ if (typeof rawMessage.data !== 'string' || rawMessage.data.length > MAX_FRAME_BYTES) return
129
+ let parsed
130
+ try { parsed = JSON.parse(String(rawMessage.data)) } catch { return }
131
+ if (parsed?.[0] !== 'EVENT' || parsed[1] !== CENSUS_SUB_ID) return
132
+ if (parsed[2]?.kind !== CENSUS_KIND) return
133
+ const content = parsed[2]?.content
134
+ if (typeof content !== 'string' || content.length > MAX_FRAME_BYTES) return
135
+ inflightVerifications++
136
+ void (async () => {
137
+ try {
138
+ const verified = await verifyCensusPacket(JSON.parse(atob(content)))
139
+ if (connection !== webSocket || !enabled) return
140
+ if (verified) ingest(verified)
141
+ }
142
+ catch { /* ignore malformed */ }
143
+ finally { inflightVerifications-- }
144
+ })()
145
+ }
146
+ /** WebSocket 关闭时更新状态为已断开。 */
147
+ connection.onclose = () => {
148
+ if (connection !== webSocket || !enabled) return
149
+ statusEl.textContent = '已断开'
150
+ }
151
+ /** WebSocket 出错时提示中继连接失败。 */
152
+ connection.onerror = () => {
153
+ if (connection !== webSocket || !enabled) return
154
+ statusEl.textContent = '中继连接失败'
155
+ }
156
+ }
157
+
158
+ toggleEl.addEventListener('change', () => {
159
+ enabled = toggleEl.checked
160
+ if (!enabled) {
161
+ events.clear()
162
+ if (webSocket) { try { webSocket.close() } catch { /* ignore */ } webSocket = null }
163
+ statusEl.textContent = '已关闭'
164
+ }
165
+ else if (demoMode) seedDemo()
166
+ else connectRelay()
167
+ render()
168
+ })
169
+
170
+ if (demoMode) seedDemo()
171
+ else connectRelay()
172
+ render()
@@ -0,0 +1,36 @@
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>Σ(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"></footer>
34
+ <script type="module" src="./app.mjs"></script>
35
+ </body>
36
+ </html>
@@ -301,8 +301,7 @@ export function applyDecayCollusionAfterSlashPure(data, targetPubKeyHash, invite
301
301
  for (let hop = 1; hop <= effectiveMaxHop(); hop++) {
302
302
  const upstream = new Set()
303
303
  for (const edge of inviteEdges) {
304
- const to = edge.to
305
- const from = edge.from
304
+ const { to, from } = edge
306
305
  if (frontier.has(to) && from) upstream.add(from)
307
306
  }
308
307
  if (!upstream.size) break
@@ -10,7 +10,12 @@ import {
10
10
  unregisterDiscoveryProvider,
11
11
  } from '../discovery/index.mjs'
12
12
  import { createLanDiscoveryProvider } from '../discovery/lan.mjs'
13
- import { createNostrDiscoveryProvider, resolveNostrRelayUrls } from '../discovery/nostr.mjs'
13
+ import { MAX_ADVERT_RELAY_POOL } from '../discovery/nostr/constants.mjs'
14
+ import {
15
+ createNostrDiscoveryProvider,
16
+ resolveNostrRelayUrls,
17
+ } from '../discovery/nostr/index.mjs'
18
+ import { getListenRelays, getWorkingRelays, loadRelayPool, startNostrRelayDiscovery } from '../discovery/nostr/relays.mjs'
14
19
  import { createBleGattLinkProvider } from '../link/providers/ble_gatt.mjs'
15
20
  import {
16
21
  listLinkProviders,
@@ -18,7 +23,7 @@ import {
18
23
  unregisterLinkProvider,
19
24
  } from '../link/providers/index.mjs'
20
25
  import { createLanTcpLinkProvider } from '../link/providers/lan_tcp.mjs'
21
- import { createNostrLinkProvider } from '../link/providers/nostr.mjs'
26
+ import { createNostrLinkProvider } from '../link/providers/nostr/index.mjs'
22
27
  import { createWebRtcLinkProvider } from '../link/providers/webrtc.mjs'
23
28
  import { getSignalingRuntimeConfig, onNodeChange } from '../node/instance.mjs'
24
29
  import { isConnectivityDebug, nodeDebug, shortHash } from '../node/log.mjs'
@@ -88,6 +93,8 @@ export function createRuntimeBootstrap(deps) {
88
93
  let stopPresence = null
89
94
  /** @type {(() => void) | null} */
90
95
  let stopSignalListener = null
96
+ /** @type {(() => void) | null} */
97
+ let stopRelayDiscovery = null
91
98
  /** @type {Map<string, () => void>} */
92
99
  const stopLinkListeners = new Map()
93
100
  /** @type {ReturnType<typeof createLanTcpLinkProvider> | null} */
@@ -153,7 +160,21 @@ export function createRuntimeBootstrap(deps) {
153
160
  async function buildLocalAdvert(scope = 'node') {
154
161
  await whenListening()
155
162
  const tcpPort = lanTcpPort()
156
- return await buildSignedAdvertForScope(scope, localIdentity, tcpPort ?? undefined)
163
+ // 仅 network 域注入 relay 字段;LAN 域传空对象(签名消息仍含空的 relays: 段)。
164
+ return buildSignedAdvertForScope(
165
+ scope,
166
+ localIdentity,
167
+ tcpPort ?? undefined,
168
+ scope === 'network'
169
+ ? {
170
+ pool: getWorkingRelays()
171
+ .filter(entry => entry.rttMs != null)
172
+ .slice(0, MAX_ADVERT_RELAY_POOL)
173
+ .map(entry => ({ url: entry.url, rttMs: entry.rttMs })),
174
+ listen: getListenRelays().map(entry => entry.url),
175
+ }
176
+ : { pool: [], listen: [] },
177
+ )
157
178
  }
158
179
 
159
180
  /**
@@ -296,6 +317,10 @@ export function createRuntimeBootstrap(deps) {
296
317
  stopSignalListener?.()
297
318
  stopPresence = null
298
319
  stopSignalListener = null
320
+ // 重启 NIP-66 发现(不重新 loadRelayPool:避免用磁盘旧数据覆盖内存中的 poolEntries/peerRoutes)。
321
+ stopRelayDiscovery?.()
322
+ stopRelayDiscovery = null
323
+ if (isChannelEnabled('nostr')) stopRelayDiscovery = startNostrRelayDiscovery()
299
324
  reconcileLinkProviders()
300
325
  reconcileDiscoveryProviders()
301
326
  if (generation !== gen || !isLive()) return
@@ -337,6 +362,15 @@ export function createRuntimeBootstrap(deps) {
337
362
  runtimeStart = (async () => {
338
363
  runtimeStarted = true
339
364
  const gen = generation
365
+ // 先加载/播种 relay 池,再启动 NIP-66 发现(首轮异步,不阻塞 startup)。
366
+ loadRelayPool()
367
+ if (isChannelEnabled('nostr')) {
368
+ if (!stopRelayDiscovery) stopRelayDiscovery = startNostrRelayDiscovery()
369
+ }
370
+ else {
371
+ stopRelayDiscovery?.()
372
+ stopRelayDiscovery = null
373
+ }
340
374
  reconcileLinkProviders()
341
375
  if (autoRegisterDiscoveryProviders)
342
376
  reconcileDiscoveryProviders()
@@ -407,6 +441,8 @@ export function createRuntimeBootstrap(deps) {
407
441
  stopSignalListener?.()
408
442
  stopPresence = null
409
443
  stopSignalListener = null
444
+ stopRelayDiscovery?.()
445
+ stopRelayDiscovery = null
410
446
  await Promise.race([
411
447
  runtimeWarm?.catch(() => { }) ?? Promise.resolve(),
412
448
  new Promise(resolve => setTimeout(resolve, 500)),