@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 +4 -2
- package/discovery/nostr/census.mjs +9 -62
- package/discovery/nostr/census_monitor.mjs +403 -0
- package/discovery/nostr/census_verify.mjs +65 -0
- package/discovery/nostr/constants.mjs +17 -0
- package/discovery/nostr/index.mjs +1 -1
- package/discovery/nostr/relays.mjs +1 -7
- package/docs/evfs.md +6 -0
- package/files/chunk/fetch.mjs +6 -1
- package/files/evfs.mjs +7 -4
- package/files/fed/fetch_shared.mjs +8 -4
- package/files/fetch_fanout.mjs +16 -3
- package/files/manifest/fetch.mjs +1 -1
- package/infra/service.mjs +0 -1
- package/package.json +2 -2
- package/pages/index.html +3 -3
- package/pages/index.mjs +62 -0
- package/pages/app.mjs +0 -172
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
|
|
19
|
-
import { keyPairFromSeed, pubKeyHash, sign
|
|
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
|
-
|
|
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
|
-
/**
|
|
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**.
|
package/files/chunk/fetch.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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),
|
|
39
|
+
await fanoutFedFetch(username, action, buildPayload(requestId, nodeHash), canonicalTargets)
|
|
36
40
|
}
|
|
37
41
|
catch { /* pending wait 超时/cancel 负责 settle */ }
|
|
38
42
|
})()
|
package/files/fetch_fanout.mjs
CHANGED
|
@@ -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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
}
|
package/files/manifest/fetch.mjs
CHANGED
|
@@ -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
|
-
|
|
57
|
+
inflightKeyBase: `${username}\0${expectedKey}`,
|
|
58
58
|
username,
|
|
59
59
|
action: 'fed_manifest_get',
|
|
60
60
|
/**
|
package/infra/service.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steve02081504/fount-p2p",
|
|
3
|
-
"version": "0.0.
|
|
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": "
|
|
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
|
|
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"
|
|
34
|
-
<script type="module" src="./
|
|
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>
|
package/pages/index.mjs
ADDED
|
@@ -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()
|