@cero-base/core 1.1.0 → 1.2.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/src/lib/utils.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import crypto from 'crypto'
2
+ import b4a from 'b4a'
2
3
  import { Readable } from 'streamx'
3
4
  import z32 from 'z32'
4
- import { encode, decode, isValid } from 'hypercore-id-encoding'
5
+ import { encode, decode } from 'hypercore-id-encoding'
5
6
 
6
7
  import { ROLE_PERMS, RANK } from './constants.js'
7
8
 
@@ -28,12 +29,18 @@ export const toId = encode
28
29
  */
29
30
  export const toKey = decode
30
31
 
31
- /**
32
- * Test whether a string is a well-formed canonical id.
33
- *
34
- * @type {(id: string) => boolean}
35
- */
36
- export const isKeyId = isValid
32
+ // Domain-separated payloads for writer-admission signatures. Binding the db
33
+ // key makes an admission unreplayable across rooms; the tag pins the format.
34
+ // v2 — pre-1.2 signatures (writer‖appender / bare writer) no longer verify.
35
+ const ADD_WRITER_TAG = b4a.from('cero/add-writer/v2')
36
+ const CLAIM_WRITER_TAG = b4a.from('cero/claim-writer/v2')
37
+
38
+ /** @type {(dbKey: Uint8Array, writer: Uint8Array, appender: Uint8Array) => Uint8Array} */
39
+ export const addWriterPayload = (dbKey, writer, appender) =>
40
+ b4a.concat([ADD_WRITER_TAG, dbKey, writer, appender])
41
+
42
+ /** @type {(dbKey: Uint8Array, writer: Uint8Array) => Uint8Array} */
43
+ export const claimWriterPayload = (dbKey, writer) => b4a.concat([CLAIM_WRITER_TAG, dbKey, writer])
37
44
 
38
45
  /**
39
46
  * Whether `role` is granted `perm` under the default policy.
@@ -63,7 +70,6 @@ export const outranks = (a, b) => RANK[a] != null && RANK[b] != null && RANK[a]
63
70
  */
