@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.
Files changed (154) hide show
  1. package/README.md +62 -0
  2. package/core/bytes_codec.mjs +51 -0
  3. package/core/canonical_json.mjs +21 -0
  4. package/core/composite_key.mjs +91 -0
  5. package/core/constants.mjs +53 -0
  6. package/core/entity_id.mjs +53 -0
  7. package/core/entity_id_parse.mjs +40 -0
  8. package/core/evfs_logical_path.mjs +15 -0
  9. package/core/hexIds.mjs +73 -0
  10. package/core/hlc.mjs +114 -0
  11. package/core/random_id.mjs +9 -0
  12. package/crypto/channel.mjs +101 -0
  13. package/crypto/checkpoint_sign.mjs +67 -0
  14. package/crypto/crypto.mjs +158 -0
  15. package/crypto/key.mjs +431 -0
  16. package/dag/canonicalize_row.mjs +65 -0
  17. package/dag/event_query.mjs +15 -0
  18. package/dag/index.mjs +335 -0
  19. package/dag/storage.mjs +333 -0
  20. package/dag/strip_extensions.mjs +18 -0
  21. package/dag/tunables.json +6 -0
  22. package/discovery/bt.mjs +260 -0
  23. package/discovery/index.mjs +134 -0
  24. package/discovery/mdns.mjs +138 -0
  25. package/discovery/nostr.mjs +273 -0
  26. package/entity/files/acl.mjs +38 -0
  27. package/entity/files/evfs.mjs +189 -0
  28. package/entity/files/evfs_ref.mjs +37 -0
  29. package/entity/files/manifest_acl_registry.mjs +93 -0
  30. package/entity/files/replica_host_cache.mjs +46 -0
  31. package/entity/files/url.mjs +19 -0
  32. package/entity/hosting_registry.mjs +47 -0
  33. package/entity/localized_core.mjs +141 -0
  34. package/entity/logical_entity.mjs +25 -0
  35. package/entity/logical_entity_id_registry.mjs +40 -0
  36. package/entity/node_hash.mjs +15 -0
  37. package/entity/presentation_registry.mjs +44 -0
  38. package/entity/profile.mjs +256 -0
  39. package/entity/replica.mjs +20 -0
  40. package/entity/session_snapshot_registry.mjs +38 -0
  41. package/federation/chunk_fetch_pending.mjs +128 -0
  42. package/federation/chunk_fetch_scheduler.mjs +97 -0
  43. package/federation/dag_order_cache.mjs +124 -0
  44. package/federation/dedupe_slot.mjs +33 -0
  45. package/federation/message_rate_limit.mjs +23 -0
  46. package/federation/operator_key_chain.mjs +200 -0
  47. package/federation/topo_order_memo.mjs +44 -0
  48. package/federation/volatile_streams.mjs +52 -0
  49. package/federation/want_ids.mjs +158 -0
  50. package/files/assemble.mjs +220 -0
  51. package/files/assemble_stream.mjs +129 -0
  52. package/files/chunk_fetch.mjs +113 -0
  53. package/files/chunk_fetch_verify.mjs +23 -0
  54. package/files/chunk_provider_registry.mjs +70 -0
  55. package/files/chunk_responder.mjs +79 -0
  56. package/files/chunk_store.mjs +87 -0
  57. package/files/manifest.mjs +99 -0
  58. package/files/transfer_key.mjs +87 -0
  59. package/files/transfer_key_registry.mjs +108 -0
  60. package/governance/branch.mjs +230 -0
  61. package/governance/join_pow.mjs +165 -0
  62. package/governance/owner_succession_ballot.mjs +57 -0
  63. package/governance/tunables.json +7 -0
  64. package/index.mjs +39 -0
  65. package/link/channel_mux.mjs +222 -0
  66. package/link/frame.mjs +246 -0
  67. package/link/handshake.mjs +175 -0
  68. package/link/link.mjs +617 -0
  69. package/link/rtc.mjs +109 -0
  70. package/link/sdp_fingerprint.mjs +25 -0
  71. package/mailbox/consumer_registry.mjs +71 -0
  72. package/mailbox/deliver_or_store.mjs +190 -0
  73. package/mailbox/importance.mjs +72 -0
  74. package/mailbox/parse.mjs +73 -0
  75. package/mailbox/prune.mjs +106 -0
  76. package/mailbox/rate.mjs +86 -0
  77. package/mailbox/settings.mjs +53 -0
  78. package/mailbox/store.mjs +256 -0
  79. package/mailbox/tunables.json +12 -0
  80. package/mailbox/wire.mjs +40 -0
  81. package/node/denylist.mjs +309 -0
  82. package/node/entity_store.mjs +214 -0
  83. package/node/identity.mjs +99 -0
  84. package/node/instance.mjs +115 -0
  85. package/node/network.mjs +306 -0
  86. package/node/personal_block.mjs +286 -0
  87. package/node/reputation_store.mjs +326 -0
  88. package/node/retention_policy.mjs +82 -0
  89. package/node/signaling_config.mjs +32 -0
  90. package/node/storage.mjs +30 -0
  91. package/node/storage_plugins.mjs +71 -0
  92. package/node/user_paths.mjs +10 -0
  93. package/overlay/index.mjs +191 -0
  94. package/package.json +73 -0
  95. package/permissions/bitmask.mjs +35 -0
  96. package/permissions/evaluator.mjs +90 -0
  97. package/permissions/index.mjs +12 -0
  98. package/permissions/layered.mjs +39 -0
  99. package/registries/action.mjs +94 -0
  100. package/registries/event_type.mjs +75 -0
  101. package/registries/inbound.mjs +90 -0
  102. package/registries/p2p_viewer.mjs +35 -0
  103. package/registries/part_path.mjs +35 -0
  104. package/registries/room_provider.mjs +47 -0
  105. package/reputation/engine.mjs +458 -0
  106. package/reputation/math.mjs +73 -0
  107. package/reputation/pick_score.mjs +11 -0
  108. package/reputation/relay_dedupe.mjs +22 -0
  109. package/reputation/tunables.json +36 -0
  110. package/rooms/scoped_link.mjs +185 -0
  111. package/schemas/discovery.mjs +110 -0
  112. package/schemas/federation_pull.mjs +102 -0
  113. package/schemas/mailbox.mjs +37 -0
  114. package/schemas/remote_event.mjs +17 -0
  115. package/timeline/append_core.mjs +54 -0
  116. package/timeline/materialize_runner.mjs +26 -0
  117. package/timeline/prune.mjs +39 -0
  118. package/timeline/retention.mjs +54 -0
  119. package/timeline/retention_runner.mjs +35 -0
  120. package/timeline/verify_remote.mjs +25 -0
  121. package/transport/group_link_set.mjs +324 -0
  122. package/transport/ice_servers.mjs +72 -0
  123. package/transport/link_registry.mjs +749 -0
  124. package/transport/peer_identity_maps.mjs +94 -0
  125. package/transport/peer_pool.mjs +355 -0
  126. package/transport/remote_user_room.mjs +109 -0
  127. package/transport/room_scopes.mjs +2 -0
  128. package/transport/room_wire_action.mjs +19 -0
  129. package/transport/rtc_connection_budget.mjs +180 -0
  130. package/transport/rtc_mdns_filter.mjs +118 -0
  131. package/transport/stale_peer_log.mjs +50 -0
  132. package/transport/user_room.mjs +263 -0
  133. package/trust_graph/build.mjs +99 -0
  134. package/trust_graph/cache.mjs +44 -0
  135. package/trust_graph/engine.mjs +184 -0
  136. package/trust_graph/registry.mjs +38 -0
  137. package/trust_graph/resolve.mjs +105 -0
  138. package/trust_graph/send.mjs +88 -0
  139. package/trust_graph/tunables.json +13 -0
  140. package/utils/async_mutex.mjs +69 -0
  141. package/utils/debug_log.mjs +16 -0
  142. package/utils/duration.mjs +28 -0
  143. package/utils/json_io.mjs +57 -0
  144. package/utils/lru.mjs +22 -0
  145. package/utils/map_pool.mjs +27 -0
  146. package/wire/group_part.mjs +43 -0
  147. package/wire/ingress.mjs +58 -0
  148. package/wire/part_common.mjs +55 -0
  149. package/wire/part_fanout.mjs +71 -0
  150. package/wire/part_ingress.mjs +172 -0
  151. package/wire/part_invoke.mjs +81 -0
  152. package/wire/rate_bucket.mjs +49 -0
  153. package/wire/volatile_signature.mjs +76 -0
  154. package/wire/wait.mjs +127 -0
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "archiveQuorumPeerMinFloor": 2,
3
+ "archiveQuorumPeerMinRatio": 0.25,
4
+ "archiveQuorumPeerStrictMinFloor": 2,
5
+ "archiveQuorumPeerStrictMinRatio": 0.5
6
+ }
@@ -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
+ }