@steve02081504/fount-p2p 0.0.0
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/README.md +62 -0
- package/core/bytes_codec.mjs +51 -0
- package/core/canonical_json.mjs +21 -0
- package/core/composite_key.mjs +91 -0
- package/core/constants.mjs +53 -0
- package/core/entity_id.mjs +53 -0
- package/core/entity_id_parse.mjs +40 -0
- package/core/evfs_logical_path.mjs +15 -0
- package/core/hexIds.mjs +73 -0
- package/core/hlc.mjs +114 -0
- package/core/random_id.mjs +9 -0
- package/crypto/channel.mjs +101 -0
- package/crypto/checkpoint_sign.mjs +67 -0
- package/crypto/crypto.mjs +158 -0
- package/crypto/key.mjs +431 -0
- package/dag/canonicalize_row.mjs +65 -0
- package/dag/event_query.mjs +15 -0
- package/dag/index.mjs +335 -0
- package/dag/storage.mjs +333 -0
- package/dag/strip_extensions.mjs +18 -0
- package/dag/tunables.json +6 -0
- package/discovery/bt.mjs +260 -0
- package/discovery/index.mjs +134 -0
- package/discovery/mdns.mjs +138 -0
- package/discovery/nostr.mjs +273 -0
- package/entity/files/acl.mjs +38 -0
- package/entity/files/evfs.mjs +189 -0
- package/entity/files/evfs_ref.mjs +37 -0
- package/entity/files/manifest_acl_registry.mjs +93 -0
- package/entity/files/replica_host_cache.mjs +46 -0
- package/entity/files/url.mjs +19 -0
- package/entity/hosting_registry.mjs +47 -0
- package/entity/localized_core.mjs +141 -0
- package/entity/logical_entity.mjs +25 -0
- package/entity/logical_entity_id_registry.mjs +40 -0
- package/entity/node_hash.mjs +15 -0
- package/entity/presentation_registry.mjs +44 -0
- package/entity/profile.mjs +256 -0
- package/entity/replica.mjs +20 -0
- package/entity/session_snapshot_registry.mjs +38 -0
- package/federation/chunk_fetch_pending.mjs +128 -0
- package/federation/chunk_fetch_scheduler.mjs +97 -0
- package/federation/dag_order_cache.mjs +124 -0
- package/federation/dedupe_slot.mjs +33 -0
- package/federation/message_rate_limit.mjs +23 -0
- package/federation/operator_key_chain.mjs +200 -0
- package/federation/topo_order_memo.mjs +44 -0
- package/federation/volatile_streams.mjs +52 -0
- package/federation/want_ids.mjs +158 -0
- package/files/assemble.mjs +220 -0
- package/files/assemble_stream.mjs +129 -0
- package/files/chunk_fetch.mjs +113 -0
- package/files/chunk_fetch_verify.mjs +23 -0
- package/files/chunk_provider_registry.mjs +70 -0
- package/files/chunk_responder.mjs +79 -0
- package/files/chunk_store.mjs +87 -0
- package/files/manifest.mjs +99 -0
- package/files/transfer_key.mjs +87 -0
- package/files/transfer_key_registry.mjs +108 -0
- package/governance/branch.mjs +230 -0
- package/governance/join_pow.mjs +165 -0
- package/governance/owner_succession_ballot.mjs +57 -0
- package/governance/tunables.json +7 -0
- package/index.mjs +39 -0
- package/link/channel_mux.mjs +222 -0
- package/link/frame.mjs +246 -0
- package/link/handshake.mjs +175 -0
- package/link/link.mjs +617 -0
- package/link/rtc.mjs +109 -0
- package/link/sdp_fingerprint.mjs +25 -0
- package/mailbox/consumer_registry.mjs +71 -0
- package/mailbox/deliver_or_store.mjs +190 -0
- package/mailbox/importance.mjs +72 -0
- package/mailbox/parse.mjs +73 -0
- package/mailbox/prune.mjs +106 -0
- package/mailbox/rate.mjs +86 -0
- package/mailbox/settings.mjs +53 -0
- package/mailbox/store.mjs +256 -0
- package/mailbox/tunables.json +12 -0
- package/mailbox/wire.mjs +40 -0
- package/node/denylist.mjs +309 -0
- package/node/entity_store.mjs +214 -0
- package/node/identity.mjs +99 -0
- package/node/instance.mjs +115 -0
- package/node/network.mjs +306 -0
- package/node/personal_block.mjs +286 -0
- package/node/reputation_store.mjs +326 -0
- package/node/retention_policy.mjs +82 -0
- package/node/signaling_config.mjs +32 -0
- package/node/storage.mjs +30 -0
- package/node/storage_plugins.mjs +71 -0
- package/node/user_paths.mjs +10 -0
- package/overlay/index.mjs +191 -0
- package/package.json +73 -0
- package/permissions/bitmask.mjs +35 -0
- package/permissions/evaluator.mjs +90 -0
- package/permissions/index.mjs +12 -0
- package/permissions/layered.mjs +39 -0
- package/registries/action.mjs +94 -0
- package/registries/event_type.mjs +75 -0
- package/registries/inbound.mjs +90 -0
- package/registries/p2p_viewer.mjs +35 -0
- package/registries/part_path.mjs +35 -0
- package/registries/room_provider.mjs +47 -0
- package/reputation/engine.mjs +458 -0
- package/reputation/math.mjs +73 -0
- package/reputation/pick_score.mjs +11 -0
- package/reputation/relay_dedupe.mjs +22 -0
- package/reputation/tunables.json +36 -0
- package/rooms/scoped_link.mjs +185 -0
- package/schemas/discovery.mjs +110 -0
- package/schemas/federation_pull.mjs +102 -0
- package/schemas/mailbox.mjs +37 -0
- package/schemas/remote_event.mjs +17 -0
- package/timeline/append_core.mjs +54 -0
- package/timeline/materialize_runner.mjs +26 -0
- package/timeline/prune.mjs +39 -0
- package/timeline/retention.mjs +54 -0
- package/timeline/retention_runner.mjs +35 -0
- package/timeline/verify_remote.mjs +25 -0
- package/transport/group_link_set.mjs +324 -0
- package/transport/ice_servers.mjs +72 -0
- package/transport/link_registry.mjs +749 -0
- package/transport/peer_identity_maps.mjs +94 -0
- package/transport/peer_pool.mjs +355 -0
- package/transport/remote_user_room.mjs +109 -0
- package/transport/room_scopes.mjs +2 -0
- package/transport/room_wire_action.mjs +19 -0
- package/transport/rtc_connection_budget.mjs +180 -0
- package/transport/rtc_mdns_filter.mjs +118 -0
- package/transport/stale_peer_log.mjs +50 -0
- package/transport/user_room.mjs +263 -0
- package/trust_graph/build.mjs +99 -0
- package/trust_graph/cache.mjs +44 -0
- package/trust_graph/engine.mjs +184 -0
- package/trust_graph/registry.mjs +38 -0
- package/trust_graph/resolve.mjs +105 -0
- package/trust_graph/send.mjs +88 -0
- package/trust_graph/tunables.json +13 -0
- package/utils/async_mutex.mjs +69 -0
- package/utils/debug_log.mjs +16 -0
- package/utils/duration.mjs +28 -0
- package/utils/json_io.mjs +57 -0
- package/utils/lru.mjs +22 -0
- package/utils/map_pool.mjs +27 -0
- package/wire/group_part.mjs +43 -0
- package/wire/ingress.mjs +58 -0
- package/wire/part_common.mjs +55 -0
- package/wire/part_fanout.mjs +71 -0
- package/wire/part_ingress.mjs +172 -0
- package/wire/part_invoke.mjs +81 -0
- package/wire/rate_bucket.mjs +49 -0
- package/wire/volatile_signature.mjs +76 -0
- package/wire/wait.mjs +127 -0
package/dag/storage.mjs
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { createReadStream, createWriteStream } from 'node:fs'
|
|
4
|
+
import { appendFile, mkdir, open, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { dirname } from 'node:path'
|
|
6
|
+
import { createInterface } from 'node:readline'
|
|
7
|
+
import { Readable } from 'node:stream'
|
|
8
|
+
import { pipeline } from 'node:stream/promises'
|
|
9
|
+
import { setTimeout as sleep } from 'node:timers/promises'
|
|
10
|
+
|
|
11
|
+
import { withAsyncMutex } from '../utils/async_mutex.mjs'
|
|
12
|
+
|
|
13
|
+
/** 流式重写 JSONL 时分块写入的行数上限 */
|
|
14
|
+
const WRITE_JSONL_CHUNK_LINES = 1000
|
|
15
|
+
/** Windows 上 rename 可能被短暂占用,做几次短退避重试。 */
|
|
16
|
+
const ATOMIC_RENAME_RETRY_DELAYS_MS = [0, 10, 25, 50, 100]
|
|
17
|
+
const ATOMIC_RENAME_RETRY_CODES = new Set(['EPERM', 'EBUSY', 'EACCES'])
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} filePath 目标路径
|
|
21
|
+
* @returns {string} 唯一临时文件路径
|
|
22
|
+
*/
|
|
23
|
+
function atomicTmpPath(filePath) {
|
|
24
|
+
return `${filePath}.tmp.${process.pid}.${randomUUID()}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} tmp 临时文件路径
|
|
29
|
+
* @returns {Promise<void>}
|
|
30
|
+
*/
|
|
31
|
+
async function cleanupAtomicTmp(tmp) {
|
|
32
|
+
try { await unlink(tmp) } catch { /* ok */ }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 完成原子写的最终 rename;若目标目录已在 cleanup 中消失,则清理残余 tmp 后静默返回。
|
|
37
|
+
* @param {string} tmp 临时文件路径
|
|
38
|
+
* @param {string} filePath 最终目标路径
|
|
39
|
+
* @returns {Promise<boolean>} 是否已成功落到目标路径
|
|
40
|
+
*/
|
|
41
|
+
export async function finalizeAtomicRename(tmp, filePath) {
|
|
42
|
+
/** @type {NodeJS.ErrnoException | undefined} */
|
|
43
|
+
let lastError
|
|
44
|
+
for (const delayMs of ATOMIC_RENAME_RETRY_DELAYS_MS) {
|
|
45
|
+
if (delayMs) await sleep(delayMs)
|
|
46
|
+
try {
|
|
47
|
+
await rename(tmp, filePath)
|
|
48
|
+
return true
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
lastError = err
|
|
52
|
+
if (ATOMIC_RENAME_RETRY_CODES.has(err?.code)) continue
|
|
53
|
+
break
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
await cleanupAtomicTmp(tmp)
|
|
57
|
+
if (lastError?.code === 'ENOENT') return false
|
|
58
|
+
throw lastError
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string} line JSONL 单行
|
|
63
|
+
* @param {(row: object) => object} sanitize 行净化
|
|
64
|
+
* @returns {object | null} 解析后的对象;坏行/空行返回 null
|
|
65
|
+
*/
|
|
66
|
+
function parseJsonlLine(line, sanitize) {
|
|
67
|
+
const trimmed = String(line).trim()
|
|
68
|
+
if (!trimmed) return null
|
|
69
|
+
try {
|
|
70
|
+
return sanitize(JSON.parse(trimmed))
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 读取 JSONL 文件并解析为对象数组;缺失或读失败时返回空数组。
|
|
79
|
+
* @param {string} filePath 文件系统路径
|
|
80
|
+
* @param {{ sanitize?: (row: object) => object }} [options] 可选净化函数
|
|
81
|
+
* @returns {Promise<object[]>} 各行解析后的对象列表
|
|
82
|
+
*/
|
|
83
|
+
export async function readJsonl(filePath, options = {}) {
|
|
84
|
+
try {
|
|
85
|
+
const text = await readFile(filePath, 'utf8')
|
|
86
|
+
const sanitize = options.sanitize ?? (row => row)
|
|
87
|
+
/** @type {object[]} */
|
|
88
|
+
const rows = []
|
|
89
|
+
for (const line of text.split('\n')) {
|
|
90
|
+
const row = parseJsonlLine(line, sanitize)
|
|
91
|
+
if (row) rows.push(row)
|
|
92
|
+
}
|
|
93
|
+
return rows
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return []
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 流式读取 JSONL(避免整文件读入内存)。文件缺失(ENOENT)视为空流,
|
|
102
|
+
* 兼容 cleanup 竞态:群目录被删后台仍在尾巴上读它。
|
|
103
|
+
* @param {string} filePath 文件路径
|
|
104
|
+
* @param {{ sanitize?: (row: object) => object }} [options] 行净化
|
|
105
|
+
* @returns {AsyncGenerator<object>} 逐行事件
|
|
106
|
+
*/
|
|
107
|
+
export async function* readJsonlStream(filePath, options = {}) {
|
|
108
|
+
const sanitize = options.sanitize ?? (row => row)
|
|
109
|
+
const input = createReadStream(filePath, { encoding: 'utf8' })
|
|
110
|
+
// stream 内部异步 open 失败会触发 'error' 事件;提前订阅避免 unhandled error,
|
|
111
|
+
// 真实错误仍由下方 for-await 抛出,被外层 try/catch 收口。
|
|
112
|
+
input.on('error', () => { })
|
|
113
|
+
const lines = createInterface({ input, crlfDelay: Infinity })
|
|
114
|
+
try {
|
|
115
|
+
for await (const line of lines) {
|
|
116
|
+
const row = parseJsonlLine(line, sanitize)
|
|
117
|
+
if (row) yield row
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (error?.code !== 'ENOENT') throw error
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 流式过滤重写 JSONL:保留 `keep(row)===true` 的行。
|
|
127
|
+
* @param {string} filePath 目标路径
|
|
128
|
+
* @param {(row: object) => boolean} keep 保留谓词
|
|
129
|
+
* @param {{ sanitize?: (row: object) => object }} [options] 读行净化
|
|
130
|
+
* @returns {Promise<{ kept: number, dropped: number }>} 统计
|
|
131
|
+
*/
|
|
132
|
+
export async function rewriteJsonlKeeping(filePath, keep, options = {}) {
|
|
133
|
+
const dir = dirname(filePath)
|
|
134
|
+
await mkdir(dir, { recursive: true })
|
|
135
|
+
const tmp = atomicTmpPath(filePath)
|
|
136
|
+
/** @type {object[]} */
|
|
137
|
+
const buffer = []
|
|
138
|
+
let kept = 0
|
|
139
|
+
let dropped = 0
|
|
140
|
+
/** @returns {Promise<void>} */
|
|
141
|
+
const flush = async () => {
|
|
142
|
+
if (!buffer.length) return
|
|
143
|
+
let block = ''
|
|
144
|
+
for (const row of buffer)
|
|
145
|
+
block += `${JSON.stringify(row)}\n`
|
|
146
|
+
await appendFile(tmp, block, 'utf8')
|
|
147
|
+
buffer.length = 0
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
for await (const row of readJsonlStream(filePath, options))
|
|
151
|
+
if (keep(row)) {
|
|
152
|
+
buffer.push(row)
|
|
153
|
+
kept++
|
|
154
|
+
if (buffer.length >= WRITE_JSONL_CHUNK_LINES)
|
|
155
|
+
await flush()
|
|
156
|
+
}
|
|
157
|
+
else dropped++
|
|
158
|
+
await flush()
|
|
159
|
+
}
|
|
160
|
+
catch { /* source missing */ }
|
|
161
|
+
if (kept > 0 || dropped > 0)
|
|
162
|
+
await finalizeAtomicRename(tmp, filePath)
|
|
163
|
+
else
|
|
164
|
+
try { await writeFile(filePath, '', 'utf8') }
|
|
165
|
+
catch { /* ok */ }
|
|
166
|
+
|
|
167
|
+
return { kept, dropped }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 读取 JSONL 末行事件的 `id`(DAG tip);空文件为 null。
|
|
172
|
+
* @param {string} filePath 文件路径
|
|
173
|
+
* @returns {Promise<string | null>} tip event id
|
|
174
|
+
*/
|
|
175
|
+
export async function readJsonlTipId(filePath) {
|
|
176
|
+
try {
|
|
177
|
+
const fh = await open(filePath, 'r')
|
|
178
|
+
try {
|
|
179
|
+
const { size } = await fh.stat()
|
|
180
|
+
if (!size) return null
|
|
181
|
+
const chunk = Math.min(size, 65_536)
|
|
182
|
+
const buf = Buffer.alloc(chunk)
|
|
183
|
+
await fh.read(buf, 0, chunk, size - chunk)
|
|
184
|
+
const lines = buf.toString('utf8').split('\n').filter(Boolean)
|
|
185
|
+
const last = lines[lines.length - 1]
|
|
186
|
+
if (!last) return null
|
|
187
|
+
const row = JSON.parse(last)
|
|
188
|
+
return row?.id != null ? String(row.id) : null
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
await fh.close()
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 将单个 JSON 对象作为一行追加写入 JSONL(必要时创建父目录)。
|
|
201
|
+
* @param {string} filePath 目标文件路径
|
|
202
|
+
* @param {object} record 要序列化写入的对象
|
|
203
|
+
* @returns {Promise<void>}
|
|
204
|
+
*/
|
|
205
|
+
export async function appendJsonl(filePath, record) {
|
|
206
|
+
await mkdir(dirname(filePath), { recursive: true })
|
|
207
|
+
await appendFile(filePath, `${JSON.stringify(record)}\n`, 'utf8')
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 流式重写 JSONL(临时文件 + rename),避免大数组 join 的内存峰值。
|
|
212
|
+
* @param {string} filePath 目标路径
|
|
213
|
+
* @param {object[]} records 行对象列表
|
|
214
|
+
* @returns {Promise<void>}
|
|
215
|
+
*/
|
|
216
|
+
export async function writeJsonl(filePath, records) {
|
|
217
|
+
const dir = dirname(filePath)
|
|
218
|
+
await mkdir(dir, { recursive: true })
|
|
219
|
+
const tmp = atomicTmpPath(filePath)
|
|
220
|
+
/** @returns {Generator<string>} JSONL 行 */
|
|
221
|
+
function* lines() {
|
|
222
|
+
for (const rec of records)
|
|
223
|
+
yield `${JSON.stringify(rec)}\n`
|
|
224
|
+
}
|
|
225
|
+
await pipeline(Readable.from(lines()), createWriteStream(tmp, { encoding: 'utf8' }))
|
|
226
|
+
await finalizeAtomicRename(tmp, filePath)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @param {string} filePath JSONL 路径
|
|
231
|
+
* @returns {string} 进程内互斥键
|
|
232
|
+
*/
|
|
233
|
+
export function jsonlMutexKey(filePath) {
|
|
234
|
+
return `jsonl:${filePath}`
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* 在 per-file 互斥锁内流式重写 JSONL(Social / Mailbox 等非 Chat 群锁域)。
|
|
239
|
+
* @param {string} filePath 目标路径
|
|
240
|
+
* @param {object[]} records 行对象列表
|
|
241
|
+
* @returns {Promise<void>}
|
|
242
|
+
*/
|
|
243
|
+
export async function writeJsonlSynced(filePath, records) {
|
|
244
|
+
return withAsyncMutex(jsonlMutexKey(filePath), () => writeJsonl(filePath, records))
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* 在 per-file 互斥锁内过滤重写 JSONL。
|
|
249
|
+
* @param {string} filePath 目标路径
|
|
250
|
+
* @param {(row: object) => boolean} keep 保留谓词
|
|
251
|
+
* @param {{ sanitize?: (row: object) => object }} [options] 读行净化
|
|
252
|
+
* @returns {Promise<{ kept: number, dropped: number }>} 统计
|
|
253
|
+
*/
|
|
254
|
+
export async function rewriteJsonlKeepingSynced(filePath, keep, options = {}) {
|
|
255
|
+
return withAsyncMutex(jsonlMutexKey(filePath), () => rewriteJsonlKeeping(filePath, keep, options))
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 追加一行 JSONL 并 `fsync`。
|
|
260
|
+
* @param {string} filePath 目标路径
|
|
261
|
+
* @param {object} record 记录对象
|
|
262
|
+
* @returns {Promise<void>}
|
|
263
|
+
*/
|
|
264
|
+
export async function appendJsonlSynced(filePath, record) {
|
|
265
|
+
await mkdir(dirname(filePath), { recursive: true })
|
|
266
|
+
const fh = await open(filePath, 'a')
|
|
267
|
+
try {
|
|
268
|
+
await fh.appendFile(`${JSON.stringify(record)}\n`, 'utf8')
|
|
269
|
+
await fh.sync()
|
|
270
|
+
}
|
|
271
|
+
finally {
|
|
272
|
+
await fh.close()
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* 写入原子临时文件;若父目录已在 cleanup 竞态中消失(ENOENT)则返回 false。
|
|
278
|
+
* @param {string} tmp 临时文件路径
|
|
279
|
+
* @param {string} data 文件内容
|
|
280
|
+
* @returns {Promise<boolean>} 是否已写入
|
|
281
|
+
*/
|
|
282
|
+
async function writeAtomicTmp(tmp, data) {
|
|
283
|
+
try {
|
|
284
|
+
await writeFile(tmp, data, 'utf8')
|
|
285
|
+
return true
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
if (err?.code === 'ENOENT') return false
|
|
289
|
+
throw err
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* 原子写入 JSON 文件(临时文件 + rename)。
|
|
295
|
+
* @param {string} filePath 目标路径
|
|
296
|
+
* @param {object} obj 可 JSON 序列化对象
|
|
297
|
+
* @returns {Promise<void>}
|
|
298
|
+
*/
|
|
299
|
+
export async function writeJsonAtomic(filePath, obj) {
|
|
300
|
+
const dir = dirname(filePath)
|
|
301
|
+
await mkdir(dir, { recursive: true })
|
|
302
|
+
const tmp = atomicTmpPath(filePath)
|
|
303
|
+
if (!await writeAtomicTmp(tmp, JSON.stringify(obj, null, '\t'))) return
|
|
304
|
+
await finalizeAtomicRename(tmp, filePath)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* 原子写入 JSON 并对目标文件 `fsync`。
|
|
309
|
+
* @param {string} filePath 目标路径
|
|
310
|
+
* @param {object} obj 可序列化对象
|
|
311
|
+
* @returns {Promise<void>}
|
|
312
|
+
*/
|
|
313
|
+
export async function writeJsonAtomicSynced(filePath, obj) {
|
|
314
|
+
const dir = dirname(filePath)
|
|
315
|
+
await mkdir(dir, { recursive: true })
|
|
316
|
+
const tmp = atomicTmpPath(filePath)
|
|
317
|
+
if (!await writeAtomicTmp(tmp, JSON.stringify(obj, null, '\t'))) return
|
|
318
|
+
const fh = await open(tmp, 'r+')
|
|
319
|
+
try {
|
|
320
|
+
await fh.sync()
|
|
321
|
+
}
|
|
322
|
+
finally {
|
|
323
|
+
await fh.close()
|
|
324
|
+
}
|
|
325
|
+
if (!await finalizeAtomicRename(tmp, filePath)) return
|
|
326
|
+
const outFh = await open(filePath, 'r+')
|
|
327
|
+
try {
|
|
328
|
+
await outFh.sync()
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
await outFh.close()
|
|
332
|
+
}
|
|
333
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DAG 事件行读盘时剥离的本地扩展键(不得进入验签域 / 联邦 wire)。
|
|
3
|
+
*/
|
|
4
|
+
import { isPlainObject } from '../wire/ingress.mjs'
|
|
5
|
+
|
|
6
|
+
/** 落盘后 trusted 读路径仍须剥除的 sidecar 键。 */
|
|
7
|
+
export const DAG_EVENT_LOCAL_EXTENSION_KEYS = new Set(['receivedAt', 'isRemote'])
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {unknown} row JSONL 行
|
|
11
|
+
* @returns {object} 剥离扩展键后的副本
|
|
12
|
+
*/
|
|
13
|
+
export function stripDagEventLocalExtensions(row) {
|
|
14
|
+
if (!isPlainObject(row)) return row
|
|
15
|
+
const out = { ...row }
|
|
16
|
+
for (const key of DAG_EVENT_LOCAL_EXTENSION_KEYS) delete out[key]
|
|
17
|
+
return out
|
|
18
|
+
}
|
package/discovery/bt.mjs
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import process from 'node:process'
|
|
3
|
+
|
|
4
|
+
const BT_SERVICE_UUID = 'f017f017f017f017f017f017f017f017'
|
|
5
|
+
const BT_CHARACTERISTIC_UUID = 'f017f017f017f017f017f017f017f018'
|
|
6
|
+
const BT_DEVICE_NAME = 'fount-bt'
|
|
7
|
+
const MAX_ADVERT_BLOB_BYTES = 12 * 1024
|
|
8
|
+
const PERIPHERAL_RESCAN_MS = 15_000
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 解析 Bluetooth 发现角色(scan / dual)。
|
|
12
|
+
* @returns {'scan' | 'dual'} 当前平台或环境变量指定的角色
|
|
13
|
+
*/
|
|
14
|
+
function resolveBtRole() {
|
|
15
|
+
const override = String(process.env.FOUNT_BT_DISCOVERY_ROLE || '').trim().toLowerCase()
|
|
16
|
+
if (override === 'dual') return 'dual'
|
|
17
|
+
if (override === 'scan') return 'scan'
|
|
18
|
+
return process.platform === 'win32' ? 'scan' : 'dual'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 加载 Noble BLE central 库。
|
|
23
|
+
* @returns {Promise<any>} Noble 运行时实例
|
|
24
|
+
*/
|
|
25
|
+
async function loadNoble() {
|
|
26
|
+
const mod = await import('@stoprocent/noble')
|
|
27
|
+
if (typeof mod.withBindings === 'function') return mod.withBindings('default')
|
|
28
|
+
return mod.default ?? mod
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 加载 Bleno BLE peripheral 库。
|
|
33
|
+
* @returns {Promise<any>} Bleno 运行时实例
|
|
34
|
+
*/
|
|
35
|
+
async function loadBleno() {
|
|
36
|
+
const mod = await import('@stoprocent/bleno')
|
|
37
|
+
if (typeof mod.withBindings === 'function') return mod.withBindings('default')
|
|
38
|
+
return mod.default ?? mod
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 将 advert 映射序列化为可读 characteristic blob。
|
|
43
|
+
* @param {Map<string, Uint8Array>} adverts topic → payload 映射
|
|
44
|
+
* @returns {Buffer} JSON 序列化后的 advert blob
|
|
45
|
+
*/
|
|
46
|
+
function serializeAdvertBlob(adverts) {
|
|
47
|
+
const entries = [...adverts.entries()].map(([topic, bytes]) => ({
|
|
48
|
+
topic,
|
|
49
|
+
data: Buffer.from(bytes).toString('base64'),
|
|
50
|
+
}))
|
|
51
|
+
const blob = Buffer.from(JSON.stringify({ entries }), 'utf8')
|
|
52
|
+
if (blob.byteLength > MAX_ADVERT_BLOB_BYTES)
|
|
53
|
+
throw new Error(`p2p: bluetooth advert blob exceeds ${MAX_ADVERT_BLOB_BYTES} bytes`)
|
|
54
|
+
return blob
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 从 characteristic blob 解析 advert 列表。
|
|
59
|
+
* @param {Uint8Array | Buffer} raw 原始 blob 字节
|
|
60
|
+
* @returns {Array<{ topic: string, bytes: Uint8Array }>} 解析出的 advert 条目
|
|
61
|
+
*/
|
|
62
|
+
function parseAdvertBlob(raw) {
|
|
63
|
+
try {
|
|
64
|
+
const parsed = JSON.parse(Buffer.from(raw).toString('utf8'))
|
|
65
|
+
if (!Array.isArray(parsed?.entries)) return []
|
|
66
|
+
return parsed.entries.map(entry => ({
|
|
67
|
+
topic: String(entry?.topic || ''),
|
|
68
|
+
bytes: Uint8Array.from(Buffer.from(String(entry?.data || ''), 'base64')),
|
|
69
|
+
})).filter(entry => entry.topic && entry.bytes.byteLength)
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return []
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 向 topic bucket 注册监听器。
|
|
78
|
+
* @param {Map<string, Set<Function>>} bucket topic → 监听器集合
|
|
79
|
+
* @param {string} topic 订阅 topic
|
|
80
|
+
* @param {Function} listener advert 回调
|
|
81
|
+
* @returns {() => void} 取消订阅函数
|
|
82
|
+
*/
|
|
83
|
+
function addListener(bucket, topic, listener) {
|
|
84
|
+
if (!bucket.has(topic)) bucket.set(topic, new Set())
|
|
85
|
+
bucket.get(topic).add(listener)
|
|
86
|
+
return () => {
|
|
87
|
+
const set = bucket.get(topic)
|
|
88
|
+
if (!set) return
|
|
89
|
+
set.delete(listener)
|
|
90
|
+
if (!set.size) bucket.delete(topic)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 蓝牙发现提供者:
|
|
96
|
+
* - 默认在 Windows 上只启用 scan 侧发现(单适配器 central+peripheral 常冲突)
|
|
97
|
+
* - 其他平台默认 dual:advertise + scan
|
|
98
|
+
* - 通过固定 BLE service + read characteristic 传输完整 advert 列表,避免 31-byte 广告包限制
|
|
99
|
+
*
|
|
100
|
+
* @returns {import('./index.mjs').DiscoveryProvider} Bluetooth 发现提供者
|
|
101
|
+
*/
|
|
102
|
+
export function createBluetoothDiscoveryProvider() {
|
|
103
|
+
const role = resolveBtRole()
|
|
104
|
+
/** @type {Map<string, Uint8Array>} */
|
|
105
|
+
const adverts = new Map()
|
|
106
|
+
/** @type {Map<string, Set<Function>>} */
|
|
107
|
+
const advertListeners = new Map()
|
|
108
|
+
/** @type {Map<string, number>} */
|
|
109
|
+
const inspectedAt = new Map()
|
|
110
|
+
let nobleRuntime = null
|
|
111
|
+
let blenoRuntime = null
|
|
112
|
+
let scanningStarted = false
|
|
113
|
+
let advertisingStarted = false
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 初始化 peripheral(Bleno)运行时。
|
|
117
|
+
* @returns {Promise<any|null>} Bleno 实例;scan 模式下为 null
|
|
118
|
+
*/
|
|
119
|
+
async function ensurePeripheralRuntime() {
|
|
120
|
+
if (role === 'scan') return null
|
|
121
|
+
if (blenoRuntime) return blenoRuntime
|
|
122
|
+
const bleno = await loadBleno()
|
|
123
|
+
const characteristic = new bleno.Characteristic({
|
|
124
|
+
uuid: BT_CHARACTERISTIC_UUID,
|
|
125
|
+
properties: ['read'],
|
|
126
|
+
/**
|
|
127
|
+
* BLE characteristic 读请求回调。
|
|
128
|
+
* @param {*} _handle Bleno handle(未使用)
|
|
129
|
+
* @param {number} offset 读取偏移
|
|
130
|
+
* @param {Function} callback Bleno 结果回调
|
|
131
|
+
* @returns {void}
|
|
132
|
+
*/
|
|
133
|
+
onReadRequest(_handle, offset, callback) {
|
|
134
|
+
try {
|
|
135
|
+
const blob = serializeAdvertBlob(adverts)
|
|
136
|
+
if (offset > blob.length) {
|
|
137
|
+
callback(bleno.Characteristic.RESULT_INVALID_OFFSET)
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
callback(bleno.Characteristic.RESULT_SUCCESS, blob.subarray(offset))
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
callback(bleno.Characteristic.RESULT_UNLIKELY_ERROR)
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
})
|
|
147
|
+
await bleno.waitForPoweredOnAsync(5_000)
|
|
148
|
+
await bleno.setServicesAsync([
|
|
149
|
+
new bleno.PrimaryService({
|
|
150
|
+
uuid: BT_SERVICE_UUID,
|
|
151
|
+
characteristics: [characteristic],
|
|
152
|
+
}),
|
|
153
|
+
])
|
|
154
|
+
blenoRuntime = bleno
|
|
155
|
+
return bleno
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 刷新 BLE 广播状态。
|
|
160
|
+
* @returns {Promise<void>}
|
|
161
|
+
*/
|
|
162
|
+
async function refreshAdvertising() {
|
|
163
|
+
if (role === 'scan') return
|
|
164
|
+
const bleno = await ensurePeripheralRuntime()
|
|
165
|
+
if (!bleno) return
|
|
166
|
+
if (!adverts.size) {
|
|
167
|
+
if (advertisingStarted) {
|
|
168
|
+
await bleno.stopAdvertisingAsync().catch(() => { })
|
|
169
|
+
advertisingStarted = false
|
|
170
|
+
}
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
serializeAdvertBlob(adverts)
|
|
174
|
+
if (!advertisingStarted) {
|
|
175
|
+
await bleno.startAdvertisingAsync(BT_DEVICE_NAME, [BT_SERVICE_UUID])
|
|
176
|
+
advertisingStarted = true
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 连接并读取远端 peripheral 的 advert characteristic。
|
|
182
|
+
* @param {*} peripheral Noble peripheral 对象
|
|
183
|
+
* @returns {Promise<void>}
|
|
184
|
+
*/
|
|
185
|
+
async function inspectPeripheral(peripheral) {
|
|
186
|
+
const inspectKey = String(peripheral?.id || peripheral?.address || '')
|
|
187
|
+
if (!inspectKey) return
|
|
188
|
+
const lastSeenAt = inspectedAt.get(inspectKey) || 0
|
|
189
|
+
if (Date.now() - lastSeenAt < PERIPHERAL_RESCAN_MS) return
|
|
190
|
+
inspectedAt.set(inspectKey, Date.now())
|
|
191
|
+
try {
|
|
192
|
+
await peripheral.connectAsync()
|
|
193
|
+
const { characteristics } = await peripheral.discoverSomeServicesAndCharacteristicsAsync(
|
|
194
|
+
[BT_SERVICE_UUID],
|
|
195
|
+
[BT_CHARACTERISTIC_UUID],
|
|
196
|
+
)
|
|
197
|
+
if (!characteristics?.length) return
|
|
198
|
+
const raw = await characteristics[0].readAsync()
|
|
199
|
+
for (const { topic, bytes } of parseAdvertBlob(raw)) {
|
|
200
|
+
const listeners = advertListeners.get(topic)
|
|
201
|
+
if (!listeners?.size) continue
|
|
202
|
+
for (const listener of listeners)
|
|
203
|
+
listener(bytes, { provider: 'bt', peripheralId: inspectKey })
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
/* ignore transient bluetooth failures */
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
try { await peripheral.disconnectAsync() } catch { /* ignore */ }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 启动 Noble 扫描运行时。
|
|
216
|
+
* @returns {Promise<void>}
|
|
217
|
+
*/
|
|
218
|
+
async function ensureScanRuntime() {
|
|
219
|
+
if (scanningStarted) return
|
|
220
|
+
const noble = await loadNoble()
|
|
221
|
+
await noble.waitForPoweredOnAsync()
|
|
222
|
+
noble.on('discover', peripheral => {
|
|
223
|
+
void inspectPeripheral(peripheral).catch(() => { })
|
|
224
|
+
})
|
|
225
|
+
await noble.startScanningAsync([BT_SERVICE_UUID], true)
|
|
226
|
+
nobleRuntime = noble
|
|
227
|
+
scanningStarted = true
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
id: 'bt',
|
|
232
|
+
priority: 20,
|
|
233
|
+
caps: { canDiscover: true, canSignal: false, canRelay: false },
|
|
234
|
+
/**
|
|
235
|
+
* 广播指定 topic 的 advert。
|
|
236
|
+
* @param {string} topic advert 主题
|
|
237
|
+
* @param {Uint8Array} bytes advert 载荷
|
|
238
|
+
* @returns {Promise<() => void>} 取消广播函数
|
|
239
|
+
*/
|
|
240
|
+
async advertise(topic, bytes) {
|
|
241
|
+
if (role === 'scan') return () => { }
|
|
242
|
+
adverts.set(String(topic), Uint8Array.from(bytes))
|
|
243
|
+
await refreshAdvertising()
|
|
244
|
+
return () => {
|
|
245
|
+
adverts.delete(String(topic))
|
|
246
|
+
void refreshAdvertising().catch(() => { })
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
/**
|
|
250
|
+
* 订阅指定 topic 的远端 advert。
|
|
251
|
+
* @param {string} topic advert 主题
|
|
252
|
+
* @param {Function} onAdvert advert 回调
|
|
253
|
+
* @returns {Promise<() => void>} 取消订阅函数
|
|
254
|
+
*/
|
|
255
|
+
async subscribe(topic, onAdvert) {
|
|
256
|
+
await ensureScanRuntime()
|
|
257
|
+
return addListener(advertListeners, String(topic), onAdvert)
|
|
258
|
+
},
|
|
259
|
+
}
|
|
260
|
+
}
|