@steve02081504/fount-p2p 0.0.40 → 0.0.42

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/AGENTS.md CHANGED
@@ -24,6 +24,8 @@ Detail docs: [transports](docs/transports.md) · [mesh](docs/mesh.md) · [signal
24
24
  | `crypto/crypto.mjs` | Node + browser | `@noble/hashes` + `@noble/curves` only — never `node:crypto` |
25
25
  | `crypto/key.mjs` / `crypto/channel.mjs`, disk I/O, LAN/BT, `ws`, CLI / `startNode` | Node (+ Deno bridge) | Do not load the whole package via esm.sh in the browser |
26
26
 
27
+ **Browser pages reusing package code:** pages import browser-safe package modules (`core/*`, `crypto/crypto.mjs`, `discovery/nostr/constants|_math|_verify|_monitor`) from esm.sh; their bare `@noble/*` deps resolve via an injected importmap. The frontend static test server (`test/frontend/census_page.test.mjs`) serves the whole package root (offline), injects an importmap into served HTML (`https://esm.sh/@steve02081504/fount-p2p/` → local package root, `@noble/` → `https://esm.sh/@noble/`), and runs the page live against a fake relay (`test/helpers/fake_relay.mjs` with `store`), injecting its `ws://` URL via `?relay=` — no demo mode. Page code stays a thin shell — pass display fn + optional relays; logic lives in the package (`discovery/nostr/census_monitor.mjs`).
28
+
27
29
  Deno / native / BT: [runtime.md](docs/runtime.md).
28
30
 
29
31
  ## Conventions
@@ -39,7 +41,7 @@ Deno / native / BT: [runtime.md](docs/runtime.md).
39
41
 
40
42
  ## Tests / tools
41
43
 
42
- - `npm test` — pure + integration (Node; `--test-force-exit`)
44
+ - `npm test` — pure + integration + frontend (Node; `--test-force-exit`)
43
45
  - `npm run test:live` — live link / LAN smoke
44
46
  - `npm run test:fount` — cross-repo Deno bridge (`test/fount/`); see [runtime.md](docs/runtime.md)
45
47
  - `npm run test:sim` — tunables co-evolution (dev-only; [sim/AGENTS.md](sim/AGENTS.md))
@@ -73,7 +75,7 @@ Deno / native / BT: [runtime.md](docs/runtime.md).
73
75
 
74
76
  | File | Directory |
75
77
  |---|---|
76
- | `tunables.json` | `reputation/`, `trust_graph/`, `mailbox/`, `governance/`, `dag/` |
78
+ | `tunables.json` | `transport/`, `infra/`, `reputation/`, `trust_graph/`, `mailbox/`, `governance/`, `dag/` |
77
79
  | `part_query.tunables.json` | `schemas/` |
78
80
 
79
81
  Sim harness: `sim/tunables_bundle.mjs` (dev-only). See [sim/AGENTS.md](sim/AGENTS.md).
@@ -15,45 +15,33 @@
15
15
  */
16
16
  import { Buffer } from 'node:buffer'
17
17
 
18
- import { isHex64, isSignatureHex128 } from '../../core/hexIds.mjs'
19
- import { keyPairFromSeed, pubKeyHash, sign, verify } from '../../crypto/crypto.mjs'
18
+ import { isHex64 } from '../../core/hexIds.mjs'
19
+ import { keyPairFromSeed, pubKeyHash, sign } from '../../crypto/crypto.mjs'
20
20
  import { ensureNodeSeed, getNodeHash } from '../../node/identity.mjs'
21
21
  import { getP2PFeatures, isNodeInitialized } from '../../node/instance.mjs'
22
22
  import { nodeDebug } from '../../node/log.mjs'
23
23
 
24
24
  import {
25
- CENSUS_MIN_P,
25
+ CENSUS_TAG_FOUNT,
26
+ CENSUS_TAG_X,
27
+ NOSTR_CENSUS_KIND,
28
+ } from './constants.mjs'
29
+ import {
26
30
  CENSUS_TARGET_EVENTS,
27
31
  estimatePopulation,
28
32
  nextInclusionProbability,
29
33
  } from './census_math.mjs'
34
+ import { CENSUS_TTL_MS, buildCensusMessage, verifyCensusBytes } from './census_verify.mjs'
30
35
  import { resolveRelayConnectTarget } from './relays.mjs'
31
36
 
32
- /** Nostr census 事件 kind(每节点每窗口至多一条,按 nodeHash 去重)。 */
33
- export const NOSTR_CENSUS_KIND = 30789
34
-
35
- /** census 订阅/发布标签:`t=fount` + `x=census`(subscribeNostrKind 以 rendezvousKey/tagX 匹配)。 */
36
- const CENSUS_TAG_FOUNT = 'fount'
37
- const CENSUS_TAG_X = 'census'
37
+ /** census 订阅标签数组(发布用)。 */
38
38
  const CENSUS_TAGS = [['t', CENSUS_TAG_FOUNT], ['x', CENSUS_TAG_X]]
39
39
 
40
- /** 事件/窗口存活时间(与 advert TTL 一致)。 */
41
- const CENSUS_TTL_MS = 10 * 60_000
42
40
  /** 发布/统计周期。 */
43
41
  const CENSUS_INTERVAL_MS = 10 * 60_000
44
42
  /** 冷启动初始包含概率。 */
45
43
  const CENSUS_INITIAL_P = 0.5
46
44
 
47
- /**
48
- * @param {number} ts 时间戳(毫秒)
49
- * @param {string} nodeHash 64 hex 节点 hash
50
- * @param {number} p 包含概率
51
- * @returns {Buffer} 待签名消息
52
- */
53
- function buildCensusMessage(ts, nodeHash, p) {
54
- return Buffer.from(`fount-census\0${ts}\0${nodeHash}\0${p}`, 'utf8')
55
- }
56
-
57
45
  /**
58
46
  * 用指定 seed 身份构建签名 census 包(nodeHash 由 seed 派生)。
59
47
  * 供工具与测试构造任意身份的 peer 包。
@@ -75,47 +63,6 @@ export async function buildCensusPacketFromSeed(seedHex, { p, ts = Date.now() })
75
63
  }
76
64
  }
77
65
 
78
- /**
79
- * 校验 census 包(Untrusted ingress):canonicalize + 验签 + 时间窗 + p 范围。
80
- * @param {unknown} packet 原始 census 包
81
- * @param {number} [now=Date.now()] 当前时间(毫秒)
82
- * @param {number} [ttlMs=CENSUS_TTL_MS] 允许的时间窗
83
- * @returns {Promise<{ nodeHash: string, p: number, ts: number } | null>} 校验通过返回 nodeHash/p/ts,否则 null
84
- */
85
- export async function verifyCensusPacket(packet, now = Date.now(), ttlMs = CENSUS_TTL_MS) {
86
- const nodeHash = isHex64(packet?.nodeHash)
87
- const nodePubKey = isHex64(packet?.nodePubKey)
88
- const sig = isSignatureHex128(packet?.sig)
89
- const ts = Number(packet?.ts)
90
- const p = Number(packet?.p)
91
- if (!nodeHash || !nodePubKey || !sig || !Number.isFinite(ts)) return null
92
- if (Math.abs(now - ts) > ttlMs) return null
93
- if (!Number.isFinite(p) || p < CENSUS_MIN_P || p > 1) return null
94
- try {
95
- if (pubKeyHash(Buffer.from(nodePubKey, 'hex')) !== nodeHash) return null
96
- }
97
- catch {
98
- return null
99
- }
100
- return await verify(Buffer.from(sig, 'hex'), buildCensusMessage(ts, nodeHash, p), Buffer.from(nodePubKey, 'hex')) ? { nodeHash, p, ts } : null
101
- }
102
-
103
- /**
104
- * 解 base64 content 字节并校验 census 包。
105
- * @param {Uint8Array} bytes content 解码字节
106
- * @param {number} [now=Date.now()] 当前时间(毫秒)
107
- * @param {number} [ttlMs=CENSUS_TTL_MS] 允许的时间窗
108
- * @returns {Promise<{ nodeHash: string, p: number, ts: number } | null>} 校验通过结果或 null
109
- */
110
- export async function verifyCensusBytes(bytes, now = Date.now(), ttlMs = CENSUS_TTL_MS) {
111
- try {
112
- return await verifyCensusPacket(JSON.parse(Buffer.from(bytes).toString('utf8')), now, ttlMs)
113
- }
114
- catch {
115
- return null
116
- }
117
- }
118
-
119
66
  /** 窗口事件:nodeHash → { p, at }(模块级,与 visibleByHash 同模式)。 */
120
67
  const censusEvents = new Map()
121
68
 
@@ -0,0 +1,403 @@
1
+ /**
2
+ * 浏览器端人口统计监控器(无 Node 依赖;WebSocket + Ed25519 验签)。
3
+ *
4
+ * 供 pages 等纯前端消费:`createPopulationMonitor({ onUpdate, relays, signal })`。
5
+ * 开始监听即自动:
6
+ * 1. 连接全部默认(或传入)relay,订阅 census 事件(kind 30789,t=fount / x=census);
7
+ * 2. 经 NIP-66(kind 30166)发现更多 relay 并加入监听(断开自动指数退避重连);
8
+ * 3. 每轮刷新取人口估计最大的 relay 作为显示源,把
9
+ * `{ estimate, sampleSize, eventsInWindow, relayUrl, relays }` 快照传给 onUpdate。
10
+ */
11
+ import { base64ToBytes } from '../../core/bytes_codec.mjs'
12
+
13
+ import {
14
+ CENSUS_TAG_FOUNT,
15
+ CENSUS_TAG_X,
16
+ DEFAULT_RELAY_URLS,
17
+ NIP66_BOOTSTRAP_RELAYS,
18
+ NOSTR_CENSUS_KIND,
19
+ } from './constants.mjs'
20
+ import { estimatePopulation } from './census_math.mjs'
21
+ import { CENSUS_TTL_MS, verifyCensusBytes } from './census_verify.mjs'
22
+
23
+ /** NIP-66 relay 注册 kind。 */
24
+ const NIP66_KIND = 30166
25
+ /** NIP-66 发现周期。 */
26
+ const NIP66_REFRESH_MS = 30 * 60_000
27
+ /** 单次 NIP-66 REQ 上限。 */
28
+ const NIP66_REQ_LIMIT = 300
29
+ /** 监控 relay 数上限(防 socket 爆炸)。 */
30
+ const MAX_MONITOR_RELAYS = 32
31
+ /** relay 帧 content 最大长度(超出直接丢弃,避免先 Base64 解码再被拒)。 */
32
+ const MAX_FRAME_BYTES = 16 * 1024
33
+ /** 并发验签任务上限;达上限时多余帧直接丢弃。 */
34
+ const MAX_INFLIGHT_VERIFICATIONS = 8
35
+ /** 单次连接超时。 */
36
+ const CONNECT_TIMEOUT_MS = 10_000
37
+ /** 断线重连指数退避:初始延迟与上限。 */
38
+ const RECONNECT_BASE_MS = 1_000
39
+ const RECONNECT_MAX_MS = 30_000
40
+ /** 事件入库后刷新防抖。 */
41
+ const REFRESH_DEBOUNCE_MS = 300
42
+ /** 无事件时周期刷新默认间隔(也用于 TTL 逐出)。 */
43
+ const DEFAULT_REFRESH_MS = 5_000
44
+
45
+ /**
46
+ * 规范化 Nostr relay URL(浏览器端轻量版):仅 ws/wss,小写 host,去默认端口与尾部斜杠。
47
+ * @param {unknown} raw 原始 URL
48
+ * @returns {string | null} 规范化字符串,无效返回 null
49
+ */
50
+ function normalizeRelayUrl(raw) {
51
+ const value = String(raw || '').trim()
52
+ if (!value) return null
53
+ let url
54
+ try { url = new URL(value) } catch { return null }
55
+ if (url.protocol !== 'wss:' && url.protocol !== 'ws:') return null
56
+ if (!url.hostname) return null
57
+ url.hostname = url.hostname.toLowerCase()
58
+ if (url.port && ((url.protocol === 'wss:' && url.port === '443') || (url.protocol === 'ws:' && url.port === '80')))
59
+ url.port = ''
60
+ const path = url.pathname.replace(/\/+$/, '') || '/'
61
+ url.pathname = path
62
+ return url.toString().replace(/\/$/, '')
63
+ }
64
+
65
+ /**
66
+ * @typedef {{
67
+ * url: string,
68
+ * ws: WebSocket | null,
69
+ * subId: string,
70
+ * events: Map<string, { p: number, at: number }>,
71
+ * inflight: number,
72
+ * reconnectDelayMs: number,
73
+ * reconnectTimer: ReturnType<typeof setTimeout> | null,
74
+ * }} RelaySession
75
+ */
76
+
77
+ /**
78
+ * 创建人口统计监控器:立即连接并监听,返回 `{ stop }` 控制句柄。
79
+ * @param {{
80
+ * onUpdate: (snapshot: { estimate: number, sampleSize: number, eventsInWindow: number, relayUrl: string, relays: number }) => void,
81
+ * relays?: string[],
82
+ * signal?: AbortSignal,
83
+ * refreshMs?: number,
84
+ * discover?: boolean,
85
+ * nip66Bootstrap?: string[],
86
+ * }} options 选项
87
+ * @returns {{ stop: () => void }} 停止函数
88
+ */
89
+ export function createPopulationMonitor(options = {}) {
90
+ const {
91
+ onUpdate,
92
+ relays,
93
+ signal,
94
+ refreshMs = DEFAULT_REFRESH_MS,
95
+ discover = true,
96
+ nip66Bootstrap = NIP66_BOOTSTRAP_RELAYS,
97
+ } = options
98
+ if (typeof onUpdate !== 'function')
99
+ throw new Error('p2p: population monitor requires onUpdate callback')
100
+ /** @type {string[]} */
101
+ const relayUrls = []
102
+ for (const raw of relays && relays.length ? relays : DEFAULT_RELAY_URLS) {
103
+ const url = normalizeRelayUrl(raw)
104
+ if (url && !relayUrls.includes(url)) relayUrls.push(url)
105
+ }
106
+ if (!relayUrls.length)
107
+ throw new Error('p2p: population monitor requires at least one relay url')
108
+
109
+ let stopped = false
110
+ /** @type {Map<string, RelaySession>} */
111
+ const sessions = new Map()
112
+ /** @type {ReturnType<typeof setInterval> | null} */
113
+ let refreshTimer = null
114
+ /** @type {ReturnType<typeof setTimeout> | null} */
115
+ let refreshDebounce = null
116
+ /** @type {ReturnType<typeof setInterval> | null} */
117
+ let discoveryTimer = null
118
+
119
+ /**
120
+ * @param {string} url relay URL
121
+ * @returns {RelaySession} 新会话
122
+ */
123
+ function createSession(url) {
124
+ return {
125
+ url,
126
+ ws: null,
127
+ subId: '',
128
+ events: new Map(),
129
+ inflight: 0,
130
+ reconnectDelayMs: RECONNECT_BASE_MS,
131
+ reconnectTimer: null,
132
+ }
133
+ }
134
+
135
+ /**
136
+ * 连接会话(含指数退避重连调度)。
137
+ * @param {RelaySession} session 会话
138
+ * @returns {void}
139
+ */
140
+ function connectSession(session) {
141
+ if (stopped || session.ws) return
142
+ const ws = new WebSocket(session.url)
143
+ session.ws = ws
144
+ const connectTimer = setTimeout(() => { try { ws.close() } catch { /* ignore */ } }, CONNECT_TIMEOUT_MS)
145
+ /**
146
+ * WebSocket 已连接:订阅 census 事件。
147
+ * @returns {void}
148
+ */
149
+ const onOpen = () => {
150
+ if (session.ws !== ws) return
151
+ clearTimeout(connectTimer)
152
+ session.reconnectDelayMs = RECONNECT_BASE_MS
153
+ session.subId = 'census-' + Math.random().toString(36).slice(2)
154
+ ws.send(JSON.stringify(['REQ', session.subId, {
155
+ kinds: [NOSTR_CENSUS_KIND],
156
+ '#t': [CENSUS_TAG_FOUNT],
157
+ '#x': [CENSUS_TAG_X],
158
+ }]))
159
+ }
160
+ /**
161
+ * @param {MessageEvent} event WebSocket 消息事件
162
+ * @returns {void}
163
+ */
164
+ const onMessage = event => {
165
+ if (session.ws !== ws) return
166
+ handleRelayMessage(session, event.data)
167
+ }
168
+ /**
169
+ * 连接关闭:清会话并调度重连。
170
+ * @returns {void}
171
+ */
172
+ const onClose = () => {
173
+ clearTimeout(connectTimer)
174
+ if (session.ws !== ws) return
175
+ session.ws = null
176
+ scheduleReconnect(session)
177
+ }
178
+ ws.addEventListener('open', onOpen)
179
+ ws.addEventListener('message', onMessage)
180
+ ws.addEventListener('close', onClose)
181
+ ws.addEventListener('error', () => { /* close follows error */ })
182
+ }
183
+
184
+ /**
185
+ * 指数退避调度重连(去重保护)。
186
+ * @param {RelaySession} session 会话
187
+ * @returns {void}
188
+ */
189
+ function scheduleReconnect(session) {
190
+ if (stopped || session.reconnectTimer) return
191
+ session.reconnectTimer = setTimeout(() => {
192
+ session.reconnectTimer = null
193
+ connectSession(session)
194
+ }, session.reconnectDelayMs)
195
+ session.reconnectDelayMs = Math.min(session.reconnectDelayMs * 2, RECONNECT_MAX_MS)
196
+ }
197
+
198
+ /**
199
+ * 处理单条 relay 消息:验签 census 帧并入库。
200
+ * @param {RelaySession} session 会话
201
+ * @param {unknown} data 消息数据
202
+ * @returns {void}
203
+ */
204
+ function handleRelayMessage(session, data) {
205
+ if (session.inflight >= MAX_INFLIGHT_VERIFICATIONS) return
206
+ if (typeof data !== 'string' || data.length > MAX_FRAME_BYTES) return
207
+ let parsed
208
+ try { parsed = JSON.parse(data) } catch { return }
209
+ if (parsed?.[0] !== 'EVENT' || parsed[1] !== session.subId) return
210
+ if (parsed[2]?.kind !== NOSTR_CENSUS_KIND) return
211
+ const content = parsed[2]?.content
212
+ if (typeof content !== 'string' || content.length > MAX_FRAME_BYTES) return
213
+ session.inflight++
214
+ void (async () => {
215
+ try {
216
+ const verified = await verifyCensusBytes(base64ToBytes(content))
217
+ if (!verified) return
218
+ const existing = session.events.get(verified.nodeHash)
219
+ if (existing && verified.ts < existing.at) return
220
+ session.events.set(verified.nodeHash, { p: verified.p, at: verified.ts })
221
+ scheduleRefresh()
222
+ }
223
+ catch { /* ignore malformed */ }
224
+ finally { session.inflight-- }
225
+ })()
226
+ }
227
+
228
+ /**
229
+ * 会话快照(逐出过期事件后 HT 估计)。
230
+ * @param {RelaySession} session 会话
231
+ * @returns {{ estimate: number, sampleSize: number, eventsInWindow: number }} 快照
232
+ */
233
+ function sessionSnapshot(session) {
234
+ const now = Date.now()
235
+ const events = []
236
+ for (const [hash, event] of session.events)
237
+ if (now - event.at > CENSUS_TTL_MS) session.events.delete(hash)
238
+ else events.push(event)
239
+ const { estimate, sampleSize } = estimatePopulation(events)
240
+ return { estimate, sampleSize, eventsInWindow: events.length }
241
+ }
242
+
243
+ /**
244
+ * 取人口估计最大的 relay 作为显示源,回调 onUpdate。
245
+ * @returns {void}
246
+ */
247
+ function refresh() {
248
+ let best = null
249
+ for (const session of sessions.values()) {
250
+ const snapshot = sessionSnapshot(session)
251
+ if (!best || snapshot.estimate > best.snapshot.estimate)
252
+ best = { url: session.url, snapshot }
253
+ }
254
+ const chosen = best || { url: relayUrls[0], snapshot: { estimate: 0, sampleSize: 0, eventsInWindow: 0 } }
255
+ onUpdate({
256
+ ...chosen.snapshot,
257
+ relayUrl: chosen.url,
258
+ relays: sessions.size,
259
+ })
260
+ }
261
+
262
+ /**
263
+ * 防抖调度刷新。
264
+ * @returns {void}
265
+ */
266
+ function scheduleRefresh() {
267
+ if (refreshDebounce || stopped) return
268
+ refreshDebounce = setTimeout(() => {
269
+ refreshDebounce = null
270
+ refresh()
271
+ }, REFRESH_DEBOUNCE_MS)
272
+ }
273
+
274
+ /**
275
+ * 单轮 NIP-66 发现:从引导集 + 现有会话拉 kind 30166 候选,加入监听(有界)。
276
+ * @returns {Promise<void>}
277
+ */
278
+ async function runDiscovery() {
279
+ /** @type {string[]} */
280
+ const candidates = []
281
+ /** @type {string[]} */
282
+ const sources = []
283
+ for (const url of [...nip66Bootstrap, ...sessions.keys()])
284
+ if (!sources.includes(url)) sources.push(url)
285
+ await Promise.allSettled(sources.map(url => collectNip66Candidates(url, candidate => {
286
+ if (!candidates.includes(candidate)) candidates.push(candidate)
287
+ })))
288
+ for (const url of candidates) {
289
+ if (stopped || sessions.size >= MAX_MONITOR_RELAYS) break
290
+ if (sessions.has(url)) continue
291
+ const session = createSession(url)
292
+ sessions.set(url, session)
293
+ connectSession(session)
294
+ }
295
+ }
296
+
297
+ /**
298
+ * 从单个中继收集 NIP-66 候选(d tag,wss)。
299
+ * @param {string} relayUrl 发现源中继
300
+ * @param {(candidate: string) => void} onCandidate 候选回调
301
+ * @returns {Promise<void>}
302
+ */
303
+ function collectNip66Candidates(relayUrl, onCandidate) {
304
+ return new Promise(resolve => {
305
+ let ws
306
+ let settled = false
307
+ /** @type {ReturnType<typeof setTimeout> | null} */
308
+ let quietTimer = null
309
+ /**
310
+ * @returns {void}
311
+ */
312
+ const finish = () => {
313
+ if (settled) return
314
+ settled = true
315
+ clearTimeout(timer)
316
+ if (quietTimer) clearTimeout(quietTimer)
317
+ try { ws?.close() } catch { /* ignore */ }
318
+ resolve()
319
+ }
320
+ /** 收到候选后 500ms 无新候选即结束(兼容不响应 EOSE 的中继)。 */
321
+ const armQuiet = () => {
322
+ if (quietTimer) clearTimeout(quietTimer)
323
+ quietTimer = setTimeout(finish, 500)
324
+ }
325
+ const timer = setTimeout(finish, CONNECT_TIMEOUT_MS)
326
+ try { ws = new WebSocket(relayUrl) } catch { finish(); return }
327
+ /**
328
+ * @param {MessageEvent} event WebSocket 消息事件
329
+ * @returns {void}
330
+ */
331
+ const onMessage = event => {
332
+ if (typeof event.data !== 'string') return
333
+ let parsed
334
+ try { parsed = JSON.parse(event.data) } catch { return }
335
+ if (parsed?.[0] === 'EOSE') { finish(); return }
336
+ if (parsed?.[0] !== 'EVENT' || parsed[2]?.kind !== NIP66_KIND) return
337
+ const d = (parsed[2]?.tags || []).find(tag => tag?.[0] === 'd')?.[1]
338
+ const url = normalizeRelayUrl(d)
339
+ if (url) {
340
+ onCandidate(url)
341
+ armQuiet()
342
+ }
343
+ }
344
+ ws.addEventListener('open', () => {
345
+ const subId = 'nip66-' + Math.random().toString(36).slice(2)
346
+ ws.send(JSON.stringify(['REQ', subId, { kinds: [NIP66_KIND, 10166], limit: NIP66_REQ_LIMIT }]))
347
+ })
348
+ ws.addEventListener('message', onMessage)
349
+ ws.addEventListener('close', finish)
350
+ ws.addEventListener('error', () => { /* close follows error */ })
351
+ })
352
+ }
353
+
354
+ /**
355
+ * 启动周期 NIP-66 发现。
356
+ * @returns {void}
357
+ */
358
+ function startDiscovery() {
359
+ void runDiscovery().catch(() => { })
360
+ discoveryTimer = setInterval(() => { void runDiscovery().catch(() => { }) }, NIP66_REFRESH_MS)
361
+ discoveryTimer.unref?.()
362
+ }
363
+
364
+ /**
365
+ * 停止监控:关闭全部连接与定时器。
366
+ * @returns {void}
367
+ */
368
+ function stop() {
369
+ if (stopped) return
370
+ stopped = true
371
+ signal?.removeEventListener('abort', stop)
372
+ if (refreshTimer) clearInterval(refreshTimer)
373
+ refreshTimer = null
374
+ if (refreshDebounce) clearTimeout(refreshDebounce)
375
+ refreshDebounce = null
376
+ if (discoveryTimer) clearInterval(discoveryTimer)
377
+ discoveryTimer = null
378
+ for (const session of sessions.values()) {
379
+ if (session.reconnectTimer) clearTimeout(session.reconnectTimer)
380
+ session.reconnectTimer = null
381
+ try { session.ws?.close() } catch { /* ignore */ }
382
+ session.ws = null
383
+ }
384
+ sessions.clear()
385
+ }
386
+
387
+ for (const url of relayUrls) {
388
+ const session = createSession(url)
389
+ sessions.set(url, session)
390
+ connectSession(session)
391
+ }
392
+ if (discover) startDiscovery()
393
+ refresh()
394
+ if (refreshMs > 0) {
395
+ refreshTimer = setInterval(refresh, refreshMs)
396
+ refreshTimer.unref?.()
397
+ }
398
+ if (signal) {
399
+ if (signal.aborted) { stop(); return { stop } }
400
+ signal.addEventListener('abort', stop, { once: true })
401
+ }
402
+ return { stop }
403
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * census 包验签(Untrusted ingress;Node 与浏览器共用,无 Node 依赖)。
3
+ *
4
+ * 签名体:{ nodeHash, nodePubKey, ts, p, sig },消息 `fount-census\0ts\0nodeHash\0p`,
5
+ * 用节点身份(Ed25519)签名,nodeHash = sha256(nodePubKey)。
6
+ */
7
+ import { hexToBytes } from '../../core/bytes_codec.mjs'
8
+ import { isHex64, isSignatureHex128 } from '../../core/hexIds.mjs'
9
+ import { pubKeyHash, verify } from '../../crypto/crypto.mjs'
10
+
11
+ import { CENSUS_MIN_P } from './census_math.mjs'
12
+
13
+ /** 事件/窗口存活时间(与 advert TTL 一致)。 */
14
+ export const CENSUS_TTL_MS = 10 * 60_000
15
+
16
+ /**
17
+ * @param {number} ts 时间戳(毫秒)
18
+ * @param {string} nodeHash 64 hex 节点 hash
19
+ * @param {number} p 包含概率
20
+ * @returns {Uint8Array} 待签名消息
21
+ */
22
+ export function buildCensusMessage(ts, nodeHash, p) {
23
+ return new TextEncoder().encode(`fount-census\0${ts}\0${nodeHash}\0${p}`)
24
+ }
25
+
26
+ /**
27
+ * 校验 census 包(Untrusted ingress):canonicalize + 验签 + 时间窗 + p 范围。
28
+ * @param {unknown} packet 原始 census 包
29
+ * @param {number} [now=Date.now()] 当前时间(毫秒)
30
+ * @param {number} [ttlMs=CENSUS_TTL_MS] 允许的时间窗
31
+ * @returns {Promise<{ nodeHash: string, p: number, ts: number } | null>} 校验通过返回 nodeHash/p/ts,否则 null
32
+ */
33
+ export async function verifyCensusPacket(packet, now = Date.now(), ttlMs = CENSUS_TTL_MS) {
34
+ const nodeHash = isHex64(packet?.nodeHash)
35
+ const nodePubKey = isHex64(packet?.nodePubKey)
36
+ const sig = isSignatureHex128(packet?.sig)
37
+ const ts = Number(packet?.ts)
38
+ const p = Number(packet?.p)
39
+ if (!nodeHash || !nodePubKey || !sig || !Number.isFinite(ts)) return null
40
+ if (Math.abs(now - ts) > ttlMs) return null
41
+ if (!Number.isFinite(p) || p < CENSUS_MIN_P || p > 1) return null
42
+ try {
43
+ if (pubKeyHash(hexToBytes(nodePubKey)) !== nodeHash) return null
44
+ }
45
+ catch {
46
+ return null
47
+ }
48
+ return await verify(hexToBytes(sig), buildCensusMessage(ts, nodeHash, p), hexToBytes(nodePubKey)) ? { nodeHash, p, ts } : null
49
+ }
50
+
51
+ /**
52
+ * 解 base64 content 字节并校验 census 包。
53
+ * @param {Uint8Array} bytes content 解码字节
54
+ * @param {number} [now=Date.now()] 当前时间(毫秒)
55
+ * @param {number} [ttlMs=CENSUS_TTL_MS] 允许的时间窗
56
+ * @returns {Promise<{ nodeHash: string, p: number, ts: number } | null>} 校验通过结果或 null
57
+ */
58
+ export async function verifyCensusBytes(bytes, now = Date.now(), ttlMs = CENSUS_TTL_MS) {
59
+ try {
60
+ return await verifyCensusPacket(JSON.parse(new TextDecoder().decode(bytes)), now, ttlMs)
61
+ }
62
+ catch {
63
+ return null
64
+ }
65
+ }
@@ -41,3 +41,20 @@ export const NIP66_BOOTSTRAP_RELAYS = [
41
41
  'wss://relaypag.es',
42
42
  'wss://monitorlizard.nostr1.com',
43
43
  ]
44
+
45
+ /** 默认公共中继(source='public',永不淘汰;浏览器端人口统计监控亦以此为默认监听集)。 */
46
+ export const DEFAULT_RELAY_URLS = [
47
+ 'wss://relay.nostr.com',
48
+ 'wss://nos.lol',
49
+ 'wss://nostr.bitcoiner.social',
50
+ 'wss://nostr.mom',
51
+ 'wss://relay.snort.social',
52
+ 'wss://relay.primal.net',
53
+ ]
54
+
55
+ /** Nostr census 事件 kind(每节点每窗口至多一条,按 nodeHash 去重)。 */
56
+ export const NOSTR_CENSUS_KIND = 30789
57
+
58
+ /** census 订阅/发布标签:`t=fount` + `x=census`(subscribeNostrKind 以 rendezvousKey/tagX 匹配)。 */
59
+ export const CENSUS_TAG_FOUNT = 'fount'
60
+ export const CENSUS_TAG_X = 'census'
@@ -18,9 +18,9 @@ import {
18
18
  } from '../internal/signal_crypto.mjs'
19
19
  import { noteDiscoveryPeerClue } from '../peer_clue.mjs'
20
20
 
21
+ import { DEFAULT_RELAY_URLS } from './constants.mjs'
21
22
  import { createNostrCensus } from './census.mjs'
22
23
  import {
23
- DEFAULT_RELAY_URLS,
24
24
  getListenRelays,
25
25
  getPeerRoute,
26
26
  getPoolByUrl,
@@ -15,6 +15,7 @@ import {
15
15
  } from '../../node/storage.mjs'
16
16
 
17
17
  import {
18
+ DEFAULT_RELAY_URLS,
18
19
  DEFAULT_RTT_MS,
19
20
  FAILURE_WEIGHT,
20
21
  LAST_GOOD_RELAYS_MAX,
@@ -29,13 +30,6 @@ import {
29
30
  } from './constants.mjs'
30
31
  import { connectRelay, dedupeRelayUrls, pinnedLookup } from './session.mjs'
31
32
 
32
- /** 默认公共中继(source='public',永不淘汰)。 */
33
- export const DEFAULT_RELAY_URLS = [
34
- 'wss://relay.damus.io',
35
- 'wss://nos.lol',
36
- 'wss://relay.primal.net',
37
- ]
38
-
39
33
  /** 持久化写盘节流延迟。 */
40
34
  const FLUSH_DELAY_MS = 2_000
41
35
  /** probe 单次连接超时。 */
package/docs/evfs.md CHANGED
@@ -40,6 +40,12 @@ Concurrency: `putChunk` / `putChunkFromStream` / `deleteChunk` / the GC delete p
40
40
 
41
41
  Outer caller timeouts must **not** abort the in-flight work — background fill continues after the caller gives up.
42
42
 
43
+ ## `fetchChunk` / public-file chunk reads
44
+
45
+ - `fetchChunk` accepts the same `fanoutTargets` as `fetchManifest`. With targets it fanouts `fed_chunk_get` **only** to that node set; without targets it falls back to node-scope fanout. Same `username`+chunk hash+mode (`targeted`/`public`) is deduped in-flight.
46
+ - `readPublicFile` / `readManifestPlaintext` forward `options.fanoutTargets` to both the manifest fetch and the chunk fetch, so a cross-node public read can pull profile/avatar content straight from the owner node instead of depending on the node-scope fanout.
47
+ - The public (non-targeted) fanout does **not** block the request window on dialing the whole peer pool: already-linked / group-room-reachable peers are sent immediately, and unreachable peers are dialed in the background and re-sent once linked. (`ensureLinkToNode` no longer gates the send.)
48
+
43
49
  ## Non-public (ACL-gated) manifests
44
50
 
45
51
  - `file-master-key-wrap` / `vault-wrap` / `identity-wrap` manifests carry no author signature, so the remote-acquisition trust boundary is **not** a signature — it is the **fanout target set** plus the **serving node's authorization**.
@@ -26,10 +26,14 @@ const chunkInflight = createInflightTable({
26
26
  * ciphertextHash: string,
27
27
  * ownerEntityHash?: string,
28
28
  * groupId?: string,
29
+ * fanoutTargets?: string[],
29
30
  * }} FetchChunkContext
30
31
  */
31
32
 
32
33
  /**
34
+ * 拉取密文块。默认走 node-scope public 语义;
35
+ * 传入 `fanoutTargets` 时只向目标集 fanout(public 文件定向拉取,如 profile/avatar 直接向 owner 节点取块)。
36
+ * 同 username+hash+模式(含规范化目标集)in-flight 去重;本地已缓存块直接返回。
33
37
  * @param {FetchChunkContext} context 上下文
34
38
  * @returns {Promise<Uint8Array | null>} 密文块
35
39
  */
@@ -50,7 +54,7 @@ export async function fetchChunk(context) {
50
54
 
51
55
  const shared = beginFedFanoutFetch({
52
56
  inflight: chunkInflight,
53
- inflightKey: `${username}\0${hash}`,
57
+ inflightKeyBase: `${username}\0${hash}`,
54
58
  username,
55
59
  action: 'fed_chunk_get',
56
60
  /**
@@ -69,6 +73,7 @@ export async function fetchChunk(context) {
69
73
  chunkHash: hash,
70
74
  ownerEntityHash: context.ownerEntityHash,
71
75
  }),
76
+ fanoutTargets: context.fanoutTargets,
72
77
  })
73
78
  if (!shared) return null
74
79
 
package/files/evfs.mjs CHANGED
@@ -32,7 +32,7 @@ function transferKeyDependenciesForReplica(replicaUsername, manifest) {
32
32
  /**
33
33
  * @param {string} username 拉取身份
34
34
  * @param {import('./manifest/normalize.mjs').FileManifest} manifest 清单
35
- * @param {{ fetchChunk?: Function }} [options] miss 拉取
35
+ * @param {{ fetchChunk?: Function, fanoutTargets?: string[] }} [options] miss 拉取
36
36
  * @returns {Promise<boolean>} 全部 part 是否已就位
37
37
  */
38
38
  async function ensureManifestPartsLocal(username, manifest, options = {}) {
@@ -43,6 +43,7 @@ async function ensureManifestPartsLocal(username, manifest, options = {}) {
43
43
  ciphertextHash: part.hash,
44
44
  ownerEntityHash: manifest.ownerEntityHash,
45
45
  groupId: manifest.transferKeyDescriptor.groupId,
46
+ fanoutTargets: options.fanoutTargets,
46
47
  })
47
48
  if (!fetchedChunk) return false
48
49
  await putChunk(part.hash, fetchedChunk)
@@ -94,7 +95,7 @@ export async function storeManifestParts(manifest, partBytes) {
94
95
  /**
95
96
  * @param {string} replicaUsername 副本用户名
96
97
  * @param {import('./manifest/normalize.mjs').FileManifest} manifest 清单
97
- * @param {{ username?: string, fetchChunk?: Function }} [options] miss 拉取
98
+ * @param {{ username?: string, fetchChunk?: Function, fanoutTargets?: string[] }} [options] miss 拉取
98
99
  * @returns {Promise<Buffer | null>} 明文内容
99
100
  */
100
101
  export async function readManifestPlaintext(replicaUsername, manifest, options = {}) {
@@ -118,7 +119,7 @@ export async function readManifestPlaintext(replicaUsername, manifest, options =
118
119
  /**
119
120
  * @param {string} replicaUsername 副本用户名
120
121
  * @param {import('./manifest/normalize.mjs').FileManifest} manifest 清单
121
- * @param {{ username?: string, fetchChunk?: Function }} [options] miss 拉取
122
+ * @param {{ username?: string, fetchChunk?: Function, fanoutTargets?: string[] }} [options] miss 拉取
122
123
  * @returns {Promise<import('node:stream').Readable | null>} 明文流
123
124
  */
124
125
  export async function readManifestPlaintextStream(replicaUsername, manifest, options = {}) {
@@ -229,10 +230,11 @@ export async function putFileManifestFromStream(parameters) {
229
230
 
230
231
  /**
231
232
  * 读取实体公开文件:本地 miss 时经网络取回签名 manifest,chunk miss 走既有 fetchChunk。
233
+ * 传入 `fanoutTargets` 时 manifest 与 chunk 拉取均定向到目标集(public 文件跨节点直取 owner 节点,避免 node-scope 大扇出)。
232
234
  * @param {string} replicaUsername 副本用户名
233
235
  * @param {string} entityHash owner entityHash
234
236
  * @param {string} logicalPath EVFS 逻辑路径
235
- * @param {{ username?: string, fetchChunk?: Function, revalidate?: boolean }} [options] miss 拉取;`revalidate` 强制阻塞等待 fanout 择新
237
+ * @param {{ username?: string, fetchChunk?: Function, revalidate?: boolean, fanoutTargets?: string[] }} [options] miss 拉取;`revalidate` 强制阻塞等待 fanout 择新
236
238
  * @returns {Promise<Buffer | null>} 明文或 null
237
239
  */
238
240
  export async function readPublicFile(replicaUsername, entityHash, logicalPath, options = {}) {
@@ -243,6 +245,7 @@ export async function readPublicFile(replicaUsername, entityHash, logicalPath, o
243
245
  logicalPath,
244
246
  cache: true,
245
247
  revalidate: options.revalidate === true,
248
+ fanoutTargets: options.fanoutTargets,
246
249
  })
247
250
  if (!manifest) return null
248
251
  return readManifestPlaintext(replicaUsername, manifest, options)
@@ -1,14 +1,15 @@
1
1
  import { randomUUID } from 'node:crypto'
2
2
 
3
3
  import { resolveNodeHash } from '../chunk/provider_registry.mjs'
4
- import { fanoutFedFetch } from '../fetch_fanout.mjs'
4
+ import { canonicalizeFanoutTargets, fanoutFedFetch } from '../fetch_fanout.mjs'
5
5
 
6
6
  /**
7
7
  * inflight + pending wait + fanout 共用骨架。
8
+ * 规范化目标集并拼入 inflight key:`fanoutTargets` 为数组(含空)即定向模式,undefined 为 node-scope public 模式。
8
9
  * @template T
9
10
  * @param {{
10
11
  * inflight: { acquire: (key: string, start: () => { done: Promise<T | null>, cancel: () => void }) => Promise<T | null> | null },
11
- * inflightKey: string,
12
+ * inflightKeyBase: string,
12
13
  * username: string,
13
14
  * action: string,
14
15
  * registerWait: (requestId: string) => { done: Promise<T | null>, cancel: () => void },
@@ -19,20 +20,23 @@ import { fanoutFedFetch } from '../fetch_fanout.mjs'
19
20
  */
20
21
  export function beginFedFanoutFetch({
21
22
  inflight,
22
- inflightKey,
23
+ inflightKeyBase,
23
24
  username,
24
25
  action,
25
26
  registerWait,
26
27
  buildPayload,
27
28
  fanoutTargets,
28
29
  }) {
30
+ const targeted = Array.isArray(fanoutTargets)
31
+ const canonicalTargets = targeted ? canonicalizeFanoutTargets(fanoutTargets) : undefined
32
+ const inflightKey = `${inflightKeyBase}\0${targeted ? 'targeted' : 'public'}` + (canonicalTargets?.length ? `\0${canonicalTargets.join('\0')}` : '')
29
33
  return inflight.acquire(inflightKey, () => {
30
34
  const requestId = randomUUID()
31
35
  const wait = registerWait(requestId)
32
36
  void (async () => {
33
37
  try {
34
38
  const { nodeHash } = await resolveNodeHash(username)
35
- await fanoutFedFetch(username, action, buildPayload(requestId, nodeHash), fanoutTargets)
39
+ await fanoutFedFetch(username, action, buildPayload(requestId, nodeHash), canonicalTargets)
36
40
  }
37
41
  catch { /* pending wait 超时/cancel 负责 settle */ }
38
42
  })()
@@ -32,6 +32,7 @@ export function canonicalizeFanoutTargets(targets) {
32
32
  /**
33
33
  * 全局 miss 请求扇出:先向已知 peer 定向发送,再 trust-graph top-K fanout。
34
34
  * 已直连 / follow hint peer 可能不在 trust-graph top-K(非成员 emoji CAS / Social 预览路径),故先定向发送。
35
+ * 拨号不阻塞请求窗口:已直连 peer 立即经现有链路投递,未直连 peer 先经群房间/overlay 尝试并后台拨号补发。
35
36
  * @param {string} username 用户
36
37
  * @param {string} action wire action 名
37
38
  * @param {object} payload 请求载荷
@@ -52,8 +53,20 @@ export async function fanoutFedFetch(username, action, payload, fanoutTargets) {
52
53
 
53
54
  const graph = await trustGraph.buildMergedGraph(username)
54
55
  const peerTargets = fetchPeerTargets()
55
- await Promise.all(peerTargets.map(nodeHash => ensureLinkToNode(nodeHash).catch(() => null)))
56
- for (const nodeHash of peerTargets)
57
- void trustGraph.sendToNode(username, nodeHash, action, payload, graph)
56
+ // 已直连 peer 立即经现有链路投递;其余 peer 后台并行:先经群房间/overlay 尝试投递,同时拨号,拨通且首投失败再补发。
57
+ // 全程不阻塞请求窗口(大 peer 池下 await 全量拨号会超窗口)。
58
+ const linked = new Set(listLinks().map(({ nodeHash }) => nodeHash))
59
+ for (const nodeHash of peerTargets) {
60
+ if (linked.has(nodeHash)) {
61
+ void trustGraph.sendToNode(username, nodeHash, action, payload, graph)
62
+ continue
63
+ }
64
+ const dialed = ensureLinkToNode(nodeHash).catch(() => null)
65
+ void trustGraph.sendToNode(username, nodeHash, action, payload, graph).then(async sent => {
66
+ if (sent) return
67
+ const link = await dialed
68
+ if (link) await trustGraph.sendToNode(username, nodeHash, action, payload, graph)
69
+ })
70
+ }
58
71
  await trustGraph.fanoutToTopNodes(username, action, payload, FEDERATION_CHUNK_FETCH_FANOUT_K)
59
72
  }
@@ -54,7 +54,7 @@ export async function fetchManifest(context) {
54
54
  const localPromise = loadFileManifest(ownerEntityHash, logicalPath)
55
55
  const shared = beginFedFanoutFetch({
56
56
  inflight: manifestInflight,
57
- inflightKey: `${username}\0${expectedKey}\0${targeted ? 'targeted' : 'public'}` + (fanoutTargets?.length ? `\0${fanoutTargets.join('\0')}` : ''),
57
+ inflightKeyBase: `${username}\0${expectedKey}`,
58
58
  username,
59
59
  action: 'fed_manifest_get',
60
60
  /**
package/infra/service.mjs CHANGED
@@ -1,4 +1,3 @@
1
-
2
1
  import { isNodeInitialized } from '../node/instance.mjs'
3
2
  import { setOverlayRateGate, clearOverlayRateGate } from '../overlay/index.mjs'
4
3
  import { getLinkRegistry } from '../transport/link_registry.mjs'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve02081504/fount-p2p",
3
- "version": "0.0.40",
3
+ "version": "0.0.42",
4
4
  "description": "fount federation P2P layer — link, trust graph, mailbox, DAG, EVFS.",
5
5
  "keywords": [
6
6
  "network",
@@ -84,7 +84,7 @@
84
84
  "optionalDependencies": {
85
85
  "@stoprocent/bleno": "latest",
86
86
  "@stoprocent/noble": "latest",
87
- "node-datachannel": "0.33.0"
87
+ "node-datachannel": "latest"
88
88
  },
89
89
  "allowScripts": {
90
90
  "node-datachannel": true
package/pages/index.html CHANGED
@@ -17,7 +17,7 @@
17
17
  </head>
18
18
  <body>
19
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>
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
21
  <label>
22
22
  <input type="checkbox" id="toggle" checked>
23
23
  人口统计(census)
@@ -30,7 +30,7 @@
30
30
  <div class="card">
31
31
  <div>采样事件数 <span id="sample-size">0</span> · 窗口内事件 <span id="window-events">0</span></div>
32
32
  </div>
33
- <footer id="footer" style="opacity:.6;font-size:.85rem"></footer>
34
- <script type="module" src="./app.mjs"></script>
33
+ <footer id="footer" style="opacity:.6;font-size:.85rem">通过 URL 参数 ?relay=wss://... 指定中继;缺省监听默认 relay。</footer>
34
+ <script type="module" src="./index.mjs"></script>
35
35
  </body>
36
36
  </html>
@@ -0,0 +1,62 @@
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
+ onUpdate: snapshot => {
36
+ render(snapshot)
37
+ statusEl.textContent = `监听 ${snapshot.relayUrl}(census,共 ${snapshot.relays} 个 relay)`
38
+ },
39
+ })
40
+ }
41
+ catch (error) {
42
+ statusEl.textContent = `中继配置无效:${String(error?.message || error)}`
43
+ }
44
+ }
45
+
46
+ /** @returns {void} 停止监控 */
47
+ function stopLive() {
48
+ monitor?.stop()
49
+ monitor = null
50
+ }
51
+
52
+ toggleEl.addEventListener('change', () => {
53
+ if (!toggleEl.checked) {
54
+ stopLive()
55
+ render({ estimate: 0, sampleSize: 0, eventsInWindow: 0 })
56
+ statusEl.textContent = '已关闭'
57
+ return
58
+ }
59
+ startLive()
60
+ })
61
+
62
+ if (toggleEl.checked) startLive()
package/pages/app.mjs DELETED
@@ -1,172 +0,0 @@
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()