64
71
  export function subscribe({ get, watch }) {
65
72
  let stop = null
66
- let version = 0
67
73
  let last
68
74
  const stream = new Readable({
69
75
  destroy(cb) {
@@ -78,15 +84,30 @@ export function subscribe({ get, watch }) {
78
84
  stream.push(data)
79
85
  }
80
86
 
87
+ // Coalesce: one get() in flight at a time. Ticks that land mid-read mark
88
+ // dirty and fold into a single trailing re-read — a write burst costs one
89
+ // extra snapshot instead of one concurrent scan per tick, and only the
90
+ // latest state is emitted.
91
+ let running = false
92
+ let dirty = false
81
93
  const push = async () => {
82
94
  if (stream.destroyed) return
83
- const v = ++version
95
+ if (running) {
96
+ dirty = true
97
+ return
98
+ }
99
+ running = true
84
100
  try {
85
- const data = await get()
86
- if (v !== version || stream.destroyed) return
87
- emit(data)
101
+ do {
102
+ dirty = false
103
+ const data = await get()
104
+ if (stream.destroyed) return
105
+ emit(data)
106
+ } while (dirty)
88
107
  } catch (e) {
89
108
  stream.destroy(e)
109
+ } finally {
110
+ running = false
90
111
  }
91
112
  }
92
113
 
@@ -0,0 +1,324 @@
1
+ import ReadyResource from 'ready-resource'
2
+ import b4a from 'b4a'
3
+ import { hash } from 'hypercore-crypto'
4
+ import safetyCatch from 'safety-catch'
5
+
6
+ // Readable characteristic carrying "<psm>:<nodeId-hex>" — the L2CAP PSM to
7
+ // connect on plus the advertiser's stable id for the initiate tie-break. Read
8
+ // over GATT after connecting (bare-mobile-doctor reads the PSM the same way);
9
+ // advertisement service-data is not a portable channel for this.
10
+ const PSM_UUID = 'ce1a0003-0000-1000-8000-00805f9b34fb'
11
+ const DEFAULT_CAP = 4
12
+ const CONNECT_TIMEOUT = 15000
13
+
14
+ /**
15
+ * Derive a stable 128-bit BLE service UUID from a topic. Only devices that
16
+ * compute the same UUID (same channel / same invite) ever discover each other.
17
+ *
18
+ * @param {Uint8Array} topic
19
+ * @param {string} [tag] Namespace so channel and invite meshes never collide.
20
+ * @returns {string}
21
+ */
22
+ export function toServiceUUID(topic, tag = 'cero-ble') {
23
+ const h = hash([b4a.from(tag), topic])
24
+ const hex = b4a.toString(h.subarray(0, 16), 'hex')
25
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
26
+ }
27
+
28
+ // Backend adapter state → the app-facing state the facade exposes.
29
+ const STATE = {
30
+ poweredOn: 'on',
31
+ poweredOff: 'waiting',
32
+ unauthorized: 'unauthorized',
33
+ unsupported: 'unsupported',
34
+ resetting: 'waiting',
35
+ unknown: 'waiting'
36
+ }
37
+
38
+ const uuidEq = (a, b) =>
39
+ String(a || '')
40
+ .toLowerCase()
41
+ .replace(/-/g, '') ===
42
+ String(b || '')
43
+ .toLowerCase()
44
+ .replace(/-/g, '')
45
+
46
+ const findByUUID = (items, uuid) => (items || []).find((i) => uuidEq(i.uuid, uuid)) || null
47
+
48
+ /**
49
+ * Dual-role BLE transport: advertises + scans one service UUID, opens an L2CAP
50
+ * channel to each discovered peer, and feeds it into `network.inject`. From
51
+ * there replication and pairing are transport-agnostic (see Network.inject).
52
+ *
53
+ * Choreography mirrors the proven bare-mobile-doctor worklet: server adds a
54
+ * readable PSM characteristic at startup and answers reads dynamically; the
55
+ * central connects, reads "<psm>:<nodeId>", tie-breaks, then opens the channel.
56
+ * `backend` is bare-bluetooth in production and a mock in tests.
57
+ *
58
+ * ponytail: capability-handshake DoS link-scoring is deferred — it needs a
59
+ * replication-progress signal (design §4b). v1 caps links + times out dials.
60
+ *
61
+ * @extends ReadyResource
62
+ */
63
+ export class BluetoothTransport extends ReadyResource {
64
+ /**
65
+ * @param {object} opts
66
+ * @param {any} opts.backend bare-bluetooth-shaped module (Central, Server, Service, Characteristic).
67
+ * @param {import('./index.js').Network} opts.network
68
+ * @param {Uint8Array} opts.uuid The 32-byte topic the service UUID derives from.
69
+ * @param {Uint8Array} opts.nodeId Stable local id (identity/device key) for the initiate tie-break.
70
+ * @param {string} [opts.tag] UUID namespace (channel mesh vs invite mesh).
71
+ * @param {number} [opts.cap] Max concurrent links; gossip covers the rest.
72
+ * @param {{ scanMode?: any }} [opts.scanOptions] Platform scan options (e.g. Android low-power).
73
+ * @param {boolean} [opts.keepLinks] On close, stop the radio but leave established links alive (invite rendezvous: the link outlives the QR and carries the initial replication).
74
+ */
75
+ constructor({
76
+ backend,
77
+ network,
78
+ uuid,
79
+ nodeId,
80
+ tag = 'cero-ble',
81
+ cap = DEFAULT_CAP,
82
+ scanOptions,
83
+ keepLinks = false
84
+ }) {
85
+ super()
86
+ this.backend = backend
87
+ this.network = network
88
+ this.nodeId = nodeId
89
+ this.nodeHex = b4a.toString(nodeId, 'hex')
90
+ this.serviceUUID = toServiceUUID(uuid, tag)
91
+ this.cap = cap
92
+ this.scanOptions = scanOptions
93
+ this.keepLinks = keepLinks
94
+
95
+ this.state = 'off'
96
+ this.central = null
97
+ this.server = null
98
+ this.psm = null
99
+ this._scanning = false
100
+ this._advertising = false
101
+ this._serviceAdded = false
102
+ /** peripheral id being dialed → its connect-timeout timer */
103
+ this._dialing = new Map()
104
+ /** live injected links keyed by remote node id hex */
105
+ this.peers = new Map()
106
+ }
107
+
108
+ /**
109
+ * Whether we should be the one to open the connection to `peerNodeId`. The
110
+ * lexicographically smaller id initiates; the larger waits — so a pair
111
+ * connects once, not twice. Equal (our own reflection) → false.
112
+ *
113
+ * @param {Uint8Array} peerNodeId
114
+ * @returns {boolean}
115
+ */
116
+ shouldInitiate(peerNodeId) {
117
+ return b4a.compare(this.nodeId, peerNodeId) < 0
118
+ }
119
+
120
+ get linkCount() {
121
+ return this.peers.size
122
+ }
123
+
124
+ async _open() {
125
+ const { Central, Server, Service, Characteristic } = this.backend
126
+
127
+ this.server = new Server()
128
+ this.server.on('stateChange', (s) => {
129
+ this._onState(s)
130
+ if (s === 'poweredOn') this._startServer(Service, Characteristic)
131
+ })
132
+ this.server.on('serviceAdd', () => {
133
+ this._serviceAdded = true
134
+ this._maybeAdvertise()
135
+ })
136
+ this.server.on('channelPublish', (psm) => {
137
+ this.psm = psm
138
+ this._maybeAdvertise()
139
+ })
140
+ // A central reads this to learn the PSM (+ our id) before opening the
141
+ // channel. Answer dynamically: the PSM isn't known when the service is added.
142
+ this.server.on('readRequest', (req) => {
143
+ const ok = this.server.constructor.ATT_SUCCESS ?? 0
144
+ const val = this.psm == null ? b4a.alloc(0) : b4a.from(`${this.psm}:${this.nodeHex}`)
145
+ this.server.respondToRequest(req, ok, val)
146
+ })
147
+ this.server.on('channelOpen', (l2cap) => this._onChannel(l2cap, false, null))
148
+ this.server.on('error', safetyCatch)
149
+
150
+ this.central = new Central()
151
+ this.central.on('stateChange', (s) => {
152
+ this._onState(s)
153
+ if (s === 'poweredOn') this._startScan()
154
+ })
155
+ this.central.on('discover', (peripheral) => this._onDiscover(peripheral))
156
+ this.central.on('connect', (peripheral) => this._onConnect(peripheral))
157
+ this.central.on('disconnect', () => {})
158
+ this.central.on('error', (err) => this._onCentralError(err))
159
+
160
+ this._startServer(Service, Characteristic)
161
+ this._startScan()
162
+ this.state = 'starting'
163
+ }
164
+
165
+ _startServer(Service, Characteristic) {
166
+ if (this.server.state !== 'poweredOn') return // wait for the adapter (stateChange)
167
+ if (!this._serviceAdded) {
168
+ const char = new Characteristic(PSM_UUID, { read: true })
169
+ this.server.addService(new Service(this.serviceUUID, [char]))
170
+ }
171
+ if (this.psm == null) this.server.publishChannel({})
172
+ }
173
+
174
+ _maybeAdvertise() {
175
+ if (this._advertising || !this._serviceAdded || this.psm == null) return
176
+ this._advertising = true
177
+ this.server.startAdvertising({ serviceUUIDs: [this.serviceUUID] })
178
+ }
179
+
180
+ _startScan() {
181
+ if (this._scanning || this.central.state !== 'poweredOn') return
182
+ this._scanning = true
183
+ this.central.startScan([this.serviceUUID], this.scanOptions)
184
+ }
185
+
186
+ _onState(raw) {
187
+ const next = STATE[raw] ?? 'waiting'
188
+ if (next === this.state) return
189
+ this.state = next
190
+ this.emit('update')
191
+ }
192
+
193
+ _onDiscover(peripheral) {
194
+ if (this.closing || this.closed) return
195
+ if (this._dialing.has(peripheral.id)) return // already connecting to this one
196
+ if (this.linkCount >= this.cap) return // gossip covers the rest
197
+ // tie-break is decided post-read (we can't trust advertisement payloads);
198
+ // dial now, learn the peer id from the PSM characteristic, decide there.
199
+ const timer = setTimeout(() => this._abortDial(peripheral, 'timeout'), CONNECT_TIMEOUT)
200
+ this._dialing.set(peripheral.id, timer)
201
+ try {
202
+ this.central.connect(peripheral)
203
+ } catch (err) {
204
+ this._abortDial(peripheral, err)
205
+ }
206
+ }
207
+
208
+ _onConnect(peripheral) {
209
+ peripheral.on('error', () => this._abortDial(peripheral, 'peripheral-error'))
210
+ peripheral.once('servicesDiscover', (services) => {
211
+ const svc = findByUUID(services, this.serviceUUID)
212
+ if (svc) peripheral.discoverCharacteristics(svc, [PSM_UUID])
213
+ else this._abortDial(peripheral, 'no-service')
214
+ })
215
+ peripheral.once('characteristicsDiscover', (_svc, chars) => {
216
+ const psmChar = findByUUID(chars, PSM_UUID)
217
+ if (psmChar) peripheral.read(psmChar)
218
+ else this._abortDial(peripheral, 'no-psm-char')
219
+ })
220
+ peripheral.once('read', (_char, data) => {
221
+ const [psmStr, peerHex] = b4a.toString(data).split(':')
222
+ const psm = Number(psmStr)
223
+ const peerId = peerHex ? b4a.from(peerHex, 'hex') : null
224
+ // the smaller id opens the channel; the larger disconnects and waits for
225
+ // the peer to dial back — a pair links once, not twice
226
+ if (!psm || !peerId || !this.shouldInitiate(peerId)) {
227
+ this._abortDial(peripheral, 'yield')
228
+ return
229
+ }
230
+ peripheral.once('channelOpen', (l2cap) => {
231
+ this._clearDial(peripheral.id)
232
+ this._onChannel(l2cap, true, peripheral.id)
233
+ })
234
+ peripheral.openL2CAPChannel(psm)
235
+ })
236
+ peripheral.discoverServices([this.serviceUUID])
237
+ }
238
+
239
+ _abortDial(peripheral, _reason) {
240
+ const id = peripheral?.id
241
+ this._clearDial(id)
242
+ try {
243
+ this.central.disconnect(peripheral)
244
+ } catch (err) {
245
+ safetyCatch(err)
246
+ }
247
+ }
248
+
249
+ _clearDial(id) {
250
+ if (id == null) return
251
+ const timer = this._dialing.get(id)
252
+ if (timer) clearTimeout(timer)
253
+ this._dialing.delete(id) // free the slot — rediscovery can re-dial
254
+ }
255
+
256
+ // iOS & Android report errored connects as 'error' with a code, not
257
+ // 'disconnect' — without this a failed dial pins the peripheral forever.
258
+ _onCentralError(err) {
259
+ safetyCatch(err)
260
+ const code = err && err.code
261
+ if (code === 'CONNECTION_FAILED' || code === 'DISCONNECT') {
262
+ for (const id of [...this._dialing.keys()]) this._clearDial(id)
263
+ }
264
+ }
265
+
266
+ _onChannel(l2cap, isInitiator, peripheralId) {
267
+ if (this.closing || this.closed) {
268
+ l2cap.destroy()
269
+ return
270
+ }
271
+ const conn = this.network.inject(l2cap, { isInitiator })
272
+ conn.on('open', () => this._track(conn, peripheralId))
273
+ conn.on('close', () => this._untrack(conn, peripheralId))
274
+ }
275
+
276
+ _track(conn, peripheralId) {
277
+ if (this.closing || this.closed) return
278
+ const key = b4a.toString(conn.remotePublicKey, 'hex')
279
+ const existing = this.peers.get(key)
280
+ if (existing && existing !== conn) {
281
+ conn.destroy() // duplicate link to the same peer — keep the first
282
+ return
283
+ }
284
+ conn._peripheralId = peripheralId
285
+ conn._peerKey = key
286
+ this.peers.set(key, conn)
287
+ this.emit('update')
288
+ }
289
+
290
+ _untrack(conn) {
291
+ const key = conn._peerKey
292
+ if (key && this.peers.get(key) === conn) this.peers.delete(key)
293
+ if (!this.closing && !this.closed) this.emit('update')
294
+ }
295
+
296
+ async _close() {
297
+ // doctor-app rule: never call central/server.destroy() — it double-frees in
298
+ // the native teardown; stop advertising/scanning and let the runtime reclaim.
299
+ for (const timer of this._dialing.values()) clearTimeout(timer)
300
+ this._dialing.clear()
301
+ try {
302
+ this.central?.stopScan()
303
+ } catch (err) {
304
+ safetyCatch(err)
305
+ }
306
+ try {
307
+ this.server?.stopAdvertising()
308
+ } catch (err) {
309
+ safetyCatch(err)
310
+ }
311
+ if (!this.keepLinks) {
312
+ // channel mesh: toggling nearby off means stop syncing nearby
313
+ for (const conn of this.peers.values()) {
314
+ try {
315
+ conn.destroy()
316
+ } catch (err) {
317
+ safetyCatch(err)
318
+ }
319
+ }
320
+ }
321
+ this.peers.clear()
322
+ this.state = 'off'
323
+ }
324
+ }
@@ -1,4 +1,6 @@
1
1
  import Hyperswarm from 'hyperswarm'
2
+ import NoiseSecretStream from '@hyperswarm/secret-stream'
3
+ import BlindPairing from 'blind-pairing'
2
4
  import ProtomuxWakeup from 'protomux-wakeup'
3
5
  import ReadyResource from 'ready-resource'
4
6
  import safetyCatch from 'safety-catch'
@@ -39,6 +41,81 @@ export class Network extends ReadyResource {
39
41
 
40
42
  this._replicateables = new Set()
41
43
  this._discoveries = new Set()
44
+ this._injected = new Set()
45
+ this._blind = null
46
+ }
47
+
48
+ /**
49
+ * Feed an externally-established connection — a Bluetooth L2CAP channel, a
50
+ * serial link, an in-process pair, any duplex — into the network. A raw
51
+ * duplex is wrapped in NoiseSecretStream (pass `isInitiator`); a stream
52
+ * that already IS one is used as-is. From here it gets the exact same
53
+ * treatment as a swarm connection: wakeup, replication of every attached
54
+ * core, pairing, and the 'connection' event.
55
+ *
56
+ * @param {any} stream Duplex transport, or a ready NoiseSecretStream.
57
+ * @param {{ isInitiator?: boolean }} [opts] Which side initiates the noise handshake (raw duplexes only).
58
+ * @returns {any} The encrypted connection stream.
59
+ */
60
+ inject(stream, { isInitiator } = {}) {
61
+ if (this.closing || this.closed) throw CeroError.CLOSED('Network')
62
+ if (!stream) throw CeroError.REQUIRED('stream')
63
+ const conn =
64
+ stream.noiseStream === stream ? stream : new NoiseSecretStream(isInitiator === true, stream)
65
+
66
+ // blind-pairing picks the lowest-`rtt` unvisited channel to send on; that
67
+ // field only exists on real udx sockets. A raw injected duplex has none,
68
+ // so `rtt < Infinity` is always false and pairing never sends a request.
69
+ // Zero is also the semantically correct RTT for a direct injected link.
70
+ if (conn.rawStream && conn.rawStream.rtt === undefined) conn.rawStream.rtt = 0
71
+
72
+ this._injected.add(conn)
73
+ conn.on('close', () => this._injected.delete(conn))
74
+ conn.on('error', safetyCatch) // a dropped radio link must not crash the host
75
+
76
+ this.wakeup.addStream(conn)
77
+ for (const r of this._replicateables) replicateInto(r, conn)
78
+ this.emit('connection', conn, { injected: true })
79
+ return conn
80
+ }
81
+
82
+ /**
83
+ * Lazily create the network-shared BlindPairing. One instance serves every
84
+ * handle's pairing member — per-handle instances each added their own swarm
85
+ * and DHT listeners plus a protomux channel per connection.
86
+ *
87
+ * @returns {Promise<any>}
88
+ */
89
+ async blind() {
90
+ if (!this._blind) {
91
+ const blind = new BlindPairing(this.swarm)
92
+ this._blind = blind.ready().then(() => {
93
+ // blind-pairing only watches the swarm — injected connections must
94
+ // reach it too, or offline (e.g. Bluetooth) pairing never completes.
95
+ // _onconnection is upstream-private; the offline-pairing test pins it.
96
+ this.on('connection', (conn, info) => {
97
+ if (info?.injected) blind._onconnection(conn)
98
+ })
99
+ for (const conn of this._injected) blind._onconnection(conn)
100
+ return blind
101
+ })
102
+ }
103
+ return this._blind
104
+ }
105
+
106
+ /**
107
+ * Re-attach pairing channels on injected connections. blind-pairing only
108
+ * auto-attaches refs that existed when a connection arrived — swarm peers
109
+ * meet again over topic joins, injected links (Bluetooth, pipes) don't, so
110
+ * a member/candidate added later must re-run the attach. Idempotent:
111
+ * protomux refuses duplicate channels.
112
+ *
113
+ * @returns {Promise<void>}
114
+ */
115
+ async refreshInjected() {
116
+ if (!this._blind || !this._injected.size) return
117
+ const blind = await this._blind
118
+ for (const conn of this._injected) blind._onconnection(conn)
42
119
  }
43
120
 
44
121
  /** @returns {Map<string, any>} Known peers keyed by public-key string. */
@@ -46,9 +123,10 @@ export class Network extends ReadyResource {
46
123
  return this.swarm ? this.swarm.peers : new Map()
47
124
  }
48
125
 
49
- /** @returns {Set<any>} Live connection streams. */
126
+ /** @returns {Set<any>} Live connection streams — swarm and injected. */
50
127
  get connections() {
51
- return this.swarm ? this.swarm.connections : new Set()
128
+ if (!this._injected.size) return this.swarm ? this.swarm.connections : new Set()
129
+ return new Set([...(this.swarm ? this.swarm.connections : []), ...this._injected])
52
130
  }
53
131
 
54
132
  /** @returns {boolean} */
@@ -129,6 +207,15 @@ export class Network extends ReadyResource {
129
207
  }
130
208
 
131
209
  async _close() {
210
+ for (const conn of [...this._injected]) {
211
+ try {
212
+ conn.destroy()
213
+ } catch (err) {
214
+ safetyCatch(err)
215
+ }
216
+ }
217
+ this._injected.clear()
218
+
132
219
  for (const d of [...this._discoveries]) {
133
220
  try {
134
221
  await d.destroy()
@@ -137,6 +224,15 @@ export class Network extends ReadyResource {
137
224
  }
138
225
  }
139
226
 
227
+ if (this._blind) {
228
+ try {
229
+ await (await this._blind).close()
230
+ } catch (err) {
231
+ safetyCatch(err)
232
+ }
233
+ this._blind = null
234
+ }
235
+
140
236
  if (this.swarm) {
141
237
  try {
142
238
  await this.flush()
@@ -195,9 +291,7 @@ export class Network extends ReadyResource {
195
291
  attach(core) {
196
292
  if (!core) throw CeroError.REQUIRED('core')
197
293
  this._replicateables.add(core)
198
- if (this.swarm) {
199
- for (const stream of this.swarm.connections) replicateInto(core, stream)
200
- }
294
+ for (const stream of this.connections) replicateInto(core, stream)
201
295
  }
202
296
 
203
297
  /**
@@ -223,13 +317,24 @@ export class Network extends ReadyResource {
223
317
  if (!target || typeof target.replicate !== 'function') {
224
318
  throw CeroError.INVALID('target must be an object with a replicate(stream) method')
225
319
  }
226
- if (this.swarm) {
227
- for (const stream of this.swarm.connections) replicateInto(target, stream)
228
- }
320
+ for (const stream of this.connections) replicateInto(target, stream)
229
321
  }
230
322
  }
231
323
 
324
+ // Corestore replication is store-wide: N attached bees on one store would
325
+ // re-attach every core AND add N duplicate StreamTracker records per
326
+ // connection. Replicate each root store once per stream; wakeup streams are
327
+ // added at the network level, so skipped bees lose nothing.
328
+ const replicatedRoots = new WeakMap()
329
+
232
330
  function replicateInto(core, stream) {
331
+ const root = core.store ? core.store.root || core.store : null
332
+ if (root) {
333
+ let seen = replicatedRoots.get(stream)
334
+ if (!seen) replicatedRoots.set(stream, (seen = new WeakSet()))
335
+ if (seen.has(root)) return
336
+ seen.add(root)
337
+ }
233
338
  try {
234
339
  core.replicate(stream)
235
340
  } catch (err) {
@@ -88,13 +88,14 @@ export class Pairing extends ReadyResource {
88
88
 
89
89
  async _open() {
90
90
  await this.network.ready()
91
- this._blind = new BlindPairing(this.network.swarm)
92
- await this._blind.ready()
91
+ // shared instance — one set of swarm/DHT listeners for every handle
92
+ this._blind = await this.network.blind()
93
93
 
94
94
  this._member = this._blind.addMember({
95
95
  discoveryKey: discoveryKey(this.topic),
96
96
  onadd: (req) => this._onCandidate(req).catch(this._onerror)
97
97
  })
98
+ await this.network.refreshInjected()
98
99
  }
99
100
 
100
101
  async _close() {
@@ -105,10 +106,8 @@ export class Pairing extends ReadyResource {
105
106
  await this._member.close()
106
107
  this._member = null
107
108
  }
108
- if (this._blind) {
109
- await this._blind.close()
110
- this._blind = null
111
- }
109
+ // the BlindPairing is network-owned and shared — never close it here
110
+ this._blind = null
112
111
  this._invites.clear()
113
112
  }
114
113
 
@@ -415,6 +414,7 @@ class Candidate {
415
414
  onadd: (result) => this._done(result)
416
415
  })
417
416
  this._candidate.request.on('rejected', (err) => this._fail(fromBlindError(err)))
417
+ this.pairing.network.refreshInjected().catch(this._onerror ?? (() => {}))
418
418
  } catch (err) {
419
419
  this._fail(err instanceof CeroError ? err : CeroError.NETWORK_ERROR(err.message))
420
420
  }
package/src/rpc/index.js CHANGED
@@ -108,13 +108,13 @@ export function bindCodec(spec) {
108
108
  },
109
109
  encodeAction(handle, op, data) {
110
110
  const ref = handle?.[op]
111
- if (!ref || !ref.schema) throw new Error(`unknown action: ${op}`)
111
+ if (!ref || !ref.schema) throw CeroError.UNKNOWN('action', op)
112
112
  if (data == null) return EMPTY
113
113
  return schema.encode(ref.schema, data)
114
114
  },
115
115
  decodeAction(handle, op, buf) {
116
116
  const ref = handle?.[op]
117
- if (!ref || !ref.schema) throw new Error(`unknown action: ${op}`)
117
+ if (!ref || !ref.schema) throw CeroError.UNKNOWN('action', op)
118
118
  if (!buf || buf.length === 0) return undefined
119
119
  return schema.decode(ref.schema, buf)
120
120
  }