@steve02081504/fount-p2p 0.0.36 → 0.0.38

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.
Files changed (38) hide show
  1. package/AGENTS.md +3 -3
  2. package/discovery/adverts.mjs +16 -8
  3. package/discovery/nostr/census.mjs +281 -0
  4. package/discovery/nostr/census_math.mjs +54 -0
  5. package/discovery/nostr/constants.mjs +43 -0
  6. package/discovery/nostr/index.mjs +612 -0
  7. package/discovery/nostr/relays.mjs +1071 -0
  8. package/discovery/nostr/selection.mjs +224 -0
  9. package/discovery/nostr/session.mjs +584 -0
  10. package/docs/evfs.md +15 -6
  11. package/docs/nostr_relay_discovery.md +92 -0
  12. package/files/chunk/responder.mjs +2 -2
  13. package/files/evfs.mjs +2 -2
  14. package/files/fed/fetch_shared.mjs +3 -1
  15. package/files/fed/responder.mjs +3 -2
  16. package/files/fetch_fanout.mjs +26 -5
  17. package/files/manifest/fetch.mjs +73 -38
  18. package/files/manifest/pending.mjs +21 -7
  19. package/files/manifest/servicer_registry.mjs +51 -0
  20. package/governance/branch.mjs +1 -1
  21. package/governance/join_pow.mjs +2 -2
  22. package/index.mjs +11 -4
  23. package/link/handshake.mjs +135 -23
  24. package/link/providers/link_id_pipe.mjs +1 -1
  25. package/link/providers/{nostr.mjs → nostr/index.mjs} +19 -14
  26. package/mailbox/deliver_or_store.mjs +3 -4
  27. package/mailbox/store.mjs +1 -1
  28. package/node/feature_config.mjs +30 -0
  29. package/node/instance.mjs +21 -0
  30. package/node/reputation_store.mjs +1 -2
  31. package/node/storage.mjs +23 -0
  32. package/package.json +10 -4
  33. package/pages/app.mjs +172 -0
  34. package/pages/index.html +36 -0
  35. package/reputation/engine.mjs +1 -2
  36. package/transport/runtime_bootstrap.mjs +39 -3
  37. package/utils/fetch_wait.mjs +12 -7
  38. package/discovery/nostr.mjs +0 -1073
package/AGENTS.md CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  **Facade:** `index.mjs`; subpath exports mirror directories.
16
16
 
17
- Detail docs: [transports](docs/transports.md) · [mesh](docs/mesh.md) · [signaling](docs/signaling.md) · [runtime](docs/runtime.md) · [infra](docs/infra.md) · [wire](docs/wire.md) · [evfs](docs/evfs.md) · [reputation](docs/reputation.md)
17
+ Detail docs: [transports](docs/transports.md) · [mesh](docs/mesh.md) · [signaling](docs/signaling.md) · [nostr-relay-discovery](docs/nostr_relay_discovery.md) · [runtime](docs/runtime.md) · [infra](docs/infra.md) · [wire](docs/wire.md) · [evfs](docs/evfs.md) · [reputation](docs/reputation.md)
18
18
 
19
19
  ### Runtime: isomorphic vs Node
20
20
 
@@ -53,12 +53,12 @@ Deno / native / BT: [runtime.md](docs/runtime.md).
53
53
 
54
54
  ### Trust / ingress
55
55
 
56
- - **Untrusted ingress only:** discovery adverts/signals, link/overlay envelopes, group federation frames, `remoteIngest`, `part_timeline_*` / `part_invoke`, `part_query_*`, public manifest (`fed_manifest_data`). Validate / `canonicalize*` / `verifySignedPublicManifest` **only** here.
56
+ - **Untrusted ingress only:** discovery adverts/signals, link/overlay envelopes, group federation frames, `remoteIngest`, `part_timeline_*` / `part_invoke`, `part_query_*`, manifest fetch responses (`fed_manifest_data`). Validate / `canonicalize*` / `verifySignedPublicManifest` **only** here. Non-public `fed_manifest_data` is accepted only into an `allowNonPublic` pending slot (targeted fanout) and passes `normalizeFileManifest` + owner/path match — the serving node's servicer is the authorization gate. [evfs.md](docs/evfs.md)
57
57
  - **Trusted after disk:** from `events.jsonl`, only `stripDagEventLocalExtensions` — no re-canonicalization upstream.
