@steve02081504/fount-p2p 0.0.10 → 0.0.12
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 +25 -7
- package/core/bytes_codec.mjs +65 -10
- package/core/tcp_port.mjs +1 -1
- package/crypto/checkpoint_sign.mjs +2 -2
- package/dag/canonicalize_row.mjs +3 -3
- package/dag/index.mjs +2 -3
- package/discovery/bt/index.mjs +5 -5
- package/discovery/bt/probe_child.mjs +20 -0
- package/discovery/bt/runtime.mjs +59 -15
- package/discovery/index.mjs +45 -48
- package/discovery/mdns.mjs +72 -17
- package/discovery/nostr.mjs +165 -129
- package/federation/chunk_fetch_pending.mjs +4 -5
- package/federation/dag_order_cache.mjs +1 -1
- package/federation/entity_key_chain.mjs +3 -4
- package/federation/topo_order_memo.mjs +11 -4
- package/files/chunk_fetch.mjs +2 -2
- package/files/evfs.mjs +2 -2
- package/link/channel_mux.mjs +10 -10
- package/link/frame.mjs +76 -124
- package/link/handshake.mjs +5 -5
- package/link/pipe.mjs +49 -75
- package/link/providers/ble_gatt.mjs +15 -27
- package/link/providers/index.mjs +7 -9
- package/link/providers/lan_tcp.mjs +18 -33
- package/link/providers/link_id_pipe.mjs +46 -0
- package/link/providers/webrtc.mjs +43 -52
- package/link/rtc.mjs +21 -9
- package/mailbox/deliver_or_store.mjs +1 -1
- package/node/identity.mjs +4 -4
- package/node/network.mjs +9 -9
- package/overlay/index.mjs +13 -13
- package/package.json +3 -2
- package/rooms/scoped_link.mjs +22 -38
- package/schemas/discovery.mjs +1 -2
- package/schemas/federation_pull.mjs +2 -2
- package/schemas/part_query.mjs +1 -1
- package/schemas/remote_event.mjs +1 -1
- package/transport/advert_ingest.mjs +20 -0
- package/transport/group_link_set.mjs +26 -45
- package/transport/ice_servers.mjs +12 -3
- package/transport/link_registry.mjs +181 -523
- package/transport/offer_answer.mjs +194 -0
- package/transport/peer_pool.mjs +53 -64
- package/transport/remote_user_room.mjs +13 -15
- package/transport/rtc_connection_budget.mjs +18 -4
- package/transport/runtime_bootstrap.mjs +369 -0
- package/transport/signal_crypto.mjs +104 -0
- package/transport/user_room.mjs +22 -15
- package/trust_graph/send.mjs +1 -1
- package/utils/emit_safe.mjs +11 -0
- package/utils/shuffle.mjs +13 -0
- package/utils/ttl_map.mjs +29 -4
- package/wire/ingress.mjs +1 -1
- package/wire/part_ingress.mjs +6 -8
- package/wire/part_invoke.mjs +4 -4
- package/wire/part_query.mjs +2 -3
- package/wire/part_query.tunables.json +6 -1
- package/wire/part_query_cache.mjs +1 -1
- package/wire/volatile_signature.mjs +1 -1
package/README.md
CHANGED
|
@@ -24,7 +24,9 @@ await startNode({ nodeDir: '/path/to/p2p/node' })
|
|
|
24
24
|
await ensureUserRoom()
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
Shells talk to the **fount network** (`ensureLinkToNode` / `sendToNodeLink` / rooms). Do not import `link/` or choose WebRTC / BLE / LAN yourself.
|
|
28
|
+
|
|
29
|
+
Subpath exports mirror source directories, e.g. `@steve02081504/fount-p2p/dag`, `@steve02081504/fount-p2p/transport/link_registry`, `@steve02081504/fount-p2p/transport/signal_crypto`, `@steve02081504/fount-p2p/registries/event_type`.
|
|
28
30
|
|
|
29
31
|
## Layout
|
|
30
32
|
|
|
@@ -33,31 +35,47 @@ Subpath exports mirror source directories, e.g. `@steve02081504/fount-p2p/dag`,
|
|
|
33
35
|
| L0 | `core/` | Pure primitives: `hexIds`, `entity_id*`, `canonical_json` |
|
|
34
36
|
| L1 | `crypto/`, `wire/`, `schemas/` | Cryptography, wire protocol, canonical validation |
|
|
35
37
|
| L2 | `node/` | Node runtime: `identity`, `entity_store`, `denylist`, `reputation_store` |
|
|
36
|
-
| L3 | `discovery/`, `link/`, `transport/`, `rooms/` | Discovery
|
|
37
|
-
| L4 | `trust_graph/`, `mailbox/`, `dag/`, `federation/`, `files/` | Federation, store-and-forward, DAG, EVFS |
|
|
38
|
+
| L3 | `discovery/`, `link/`, `transport/`, `rooms/` | Discovery + fount-network registry/rooms (`link/providers` are package-private) |
|
|
39
|
+
| L4 | `trust_graph/`, `mailbox/`, `dag/`, `federation/`, `files/`, `governance/`, `reputation/` | Federation, store-and-forward, DAG, EVFS, tunables |
|
|
38
40
|
| — | `registries/` | Pluggable registries (event type, part path, room provider, …) |
|
|
39
41
|
|
|
40
42
|
Facade entry: `index.mjs` (`startNode`, `createGroupLinkSet`, `registerDiscoveryProvider`, …).
|
|
41
43
|
|
|
42
|
-
|
|
44
|
+
**Transport modules** (exported under `./transport/*`):
|
|
45
|
+
|
|
46
|
+
| Module | Role |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `link_registry.mjs` | fount-network facade: dial fallback, scope/overlay |
|
|
49
|
+
| `runtime_bootstrap.mjs` | Progressive `ensureRuntime` (register + background listen / discovery / BT) |
|
|
50
|
+
| `offer_answer.mjs` | Discovery-signal glare for offer/answer providers |
|
|
51
|
+
| `signal_crypto.mjs` | Rendezvous topics + AES-GCM signal packets |
|
|
52
|
+
|
|
53
|
+
`ensureRuntime` returns after registration and scheduling warm-up; it does not await lan_tcp listen, public relays, or Bluetooth. See [docs/runtime.md](./docs/runtime.md) and [docs/transports.md](./docs/transports.md).
|
|
54
|
+
|
|
55
|
+
Root contains only the facade and package metadata; all modules live in layered subdirectories.
|
|
43
56
|
|
|
44
57
|
## Tests
|
|
45
58
|
|
|
46
59
|
```bash
|
|
47
60
|
npm test # package pure + integration (Node)
|
|
48
61
|
npm run test:fount # cross-repo bridge (Deno; requires fount on PATH)
|
|
49
|
-
npm run test:live #
|
|
62
|
+
npm run test:live # link / LAN / glare smoke
|
|
50
63
|
npm run test:sim # tunables co-evolution sim (dev only, not published; --social-tunables to write back)
|
|
51
64
|
```
|
|
52
65
|
|
|
53
66
|
During development: `node scripts/check-imports.mjs` validates relative imports. After a layout migration, `node scripts/cleanup-root-duplicates.mjs` removes stale root-level stubs.
|
|
54
67
|
|
|
68
|
+
Maintainer notes for agents / contributors: [AGENTS.md](./AGENTS.md). Sim harness fidelity: [sim/AGENTS.md](./sim/AGENTS.md).
|
|
69
|
+
|
|
55
70
|
## Optional dependencies
|
|
56
71
|
|
|
57
|
-
- `@stoprocent/noble` / `@stoprocent/bleno` — Bluetooth (optionalDependencies).
|
|
72
|
+
- `@stoprocent/noble` / `@stoprocent/bleno` — Bluetooth (optionalDependencies). Hardware probe is subprocess-only; see [docs/runtime.md](./docs/runtime.md).
|
|
73
|
+
- `node-datachannel` — WebRTC DataChannels (dependency).
|
|
74
|
+
- `ws` — Nostr discovery/signaling WebSockets (dependency).
|
|
58
75
|
|
|
59
76
|
Group chunk remote storage (S3, etc.) is implemented by the shell as `GroupStoragePlugin` and injected; see `node/storage_plugins.mjs` for the local reference implementation.
|
|
60
77
|
|
|
61
78
|
## Docs
|
|
62
79
|
|
|
63
|
-
-
|
|
80
|
+
- Transports and provider fallback: [`docs/transports.md`](./docs/transports.md)
|
|
81
|
+
- Signaling and WebRTC glare: [`docs/signaling.md`](./docs/signaling.md)
|
package/core/bytes_codec.mjs
CHANGED
|
@@ -2,23 +2,78 @@
|
|
|
2
2
|
* Base64 / hex / bytes 互转(无 Node 依赖,浏览器与 Node 共用)。
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
const HEX = '0123456789abcdef'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {number} code UTF-16 码元(期望为 0-9 / a-f / A-F)
|
|
9
|
+
* @returns {number} 0–15;非法为 -1
|
|
10
|
+
*/
|
|
11
|
+
function hexNibble(code) {
|
|
12
|
+
if (code >= 48 && code <= 57) return code - 48
|
|
13
|
+
if (code >= 97 && code <= 102) return code - 87
|
|
14
|
+
if (code >= 65 && code <= 70) return code - 55
|
|
15
|
+
return -1
|
|
16
|
+
}
|
|
17
|
+
|
|
5
18
|
/**
|
|
6
|
-
* @param {Uint8Array}
|
|
7
|
-
* @returns {string}
|
|
19
|
+
* @param {Uint8Array} bytes 原始字节
|
|
20
|
+
* @returns {string} 小写 hex
|
|
21
|
+
*/
|
|
22
|
+
export function bytesToHex(bytes) {
|
|
23
|
+
let out = ''
|
|
24
|
+
for (let index = 0; index < bytes.length; index++) {
|
|
25
|
+
const byte = bytes[index]
|
|
26
|
+
out += HEX[byte >> 4] + HEX[byte & 15]
|
|
27
|
+
}
|
|
28
|
+
return out
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} hex hex 文本(可含空白;大小写不敏感)
|
|
33
|
+
* @returns {Uint8Array} 解码后的字节
|
|
8
34
|
*/
|
|
9
|
-
export function
|
|
35
|
+
export function hexToBytes(hex) {
|
|
36
|
+
const text = hex.trim().toLowerCase()
|
|
37
|
+
if (text.length % 2) throw new Error('p2p: hex length must be even')
|
|
38
|
+
const out = new Uint8Array(text.length / 2)
|
|
39
|
+
for (let index = 0; index < out.length; index++) {
|
|
40
|
+
const high = hexNibble(text.charCodeAt(index * 2))
|
|
41
|
+
const low = hexNibble(text.charCodeAt(index * 2 + 1))
|
|
42
|
+
if (high < 0 || low < 0) throw new Error('p2p: invalid hex')
|
|
43
|
+
out[index] = (high << 4) | low
|
|
44
|
+
}
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* ArrayBuffer / TypedArray / Uint8Array → Uint8Array;`allowString` 时接受文本。
|
|
50
|
+
* @param {unknown} value 待转换值
|
|
51
|
+
* @param {{ allowString?: boolean }} [options] `allowString` 时把非字节输入当文本编码
|
|
52
|
+
* @returns {Uint8Array} 字节视图(可能与输入共享底层 buffer)
|
|
53
|
+
*/
|
|
54
|
+
export function toBytes(value, options = {}) {
|
|
55
|
+
if (value instanceof Uint8Array) return value
|
|
56
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value)
|
|
57
|
+
if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
|
|
58
|
+
if (options.allowString)
|
|
59
|
+
return new TextEncoder().encode(typeof value === 'string' ? value : String(value ?? ''))
|
|
60
|
+
throw new Error('p2p: bytes must be Uint8Array-compatible')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {Uint8Array} bytes 原始字节
|
|
65
|
+
* @returns {string} 标准 Base64
|
|
66
|
+
*/
|
|
67
|
+
export function bytesToBase64(bytes) {
|
|
10
68
|
let binary = ''
|
|
11
|
-
for (let index = 0; index <
|
|
69
|
+
for (let index = 0; index < bytes.length; index++) binary += String.fromCharCode(bytes[index])
|
|
12
70
|
return btoa(binary)
|
|
13
71
|
}
|
|
14
72
|
|
|
15
73
|
/**
|
|
16
|
-
* @param {string}
|
|
74
|
+
* @param {string} base64 标准 Base64
|
|
17
75
|
* @returns {Uint8Array} 解码后的字节
|
|
18
76
|
*/
|
|
19
|
-
export function
|
|
20
|
-
|
|
21
|
-
const out = new Uint8Array(bin.length)
|
|
22
|
-
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
|
|
23
|
-
return out
|
|
77
|
+
export function base64ToBytes(base64) {
|
|
78
|
+
return Uint8Array.from(atob(base64), char => char.charCodeAt(0))
|
|
24
79
|
}
|
package/core/tcp_port.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* @returns {number | null} 有效端口或 null(未提供 / 非法)
|
|
5
5
|
*/
|
|
6
6
|
export function normalizeTcpPort(port) {
|
|
7
|
-
if (port
|
|
7
|
+
if (!port) return null
|
|
8
8
|
const value = Number(port)
|
|
9
9
|
if (!Number.isInteger(value) || value < 1 || value > 65535) return null
|
|
10
10
|
return value
|
|
@@ -49,9 +49,9 @@ export function isSignedCheckpoint(checkpoint) {
|
|
|
49
49
|
* @returns {Promise<{ valid: boolean, reason?: string }>} 校验结果;`valid` 为 false 时 `reason` 说明原因
|
|
50
50
|
*/
|
|
51
51
|
export async function verifyRemoteCheckpoint(checkpoint) {
|
|
52
|
-
if (!checkpoint
|
|
52
|
+
if (!checkpoint)
|
|
53
53
|
return { valid: false, reason: 'checkpoint missing or not an object' }
|
|
54
|
-
if (!
|
|
54
|
+
if (!checkpoint.eventIdsInEpoch?.length)
|
|
55
55
|
return { valid: false, reason: 'eventIdsInEpoch missing or empty' }
|
|
56
56
|
const root = merkleRoot(checkpoint.eventIdsInEpoch)
|
|
57
57
|
if (checkpoint.epoch_root_hash !== root)
|
package/dag/canonicalize_row.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { assertHex64, HEX_ID_64, normalizeHex64 } from '../core/hexIds.mjs'
|
|
|
8
8
|
* @param {string} key 字段名
|
|
9
9
|
*/
|
|
10
10
|
function canonicalizeHexField(obj, key) {
|
|
11
|
-
if (obj[key]
|
|
11
|
+
if (!obj[key]) return
|
|
12
12
|
const normalized = normalizeHex64(obj[key])
|
|
13
13
|
if (!HEX_ID_64.test(normalized))
|
|
14
14
|
throw new Error(`${key} must be 64 hex characters`)
|
|
@@ -27,7 +27,7 @@ export function canonicalizeRowContent(content, hexKeys, entityHashKeys = new Se
|
|
|
27
27
|
for (const key of hexKeys)
|
|
28
28
|
canonicalizeHexField(out, key)
|
|
29
29
|
for (const key of entityHashKeys) {
|
|
30
|
-
if (out[key]
|
|
30
|
+
if (!out[key]) continue
|
|
31
31
|
const entityHash = String(out[key]).toLowerCase()
|
|
32
32
|
if (!isEntityHash128(entityHash))
|
|
33
33
|
throw new Error(`${key} must be 128 hex characters`)
|
|
@@ -54,7 +54,7 @@ export function canonicalizeSignedRow(event, options = {}) {
|
|
|
54
54
|
const out = options.prepare ? options.prepare({ ...event }) : { ...event }
|
|
55
55
|
out.id = assertHex64(out.id, 'id')
|
|
56
56
|
out.sender = assertHex64(out.sender, 'sender')
|
|
57
|
-
if (
|
|
57
|
+
if (out.prev_event_ids)
|
|
58
58
|
out.prev_event_ids = out.prev_event_ids.map((id, index) =>
|
|
59
59
|
assertHex64(id, `prev_event_ids[${index}]`),
|
|
60
60
|
)
|
package/dag/index.mjs
CHANGED
|
@@ -51,8 +51,7 @@ export function computeLocalTipsHash(tipIds) {
|
|
|
51
51
|
* @returns {string[]} 去重并字典序排序后的父事件 id 数组
|
|
52
52
|
*/
|
|
53
53
|
export function sortedPrevEventIds(raw) {
|
|
54
|
-
|
|
55
|
-
return [...new Set(raw.filter(id => EVENT_ID_HEX.test(String(id))))].sort()
|
|
54
|
+
return [...new Set((raw || []).filter(id => EVENT_ID_HEX.test(id)))].sort()
|
|
56
55
|
}
|
|
57
56
|
|
|
58
57
|
/**
|
|
@@ -226,7 +225,7 @@ export function topologicalCanonicalOrder(metas) {
|
|
|
226
225
|
|
|
227
226
|
while (ready.size) {
|
|
228
227
|
const next = ready.pop()
|
|
229
|
-
if (next
|
|
228
|
+
if (!next) break
|
|
230
229
|
ordered.push(next)
|
|
231
230
|
for (const childId of children.get(next) || []) {
|
|
232
231
|
const remaining = (parentCount.get(childId) || 0) - 1
|
package/discovery/bt/index.mjs
CHANGED
|
@@ -46,10 +46,10 @@ function serializeAdvertBlob(adverts) {
|
|
|
46
46
|
function parseAdvertBlob(raw) {
|
|
47
47
|
try {
|
|
48
48
|
const parsed = JSON.parse(Buffer.from(raw).toString('utf8'))
|
|
49
|
-
if (!
|
|
49
|
+
if (!parsed?.entries?.length) return []
|
|
50
50
|
return parsed.entries.map(entry => ({
|
|
51
|
-
topic:
|
|
52
|
-
bytes: Uint8Array.from(Buffer.from(
|
|
51
|
+
topic: entry.topic || '',
|
|
52
|
+
bytes: Uint8Array.from(Buffer.from(entry.data || '', 'base64')),
|
|
53
53
|
})).filter(entry => entry.topic && entry.bytes.byteLength)
|
|
54
54
|
}
|
|
55
55
|
catch {
|
|
@@ -148,10 +148,10 @@ export function createBluetoothDiscoveryProvider() {
|
|
|
148
148
|
const parsed = JSON.parse(Buffer.from(data).toString('utf8'))
|
|
149
149
|
const topic = String(parsed?.topic || '')
|
|
150
150
|
const bytes = Uint8Array.from(Buffer.from(String(parsed?.data || ''), 'base64'))
|
|
151
|
-
if (topic && bytes.byteLength)
|
|
151
|
+
if (topic && bytes.byteLength)
|
|
152
152
|
for (const listener of signalListeners.get(topic) || [])
|
|
153
153
|
listener(bytes, { provider: 'bt' })
|
|
154
|
-
|
|
154
|
+
|
|
155
155
|
callback(bleno.Characteristic.RESULT_SUCCESS)
|
|
156
156
|
}
|
|
157
157
|
catch {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 子进程 BT 可用性探测:load → poweredOn → stop → exit。
|
|
3
|
+
* 由 `canUseBluetoothRuntime` spawn;勿在父进程直接跑 waitPoweredOn(会拖住事件循环)。
|
|
4
|
+
*/
|
|
5
|
+
import process from 'node:process'
|
|
6
|
+
|
|
7
|
+
import { loadNoble, waitPoweredOn } from './runtime.mjs'
|
|
8
|
+
|
|
9
|
+
const timeoutMs = Number(process.env.FOUNT_BT_PROBE_MS || 3000)
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const noble = await loadNoble()
|
|
13
|
+
if (!noble?.startScanningAsync) process.exit(2)
|
|
14
|
+
await waitPoweredOn(noble, timeoutMs)
|
|
15
|
+
try { noble.stop() } catch { /* Windows 上 stop 偶发崩;子进程反正要退出 */ }
|
|
16
|
+
process.exit(0)
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
process.exit(1)
|
|
20
|
+
}
|
package/discovery/bt/runtime.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
1
2
|
import { existsSync, readdirSync } from 'node:fs'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
2
4
|
import process from 'node:process'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
3
6
|
|
|
4
7
|
/**
|
|
5
8
|
* 解析 Bluetooth 角色(scan / dual)。
|
|
@@ -31,10 +34,55 @@ export function probeBluetoothHardware() {
|
|
|
31
34
|
|
|
32
35
|
/** @type {boolean | null} canUseBluetoothRuntime 缓存 */
|
|
33
36
|
let cachedRuntimeOk = null
|
|
37
|
+
/** @type {Promise<boolean> | null} 并发 canUse 合并为一次子进程探测 */
|
|
38
|
+
let probeInflight = null
|
|
39
|
+
|
|
40
|
+
const PROBE_CHILD = join(dirname(fileURLToPath(import.meta.url)), 'probe_child.mjs')
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 在子进程中探测 BT(父进程 waitPoweredOn 会拖住事件循环;stop() 还可能 AV)。
|
|
44
|
+
* @param {number} timeoutMs waitPoweredOn 超时
|
|
45
|
+
* @returns {Promise<boolean>} 子进程 exit 0 为可用
|
|
46
|
+
*/
|
|
47
|
+
function probeBluetoothInSubprocess(timeoutMs) {
|
|
48
|
+
return new Promise(resolve => {
|
|
49
|
+
const child = spawn(process.execPath, [PROBE_CHILD], {
|
|
50
|
+
stdio: 'ignore',
|
|
51
|
+
env: {
|
|
52
|
+
...process.env,
|
|
53
|
+
FOUNT_BT_PROBE_MS: String(timeoutMs),
|
|
54
|
+
},
|
|
55
|
+
windowsHide: true,
|
|
56
|
+
})
|
|
57
|
+
// 探测是旁路缓存填充;勿拖住父进程事件循环 / shutdown 退出。
|
|
58
|
+
child.unref()
|
|
59
|
+
let settled = false
|
|
60
|
+
/**
|
|
61
|
+
* @param {boolean} ok 探测结果
|
|
62
|
+
* @returns {void}
|
|
63
|
+
*/
|
|
64
|
+
const finish = ok => {
|
|
65
|
+
if (settled) return
|
|
66
|
+
settled = true
|
|
67
|
+
clearTimeout(timer)
|
|
68
|
+
resolve(ok)
|
|
69
|
+
}
|
|
70
|
+
const timer = setTimeout(() => {
|
|
71
|
+
child.kill('SIGKILL')
|
|
72
|
+
finish(false)
|
|
73
|
+
}, timeoutMs + 5_000)
|
|
74
|
+
timer.unref()
|
|
75
|
+
child.once('error', () => finish(false))
|
|
76
|
+
child.once('exit', (code, signal) => {
|
|
77
|
+
if (signal) finish(false)
|
|
78
|
+
else finish(code === 0)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
}
|
|
34
82
|
|
|
35
83
|
/**
|
|
36
|
-
* 探测 BT
|
|
37
|
-
* 任一步失败返回 false
|
|
84
|
+
* 探测 BT 栈是否真正可用(硬件迹象 → 子进程 load → poweredOn)。
|
|
85
|
+
* 任一步失败返回 false;不抛错。
|
|
38
86
|
* @param {number} [timeoutMs=3000] waitPoweredOn 超时
|
|
39
87
|
* @returns {Promise<boolean>} 可用为 true
|
|
40
88
|
*/
|
|
@@ -44,19 +92,15 @@ export async function canUseBluetoothRuntime(timeoutMs = 3000) {
|
|
|
44
92
|
cachedRuntimeOk = false
|
|
45
93
|
return false
|
|
46
94
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
catch {
|
|
57
|
-
cachedRuntimeOk = false
|
|
58
|
-
}
|
|
59
|
-
return cachedRuntimeOk
|
|
95
|
+
if (!probeInflight)
|
|
96
|
+
probeInflight = probeBluetoothInSubprocess(timeoutMs)
|
|
97
|
+
.then(ok => {
|
|
98
|
+
cachedRuntimeOk = ok
|
|
99
|
+
return ok
|
|
100
|
+
})
|
|
101
|
+
.finally(() => { probeInflight = null })
|
|
102
|
+
|
|
103
|
+
return probeInflight
|
|
60
104
|
}
|
|
61
105
|
|
|
62
106
|
/**
|
package/discovery/index.mjs
CHANGED
|
@@ -23,6 +23,14 @@ export function unregisterDiscoveryProvider(id) {
|
|
|
23
23
|
providers.delete(String(id))
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* 清空全部 discovery provider。
|
|
28
|
+
* @returns {void}
|
|
29
|
+
*/
|
|
30
|
+
export function clearDiscoveryProviders() {
|
|
31
|
+
providers.clear()
|
|
32
|
+
}
|
|
33
|
+
|
|
26
34
|
/**
|
|
27
35
|
* 列出已注册的 discovery provider(按 priority 排序)。
|
|
28
36
|
* @returns {DiscoveryProvider[]} 提供者列表
|
|
@@ -32,29 +40,40 @@ export function listDiscoveryProviders() {
|
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* @param {
|
|
38
|
-
* @param {Uint8Array} bytes advert 载荷
|
|
43
|
+
* 并行启动各 provider 的 async 钩子;单路失败静默。
|
|
44
|
+
* @param {DiscoveryProvider[]} list 提供者列表
|
|
45
|
+
* @param {(provider: DiscoveryProvider) => Promise<(() => void) | null | undefined>} run 单路启动
|
|
39
46
|
* @returns {Promise<() => void>} 统一取消函数
|
|
40
47
|
*/
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
for (const provider of listDiscoveryProviders()) {
|
|
44
|
-
if (!provider.caps?.canDiscover || typeof provider.advertise !== 'function') continue
|
|
45
|
-
let cleanup = null
|
|
48
|
+
async function startProvidersParallel(list, run) {
|
|
49
|
+
const settled = await Promise.all(list.map(async provider => {
|
|
46
50
|
try {
|
|
47
|
-
cleanup = await provider
|
|
51
|
+
const cleanup = await run(provider)
|
|
52
|
+
return typeof cleanup === 'function' ? cleanup : null
|
|
48
53
|
}
|
|
49
|
-
catch {
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
catch {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
}))
|
|
58
|
+
const cleanups = settled.filter(Boolean)
|
|
52
59
|
return () => {
|
|
53
60
|
for (const cleanup of cleanups)
|
|
54
61
|
try { cleanup() } catch { /* ignore */ }
|
|
55
62
|
}
|
|
56
63
|
}
|
|
57
64
|
|
|
65
|
+
/**
|
|
66
|
+
* 通过所有可用 provider 广播 topic advert。
|
|
67
|
+
* 单路失败静默(正常降级);其它 provider 仍可成功。
|
|
68
|
+
* @param {string} topic advert 主题
|
|
69
|
+
* @param {Uint8Array} bytes advert 载荷
|
|
70
|
+
* @returns {Promise<() => void>} 统一取消函数
|
|
71
|
+
*/
|
|
72
|
+
export async function advertiseTopic(topic, bytes) {
|
|
73
|
+
const list = listDiscoveryProviders().filter(provider => provider.caps?.canDiscover && typeof provider.advertise === 'function')
|
|
74
|
+
return startProvidersParallel(list, provider => provider.advertise(topic, bytes))
|
|
75
|
+
}
|
|
76
|
+
|
|
58
77
|
/**
|
|
59
78
|
* 通过所有可用 provider 订阅 topic advert。
|
|
60
79
|
* 单路失败静默(正常降级)。
|
|
@@ -63,20 +82,8 @@ export async function advertiseTopic(topic, bytes) {
|
|
|
63
82
|
* @returns {Promise<() => void>} 统一取消函数
|
|
64
83
|
*/
|
|
65
84
|
export async function subscribeTopic(topic, onAdvert) {
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
if (!provider.caps?.canDiscover || typeof provider.subscribe !== 'function') continue
|
|
69
|
-
let cleanup = null
|
|
70
|
-
try {
|
|
71
|
-
cleanup = await provider.subscribe(topic, onAdvert)
|
|
72
|
-
}
|
|
73
|
-
catch { continue }
|
|
74
|
-
if (typeof cleanup === 'function') cleanups.push(cleanup)
|
|
75
|
-
}
|
|
76
|
-
return () => {
|
|
77
|
-
for (const cleanup of cleanups)
|
|
78
|
-
try { cleanup() } catch { /* ignore */ }
|
|
79
|
-
}
|
|
85
|
+
const list = listDiscoveryProviders().filter(provider => provider.caps?.canDiscover && typeof provider.subscribe === 'function')
|
|
86
|
+
return startProvidersParallel(list, provider => provider.subscribe(topic, onAdvert))
|
|
80
87
|
}
|
|
81
88
|
|
|
82
89
|
/**
|
|
@@ -93,13 +100,15 @@ export async function sendSignal(topic, to, bytes) {
|
|
|
93
100
|
if (!capable.length) throw new Error('p2p: no discovery provider can signal')
|
|
94
101
|
let sent = false
|
|
95
102
|
let lastError = null
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
+
await Promise.all(capable.map(async provider => {
|
|
104
|
+
try {
|
|
105
|
+
if (await Promise.resolve(provider.sendSignal(topic, to, bytes)) !== false)
|
|
106
|
+
sent = true
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
lastError = error
|
|
110
|
+
}
|
|
111
|
+
}))
|
|
103
112
|
if (!sent) throw lastError || new Error('p2p: no discovery provider delivered signal')
|
|
104
113
|
}
|
|
105
114
|
|
|
@@ -111,18 +120,6 @@ export async function sendSignal(topic, to, bytes) {
|
|
|
111
120
|
* @returns {Promise<() => void>} 统一取消函数
|
|
112
121
|
*/
|
|
113
122
|
export async function listenSignals(topic, onSignal) {
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
if (!provider.caps?.canSignal || typeof provider.onSignal !== 'function') continue
|
|
117
|
-
let cleanup = null
|
|
118
|
-
try {
|
|
119
|
-
cleanup = await provider.onSignal(topic, onSignal)
|
|
120
|
-
}
|
|
121
|
-
catch { continue }
|
|
122
|
-
if (typeof cleanup === 'function') cleanups.push(cleanup)
|
|
123
|
-
}
|
|
124
|
-
return () => {
|
|
125
|
-
for (const cleanup of cleanups)
|
|
126
|
-
try { cleanup() } catch { /* ignore */ }
|
|
127
|
-
}
|
|
123
|
+
const list = listDiscoveryProviders().filter(provider => provider.caps?.canSignal && typeof provider.onSignal === 'function')
|
|
124
|
+
return startProvidersParallel(list, provider => provider.onSignal(topic, onSignal))
|
|
128
125
|
}
|