@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.
Files changed (60) hide show
  1. package/README.md +25 -7
  2. package/core/bytes_codec.mjs +65 -10
  3. package/core/tcp_port.mjs +1 -1
  4. package/crypto/checkpoint_sign.mjs +2 -2
  5. package/dag/canonicalize_row.mjs +3 -3
  6. package/dag/index.mjs +2 -3
  7. package/discovery/bt/index.mjs +5 -5
  8. package/discovery/bt/probe_child.mjs +20 -0
  9. package/discovery/bt/runtime.mjs +59 -15
  10. package/discovery/index.mjs +45 -48
  11. package/discovery/mdns.mjs +72 -17
  12. package/discovery/nostr.mjs +165 -129
  13. package/federation/chunk_fetch_pending.mjs +4 -5
  14. package/federation/dag_order_cache.mjs +1 -1
  15. package/federation/entity_key_chain.mjs +3 -4
  16. package/federation/topo_order_memo.mjs +11 -4
  17. package/files/chunk_fetch.mjs +2 -2
  18. package/files/evfs.mjs +2 -2
  19. package/link/channel_mux.mjs +10 -10
  20. package/link/frame.mjs +76 -124
  21. package/link/handshake.mjs +5 -5
  22. package/link/pipe.mjs +49 -75
  23. package/link/providers/ble_gatt.mjs +15 -27
  24. package/link/providers/index.mjs +7 -9
  25. package/link/providers/lan_tcp.mjs +18 -33
  26. package/link/providers/link_id_pipe.mjs +46 -0
  27. package/link/providers/webrtc.mjs +43 -52
  28. package/link/rtc.mjs +21 -9
  29. package/mailbox/deliver_or_store.mjs +1 -1
  30. package/node/identity.mjs +4 -4
  31. package/node/network.mjs +9 -9
  32. package/overlay/index.mjs +13 -13
  33. package/package.json +3 -2
  34. package/rooms/scoped_link.mjs +22 -38
  35. package/schemas/discovery.mjs +1 -2
  36. package/schemas/federation_pull.mjs +2 -2
  37. package/schemas/part_query.mjs +1 -1
  38. package/schemas/remote_event.mjs +1 -1
  39. package/transport/advert_ingest.mjs +20 -0
  40. package/transport/group_link_set.mjs +26 -45
  41. package/transport/ice_servers.mjs +12 -3
  42. package/transport/link_registry.mjs +181 -523
  43. package/transport/offer_answer.mjs +194 -0
  44. package/transport/peer_pool.mjs +53 -64
  45. package/transport/remote_user_room.mjs +13 -15
  46. package/transport/rtc_connection_budget.mjs +18 -4
  47. package/transport/runtime_bootstrap.mjs +369 -0
  48. package/transport/signal_crypto.mjs +104 -0
  49. package/transport/user_room.mjs +22 -15
  50. package/trust_graph/send.mjs +1 -1
  51. package/utils/emit_safe.mjs +11 -0
  52. package/utils/shuffle.mjs +13 -0
  53. package/utils/ttl_map.mjs +29 -4
  54. package/wire/ingress.mjs +1 -1
  55. package/wire/part_ingress.mjs +6 -8
  56. package/wire/part_invoke.mjs +4 -4
  57. package/wire/part_query.mjs +2 -3
  58. package/wire/part_query.tunables.json +6 -1
  59. package/wire/part_query_cache.mjs +1 -1
  60. 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
- Subpath exports mirror source directories, e.g. `@steve02081504/fount-p2p/dag`, `@steve02081504/fount-p2p/transport/link_registry`, `@steve02081504/fount-p2p/registries/event_type`.
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, RTC links, rooms |
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
- Root contains only the facade and package metadata; all modules live in layered subdirectories. After a layout migration, run `node scripts/cleanup-root-duplicates.mjs` to drop stale root stubs.
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 # RTC / link smoke (requires node-datachannel)
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). Unavailable when there is no adapter, load fails, or the radio never reaches poweredOn — other discovery/link paths continue.
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
- - Signaling and link setup: [`docs/signaling.md`](./docs/signaling.md)
80
+ - Transports and provider fallback: [`docs/transports.md`](./docs/transports.md)
81
+ - Signaling and WebRTC glare: [`docs/signaling.md`](./docs/signaling.md)
@@ -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} u8 原始字节
7
- * @returns {string} 标准 Base64 文本
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 u8ToB64(u8) {
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 < u8.length; index++) binary += String.fromCharCode(u8[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} b64 标准 Base64 文本
74
+ * @param {string} base64 标准 Base64
17
75
  * @returns {Uint8Array} 解码后的字节
18
76
  */
19
- export function b64ToU8(b64) {
20
- const bin = atob(b64)
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 == null || port === '') return null
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 || typeof checkpoint !== 'object')
52
+ if (!checkpoint)
53
53
  return { valid: false, reason: 'checkpoint missing or not an object' }
54
- if (!Array.isArray(checkpoint.eventIdsInEpoch) || checkpoint.eventIdsInEpoch.length === 0)
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)
@@ -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] == null || obj[key] === '') return
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] == null || out[key] === '') continue
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 (Array.isArray(out.prev_event_ids))
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
- if (!Array.isArray(raw)) return []
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 == null) break
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
@@ -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 (!Array.isArray(parsed?.entries)) return []
49
+ if (!parsed?.entries?.length) return []
50
50
  return parsed.entries.map(entry => ({
51
- topic: String(entry?.topic || ''),
52
- bytes: Uint8Array.from(Buffer.from(String(entry?.data || ''), 'base64')),
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
+ }
@@ -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 栈是否真正可用(有适配器迹象 load → poweredOn)。
37
- * 任一步失败返回 false,调用方回落其它 discovery/link;不抛错。
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
- try {
48
- const noble = await loadNoble()
49
- if (!noble.startScanningAsync) {
50
- cachedRuntimeOk = false
51
- return false
52
- }
53
- await waitPoweredOn(noble, timeoutMs)
54
- cachedRuntimeOk = true
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
  /**
@@ -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
- * 通过所有可用 provider 广播 topic advert。
36
- * 单路失败静默(正常降级);其它 provider 仍可成功。
37
- * @param {string} topic advert 主题
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
- export async function advertiseTopic(topic, bytes) {
42
- const cleanups = []
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.advertise(topic, bytes)
51
+ const cleanup = await run(provider)
52
+ return typeof cleanup === 'function' ? cleanup : null
48
53
  }
49
- catch { continue }
50
- if (typeof cleanup === 'function') cleanups.push(cleanup)
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 cleanups = []
67
- for (const provider of listDiscoveryProviders()) {
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
- for (const provider of capable) try {
97
- if (await Promise.resolve(provider.sendSignal(topic, to, bytes)) !== false)
98
- sent = true
99
- }
100
- catch (error) {
101
- lastError = error
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 cleanups = []
115
- for (const provider of listDiscoveryProviders()) {
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
  }