58
58
  - **Fanout vs targeted / timed collect:** [wire.md](docs/wire.md).
59
59
  - **Channel encryption:** per-channel `K_ch`, scheme `channel-key` (`CHANNEL_KEY_SCHEME`); decrypted payloads are untrusted outside DAG Ed25519 context.
60
60
  - **Denylist vs personal lists:** node `denylist.json` vs per-entity `personal_block.json` / `personal_hide.json`.
61
- - **Manifest ACL / transfer owner:** shells register matchers; core does not hard-code chat/social types.
61
+ - **Manifest ACL / transfer owner / servicer:** shells register matchers and `registerManifestServicer`; core does not hard-code chat/social types. Non-public manifests are served cross-node only via a registered servicer (default deny).
62
62
 
63
63
  ### Node / network
64
64
 
@@ -29,9 +29,10 @@ export function rendezvousKeyForScope(scope, selfNodeHash) {
29
29
  * @param {AdvertScope} scope advert 域
30
30
  * @param {{ nodeHash: string, nodePubKey: string, secretKey: Uint8Array }} localIdentity 本地身份
31
31
  * @param {number | null | undefined} [tcpPort] LAN TCP 端口
32
+ * @param {{ pool?: Array<{ url: string, rtt?: number }>, listen?: string[] }} [relayData] 已规范化并经 sanitize 裁剪的 relay 字段
32
33
  * @returns {Promise<object>} 签名 advert body
33
34
  */
34
- export async function buildSignedAdvertForScope(scope, localIdentity, tcpPort) {
35
+ export async function buildSignedAdvertForScope(scope, localIdentity, tcpPort, relayData) {
35
36
  const key = rendezvousKeyForScope(scope, localIdentity.nodeHash)
36
37
  const lanHosts = scope === 'network' && tcpPort != null
37
38
  ? listMulticastIpv4Addresses()
@@ -40,6 +41,8 @@ export async function buildSignedAdvertForScope(scope, localIdentity, tcpPort) {
40
41
  ...localIdentity,
41
42
  ...tcpPort != null ? { tcpPort } : {},
42
43
  ...lanHosts.length ? { lanHosts } : {},
44
+ ...relayData?.pool ? { nostrRelayPool: relayData.pool } : {},
45
+ ...relayData?.listen ? { listenNostrRelays: relayData.listen } : {},
43
46
  })
44
47
  }
45
48
 
@@ -68,20 +71,25 @@ export function encryptAdvertForScope(scope, localIdentity, advertBody) {
68
71
  * Untrusted ingress:解密并验签 advert;失败返回 null,不抛。不写入可见池 / peer hints。
69
72
  * @param {string} rendezvousKey rendezvous 键
70
73
  * @param {Uint8Array} bytes 加密 advert
71
- * @returns {Promise<{ verifiedNodeHash: string, body: object } | null>} 验签成功返回 nodeHashadvert body,否则 null
74
+ * @returns {Promise<{ verifiedNodeHash: string, body: object, relayPool: Array<{ url: string, rtt: number }>, listenRelays: string[] } | null>} 验签成功返回 nodeHashadvert body 与规范化 relay 字段,否则 null
72
75
  */
73
76
  export async function ingestEncryptedAdvert(rendezvousKey, bytes) {
74
77
  const packet = decryptSignalPacket(rendezvousKey, bytes)
75
78
  if (packet?.type !== 'advert' || !packet.body) return null
76
- const verifiedNodeHash = await verifySignedAdvert(rendezvousKey, packet.body)
77
- if (!verifiedNodeHash) return null
78
- return { verifiedNodeHash, body: packet.body }
79
+ const verified = await verifySignedAdvert(rendezvousKey, packet.body)
80
+ if (!verified) return null
81
+ return {
82
+ verifiedNodeHash: verified.nodeHash,
83
+ body: packet.body,
84
+ relayPool: verified.relayPool,
85
+ listenRelays: verified.listenRelays,
86
+ }
79
87
  }
80
88
 
81
89
  /**
82
90
  * Untrusted ingress:验签 network-scope advert;失败返回 null。不写盘 / 不写 hints。
83
91
  * @param {Uint8Array} bytes 加密 advert
84
- * @returns {Promise<{ verifiedNodeHash: string, body: object } | null>} 验签成功返回 nodeHashadvert body,否则 null
92
+ * @returns {Promise<{ verifiedNodeHash: string, body: object, relayPool: Array<{ url: string, rtt: number }>, listenRelays: string[] } | null>} 验签成功返回 nodeHashadvert body 与规范化 relay 字段,否则 null
85
93
  */
86
94
  export async function ingestNetworkAdvert(bytes) {
87
95
  return ingestEncryptedAdvert(networkRendezvousKey(), bytes)
@@ -91,7 +99,7 @@ export async function ingestNetworkAdvert(bytes) {
91
99
  * Untrusted ingress:验签 node-scope advert;失败返回 null。不写盘 / 不写 hints。
92
100
  * @param {string} nodeHash 目标 nodeHash
93
101
  * @param {Uint8Array} bytes 加密 advert
94
- * @returns {Promise<{ verifiedNodeHash: string, body: object } | null>} 验签成功返回 nodeHashadvert body,否则 null
102
+ * @returns {Promise<{ verifiedNodeHash: string, body: object, relayPool: Array<{ url: string, rtt: number }>, listenRelays: string[] } | null>} 验签成功返回 nodeHashadvert body 与规范化 relay 字段,否则 null
95
103
  */
96
104
  export async function ingestNodeAdvert(nodeHash, bytes) {
97
105
  return ingestEncryptedAdvert(nodeRendezvousKey(nodeHash), bytes)
@@ -101,7 +109,7 @@ export async function ingestNodeAdvert(nodeHash, bytes) {
101
109
  * Untrusted ingress:验签 group-scope advert;失败返回 null。不写盘 / 不写 hints。
102
110
  * @param {string} roomSecret 房间密钥
103
111
  * @param {Uint8Array} bytes 加密 advert
104
- * @returns {Promise<{ verifiedNodeHash: string, body: object } | null>} 验签成功返回 nodeHashadvert body,否则 null
112
+ * @returns {Promise<{ verifiedNodeHash: string, body: object, relayPool: Array<{ url: string, rtt: number }>, listenRelays: string[] } | null>} 验签成功返回 nodeHashadvert body 与规范化 relay 字段,否则 null
105
113
  */
106
114
  export async function ingestGroupAdvert(roomSecret, bytes) {
107
115
  return ingestEncryptedAdvert(groupRendezvousKey(roomSecret), bytes)
@@ -0,0 +1,281 @@
1
+ /**
2
+ * 基于 nostr 的人口统计(census):由 `features.census` 开关驱动。
3
+ *
4
+ * 事件 kind 30789,tag `t=fount` / `x=census`;content = base64(JSON 签名体)。
5
+ * 签名体(Untrusted ingress,需 canonicalize):
6
+ * { nodeHash, nodePubKey, ts, p, sig },签名消息 `fount-census\0ts\0nodeHash\0p`,
7
+ * 用节点身份(node seed,Ed25519)签名,nodeHash = sha256(nodePubKey)。
8
+ *
9
+ * 每窗口(10min):
10
+ * E = 窗口内去重事件数(含自身事件)→ if (self 事件在) E--;p' = p·(T/(E+1))
11
+ * → rand<p 则发布携带 p 的事件。
12
+ * 读者用 HT 估计:M̂ = Σ(1/p)(含自身事件)− 1/p_self(-1:把自身事件移出自己的数据)
13
+ * + 1(+1:自己确定性存在)。缺 -1 会把自己虚增成 1/p_self 人(p=0.01 时 100 人),
14
+ * 缺 +1 则漏掉自己——两操作独立,不能抵消。
15
+ */
16
+ import { Buffer } from 'node:buffer'
17
+
18
+ import { isHex64, isSignatureHex128 } from '../../core/hexIds.mjs'
19
+ import { keyPairFromSeed, pubKeyHash, sign, verify } from '../../crypto/crypto.mjs'
20
+ import { ensureNodeSeed, getNodeHash } from '../../node/identity.mjs'
21
+ import { getP2PFeatures, isNodeInitialized } from '../../node/instance.mjs'
22
+ import { nodeDebug } from '../../node/log.mjs'
23
+
24
+ import {
25
+ CENSUS_MIN_P,
26
+ CENSUS_TARGET_EVENTS,
27
+ estimatePopulation,
28
+ nextInclusionProbability,
29
+ } from './census_math.mjs'
30
+ import { resolveRelayConnectTarget } from './relays.mjs'
31
+
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'
38
+ const CENSUS_TAGS = [['t', CENSUS_TAG_FOUNT], ['x', CENSUS_TAG_X]]
39
+
40
+ /** 事件/窗口存活时间(与 advert TTL 一致)。 */
41
+ const CENSUS_TTL_MS = 10 * 60_000
42
+ /** 发布/统计周期。 */
43
+ const CENSUS_INTERVAL_MS = 10 * 60_000
44
+ /** 冷启动初始包含概率。 */
45
+ const CENSUS_INITIAL_P = 0.5
46
+
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
+ /**
58
+ * 用指定 seed 身份构建签名 census 包(nodeHash 由 seed 派生)。
59
+ * 供工具与测试构造任意身份的 peer 包。
60
+ * @param {string} seedHex 64 hex seed
61
+ * @param {{ p: number, ts?: number }} options 包含概率与时间戳
62
+ * @returns {Promise<{ nodeHash: string, nodePubKey: string, ts: number, p: number, sig: string }>} 签名包
63
+ */
64
+ export async function buildCensusPacketFromSeed(seedHex, { p, ts = Date.now() }) {
65
+ if (!isHex64(seedHex)) throw new Error('p2p: census invalid seed')
66
+ if (!Number.isFinite(p) || p <= 0 || p > 1) throw new Error('p2p: census invalid p')
67
+ const { publicKey, secretKey } = keyPairFromSeed(Buffer.from(seedHex, 'hex'))
68
+ const nodeHash = pubKeyHash(publicKey)
69
+ return {
70
+ nodeHash,
71
+ nodePubKey: Buffer.from(publicKey).toString('hex'),
72
+ ts,
73
+ p,
74
+ sig: Buffer.from(await sign(buildCensusMessage(ts, nodeHash, p), secretKey)).toString('hex'),
75
+ }
76
+ }
77
+
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
+ /** 窗口事件:nodeHash → { p, at }(模块级,与 visibleByHash 同模式)。 */
120
+ const censusEvents = new Map()
121
+
122
+ /**
123
+ * 写入一个已验签 census 事件(按 nodeHash 去重,保留最新)。
124
+ * @param {string} nodeHash 64 hex 节点 hash
125
+ * @param {number} p 包含概率
126
+ * @param {number} [at=Date.now()] 事件时间戳(毫秒)
127
+ * @returns {void}
128
+ */
129
+ function noteCensusEvent(nodeHash, p, at = Date.now()) {
130
+ if (at < censusEvents.get(nodeHash)?.at) return
131
+ censusEvents.set(nodeHash, { p, at })
132
+ }
133
+
134
+ /**
135
+ * 清理过期窗口事件。
136
+ * @param {number} [now=Date.now()] 当前时间(毫秒)
137
+ * @param {number} [ttlMs=CENSUS_TTL_MS] TTL
138
+ * @returns {void}
139
+ */
140
+ function pruneCensusEvents(now = Date.now(), ttlMs = CENSUS_TTL_MS) {
141
+ for (const [hash, event] of censusEvents)
142
+ if (now - event.at > ttlMs) censusEvents.delete(hash)
143
+ }
144
+
145
+ /**
146
+ * @param {number} [now=Date.now()] 当前时间(毫秒)
147
+ * @param {number} [ttlMs=CENSUS_TTL_MS] TTL
148
+ * @returns {Array<{ p: number, at: number }>} 窗口内有效事件(含 p/at)
149
+ */
150
+ function listCensusEvents(now = Date.now(), ttlMs = CENSUS_TTL_MS) {
151
+ pruneCensusEvents(now, ttlMs)
152
+ return [...censusEvents.values()]
153
+ }
154
+
155
+ /**
156
+ * 自身 census 事件是否在窗口内(若在,返回其包含概率 p)。
157
+ * self 事件计入窗口数据(onPayload 不排除),此处供 multiplier(--)与 estimate(-1 权重)移出自身。
158
+ * @param {number} [now=Date.now()] 当前时间(毫秒)
159
+ * @param {number} [ttlMs=CENSUS_TTL_MS] TTL
160
+ * @returns {{ p: number } | null} 自身事件或 null
161
+ */
162
+ function selfCensusEvent(now = Date.now(), ttlMs = CENSUS_TTL_MS) {
163
+ const event = censusEvents.get(getNodeHash())
164
+ return event && now - event.at <= ttlMs ? { p: event.p } : null
165
+ }
166
+
167
+ /** @returns {void} 测试用:清空窗口 */
168
+ export function resetCensusEvents() {
169
+ censusEvents.clear()
170
+ }
171
+
172
+ /**
173
+ * 当前在线节点数估计(HT:Σ 1/p 对端 + 1 自身)。
174
+ * @param {number} [now=Date.now()] 当前时间(毫秒)
175
+ * @param {number} [ttlMs=CENSUS_TTL_MS] TTL
176
+ * @returns {{ estimate: number, sampleSize: number, eventsInWindow: number }} 估计与采样信息
177
+ */
178
+ export function getNodePopulationEstimate(now = Date.now(), ttlMs = CENSUS_TTL_MS) {
179
+ const events = listCensusEvents(now, ttlMs)
180
+ const { estimate, sampleSize } = estimatePopulation(events)
181
+ let total = estimate
182
+ if (isNodeInitialized() && getP2PFeatures().census) {
183
+ const selfEvent = selfCensusEvent(now, ttlMs)
184
+ if (selfEvent) total -= 1 / selfEvent.p
185
+ total++
186
+ }
187
+ return { estimate: total, sampleSize, eventsInWindow: events.length }
188
+ }
189
+
190
+ /**
191
+ * 创建 census worker:每周期读 `features.census`,disabled 则跳过;订阅与发布仅在 enabled 时进行。
192
+ * @param {{
193
+ * resolveRelayUrls: () => string[],
194
+ * publishEvent: (relayUrls: string[], event: object, signal?: AbortSignal) => Promise<void>,
195
+ * signEvent: (kind: number, tags: string[][], content: string) => Promise<object>,
196
+ * subscribeNostrKind: (relayUrls: string[], options: object) => () => void,
197
+ * now?: () => number
198
+ * }} deps 依赖(由 nostr provider 注入闭包)
199
+ * @returns {{ start: () => void, stop: () => void }} worker 控制
200
+ */
201
+ export function createNostrCensus(deps) {
202
+ const { resolveRelayUrls, publishEvent, signEvent, subscribeNostrKind, now = Date.now } = deps
203
+ /** @type {number} */
204
+ let localP = CENSUS_INITIAL_P
205
+ /** @type {() => void} */
206
+ let stopSubscription = () => { }
207
+ let subscribed = false
208
+ /** @type {ReturnType<typeof setInterval> | null} */
209
+ let timer = null
210
+ const abortController = new AbortController()
211
+
212
+ /**
213
+ * 确保 census 订阅(幂等)。
214
+ * @returns {void}
215
+ */
216
+ function ensureSubscription() {
217
+ if (subscribed) return
218
+ subscribed = true
219
+ stopSubscription = subscribeNostrKind(resolveRelayUrls(), {
220
+ kind: NOSTR_CENSUS_KIND,
221
+ rendezvousKey: CENSUS_TAG_FOUNT,
222
+ tagX: CENSUS_TAG_X,
223
+ resolveConnectTarget: resolveRelayConnectTarget,
224
+ /**
225
+ * @param {Uint8Array} bytes content 字节
226
+ * @returns {Promise<void>}
227
+ */
228
+ async onPayload(bytes) {
229
+ const verified = await verifyCensusBytes(bytes, now())
230
+ if (verified) noteCensusEvent(verified.nodeHash, verified.p, verified.ts)
231
+ },
232
+ })
233
+ }
234
+
235
+ /**
236
+ * 单周期:读开关 → 订阅 → 更新 p → 掷币发布。
237
+ * @returns {Promise<void>}
238
+ */
239
+ const run = async () => {
240
+ if (abortController.signal.aborted) return
241
+ if (!isNodeInitialized() || !getP2PFeatures().census) return
242
+ ensureSubscription()
243
+ let observed = listCensusEvents(now()).length
244
+ if (selfCensusEvent(now())) observed--
245
+ localP = nextInclusionProbability(localP, observed + 1, CENSUS_TARGET_EVENTS)
246
+ if (Math.random() >= localP) return
247
+ try {
248
+ const packet = await buildCensusPacketFromSeed(ensureNodeSeed(), { p: localP, ts: now() })
249
+ const event = await signEvent(NOSTR_CENSUS_KIND, CENSUS_TAGS, Buffer.from(JSON.stringify(packet), 'utf8').toString('base64'))
250
+ await publishEvent(resolveRelayUrls(), event, abortController.signal)
251
+ nodeDebug('p2p:census published', { p: localP })
252
+ }
253
+ catch (error) {
254
+ nodeDebug('p2p:census publish fail', { err: String(error?.message || error) })
255
+ }
256
+ }
257
+
258
+ /**
259
+ * 启动 worker(立即跑一轮 + 每 CENSUS_INTERVAL_MS 一轮)。
260
+ * @returns {void}
261
+ */
262
+ function start() {
263
+ void run().catch(() => { })
264
+ timer = setInterval(() => { void run().catch(() => { }) }, CENSUS_INTERVAL_MS)
265
+ timer.unref?.()
266
+ }
267
+
268
+ /**
269
+ * 停止 worker 与订阅。
270
+ * @returns {void}
271
+ */
272
+ function stop() {
273
+ abortController.abort()
274
+ if (timer) clearInterval(timer)
275
+ timer = null
276
+ stopSubscription()
277
+ subscribed = false
278
+ }
279
+
280
+ return { start, stop }
281
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * 人口统计采样纯函数:HT 估计 + 包含概率反馈更新。
3
+ * 修正历史公式:估计用 `Σ(1/p)`(除以包含概率),更新用 `p' = p·(T/E)`(乘性),
4
+ * 而非 `Σp/2`(规模越大越失真)或 `T/(Σp/2)`(事件数随 M^(2/3) 发散)。
5
+ */
6
+
7
+ /** 理想窗口事件数 */
8
+ export const CENSUS_TARGET_EVENTS = 20
9
+ /** 包含概率下限(E==0 探测增长的上限 clamp 由调用方保证) */
10
+ export const CENSUS_MIN_P = 0.001
11
+ /** E==0 时概率增长因子 */
12
+ export const CENSUS_GROW_FACTOR = 1.5
13
+
14
+ /**
15
+ * clamp 包含概率到 [minP, 1];非有限值回落 minP。
16
+ * @param {unknown} p 原始概率
17
+ * @returns {number} 规范化概率
18
+ */
19
+ export function clampP(p) {
20
+ const value = Number(p)
21
+ if (!Number.isFinite(value)) return CENSUS_MIN_P
22
+ return Math.min(1, Math.max(CENSUS_MIN_P, value))
23
+ }
24
+
25
+ /**
26
+ * 下一轮包含概率:观察到 E 条、目标 T 条,按 T/E 乘性缩放;E==0 时向上探测。
27
+ * @param {number} currentP 当前包含概率
28
+ * @param {number} observedCount 窗口内观察到的事件数 E
29
+ * @param {number} [target=CENSUS_TARGET_EVENTS] 目标事件数 T
30
+ * @returns {number} 下一轮包含概率
31
+ */
32
+ export function nextInclusionProbability(currentP, observedCount, target = CENSUS_TARGET_EVENTS) {
33
+ const base = clampP(currentP)
34
+ if (!observedCount) return clampP(base * CENSUS_GROW_FACTOR)
35
+ return clampP(base * (Math.max(1, target) / observedCount))
36
+ }
37
+
38
+ /**
39
+ * HT 估计在线节点数:对每个有效采样事件累加 `1/p`。
40
+ * 剔除 p 非法(≤0 / >1 / 非有限)。TTL 过期由调用方(窗口存储)负责清理。
41
+ * @param {Array<{ p?: unknown }>} events 采样事件(含包含概率)
42
+ * @returns {{ estimate: number, sampleSize: number }} 估计值与有效采样数
43
+ */
44
+ export function estimatePopulation(events) {
45
+ let total = 0
46
+ let sampleSize = 0
47
+ for (const event of events || []) {
48
+ const p = Number(event?.p)
49
+ if (!Number.isFinite(p) || p <= 0 || p > 1) continue
50
+ total += 1 / p
51
+ sampleSize++
52
+ }
53
+ return { estimate: total, sampleSize }
54
+ }
@@ -0,0 +1,43 @@
1
+ /** 三级集合与 Nostr relay 池的硬限制常量。 */
2
+
3
+ /** 池最大条目数。 */
4
+ export const POOL_CAP = 300
5
+ /** 工作集大小(健康分最优前 N)。 */
6
+ export const WORKING_RELAYS_COUNT = 32
7
+ /** 监听/发布子集大小(含所有 public/manual)。 */
8
+ export const LISTEN_RELAYS_COUNT = 24
9
+ /** advert 中 pool 最大条目。 */
10
+ export const MAX_ADVERT_RELAY_POOL = 16
11
+ /** advert 中 listen 最大条目。 */
12
+ export const MAX_ADVERT_LISTEN_RELAYS = 32
13
+ /** 每轮路由最大目标数。 */
14
+ export const MAX_ROUTING_FANOUT = 64
15
+ /** 最大路由重试轮数。 */
16
+ export const MAX_ROUTING_ATTEMPTS = 4
17
+ /** round 0 / 核心集目标 relay 数。 */
18
+ export const ROUND0_TARGET_COUNT = 4
19
+ /** lastGoodNostrRelays / 历史扩展上限。 */
20
+ export const LAST_GOOD_RELAYS_MAX = 16
21
+ /** 失败率惩罚因子。 */
22
+ export const FAILURE_WEIGHT = 4
23
+ /** 过时探测惩罚倍数。 */
24
+ export const STALE_PENALTY = 2
25
+ /** NIP-66 刷新间隔。 */
26
+ export const NIP66_REFRESH_MS = 6 * 3600 * 1000
27
+ /** 超过此时间未探测视为 stale。 */
28
+ export const PROBE_STALE_MS = 24 * 3600 * 1000
29
+ /** 路由退避基数。 */
30
+ export const BACKOFF_BASE_MS = 2000
31
+ /** 路由退避上限。 */
32
+ export const BACKOFF_CAP_MS = 60000
33
+ /** 有效 RTT 上限。 */
34
+ export const MAX_RTT_MS = 60000
35
+ /** 缺失 RTT 默认值。 */
36
+ export const DEFAULT_RTT_MS = 300
37
+
38
+ /** NIP-66 专用引导中继(kind 30166 发现源)。 */
39
+ export const NIP66_BOOTSTRAP_RELAYS = [
40
+ 'wss://relay.nostr.watch',
41
+ 'wss://relaypag.es',
42
+ 'wss://monitorlizard.nostr1.com',
43
+ ]