@cero-base/core 1.8.1 → 1.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cero-base/core",
3
- "version": "1.8.1",
3
+ "version": "1.10.0",
4
4
  "description": "cero p2p primitives — identity, storage, network, database, blobs, rpc, pairing.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -144,15 +144,15 @@
144
144
  },
145
145
  "dependencies": {
146
146
  "@hyperswarm/secret-stream": "^6.9.1",
147
- "autobee": "1.0.10",
147
+ "autobee": "2.0.0-rc.6",
148
148
  "autobee-encryption": "0.1.3",
149
149
  "b4a": "^1.8.1",
150
150
  "bare-crypto": "^1.15.3",
151
- "bare-fs": "^4.7.4",
151
+ "bare-fs": "^4.8.0",
152
152
  "bare-path": "^3.1.1",
153
153
  "bip39-mnemonic": "^2.5.0",
154
154
  "blind-pairing": "^2.3.1",
155
- "blind-peering": "^2.5.3",
155
+ "blind-peering": "^2.6.1",
156
156
  "compact-encoding": "^3.3.0",
157
157
  "corestore": "^7.12.0",
158
158
  "framed-stream": "^1.0.1",
@@ -181,8 +181,8 @@
181
181
  "bare-events": "^2.9.1",
182
182
  "bare-fetch": "^3.2.0",
183
183
  "bare-process": "^4.5.1",
184
- "bare-url": "^2.4.6",
185
- "blind-peer": "^3.12.5",
184
+ "bare-url": "^2.5.2",
185
+ "blind-peer": "^3.13.2",
186
186
  "brittle": "^4.1.0",
187
187
  "typescript": "^5.9.3",
188
188
  "which-runtime": "^1.4.0"
@@ -238,6 +238,11 @@ export class EpochAutobee extends Autobee {
238
238
  // step so an UNKNOWN_EPOCH surfacing from batch processing parks too
239
239
  // instead of crashing the bee.
240
240
  async _bumpPendingWriters() {
241
+ if (this._catchupMigratedNodes !== null) {
242
+ await this._bumpMigratedWriters()
243
+ this._catchupMigratedNodes = null
244
+ }
245
+
241
246
  let updated = false
242
247
 
243
248
  const pending = this.writers.pending.slice()
@@ -4,12 +4,20 @@ import { hash, randomBytes } from 'hypercore-crypto'
4
4
  import safetyCatch from 'safety-catch'
5
5
 
6
6
  import { GattStream } from './gatt.js'
7
+ import { L2CAPStream, readSidPreamble } from './l2cap.js'
7
8
 
8
- // iOS never answers an L2CAP channel opened by a Mac central and gives no L2CAP
9
- // disconnect signal, so one data characteristic (write + notify) carries a
10
- // framed byte stream both ways instead.
9
+ // One data characteristic (write + notify) carries the framed control traffic
10
+ // both ways and, on the gatt pipe, the data too.
11
11
  const DATA_UUID = 'ce1a0004-0000-1000-8000-00805f9b34fb'
12
12
 
13
+ // The l2cap open historically hangs forever on some platform pairs (no error,
14
+ // no disconnect) — every open runs under this deadline instead.
15
+ const L2CAP_OPEN_TIMEOUT = 3000
16
+ // a pending server session must see its channel within this window or be reaped
17
+ const PIPE_PENDING_TIMEOUT = 20000
18
+
19
+ const PLATFORM = typeof Bare !== 'undefined' ? Bare.platform : process.platform
20
+
13
21
  // Wire frame both directions: [type:1][sessionId:8][payload].
14
22
  const TYPE_OPEN = 1
15
23
  const TYPE_DATA = 2
@@ -132,6 +140,8 @@ export class BLETransport extends ReadyResource {
132
140
  * @param {{ scanMode?: any }} [opts.scanOptions] Platform scan options (e.g. Android low-power).
133
141
  * @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).
134
142
  * @param {string} [opts.name] Local app-user display name, sent to peers over a hello frame.
143
+ * @param {'l2cap' | 'gatt'} [opts.pipe] Data pipe: 'l2cap' (default — a real channel per session, several times faster) or 'gatt' (framed characteristic stream). Both peers must match.
144
+ * @param {{ timeout?: number }} [opts.l2cap] Deadline for an l2cap channel open.
135
145
  */
136
146
  constructor({
137
147
  backend,
@@ -143,7 +153,9 @@ export class BLETransport extends ReadyResource {
143
153
  maxInbound = DEFAULT_MAX_INBOUND,
144
154
  scanOptions,
145
155
  keepLinks = false,
146
- name
156
+ name,
157
+ pipe = 'l2cap',
158
+ l2cap = {}
147
159
  }) {
148
160
  super()
149
161
  this.backend = backend
@@ -156,11 +168,15 @@ export class BLETransport extends ReadyResource {
156
168
  this.maxInbound = maxInbound
157
169
  this.scanOptions = scanOptions
158
170
  this.keepLinks = keepLinks
171
+ this.pipe = pipe
172
+ this._l2capTimeout = l2cap.timeout ?? L2CAP_OPEN_TIMEOUT
159
173
 
160
174
  this.state = 'off'
161
175
  this.central = null
162
176
  this.server = null
163
177
  this._dataChar = null
178
+ /** the published l2cap listener's psm, advertised to centrals over hello */
179
+ this._psm = null
164
180
  /** sessionId hex → { stream, sid } for server-side (peripheral) sessions */
165
181
  this._sessions = new Map()
166
182
  /** serialized server notify queue: { frame, resolve, reject } */
@@ -217,6 +233,7 @@ export class BLETransport extends ReadyResource {
217
233
  this.server.on('stateChange', (s) => {
218
234
  this._onState(s)
219
235
  if (s === 'poweredOn') this._startServer(Service, Characteristic)
236
+ else if (s === 'poweredOff' || s === 'resetting') this._onRadioDown()
220
237
  })
221
238
  this.server.on('serviceAdd', () => {
222
239
  this._serviceAdded = true
@@ -224,6 +241,10 @@ export class BLETransport extends ReadyResource {
224
241
  })
225
242
  this.server.on('writeRequest', (reqs) => this._onWriteRequests(reqs))
226
243
  this.server.on('readyToUpdate', () => this._drainNotify())
244
+ this.server.on('channelPublish', (psm) => {
245
+ this._psm = psm
246
+ })
247
+ this.server.on('channelOpen', (channel) => this._onServerChannel(channel))
227
248
  // writeRequests carry no central identifier, so an unsubscribe can't be
228
249
  // mapped to a session; teardown is left to _onChannel's keepalive/timeout.
229
250
  this.server.on('unsubscribe', () => {})
@@ -233,6 +254,7 @@ export class BLETransport extends ReadyResource {
233
254
  this.central.on('stateChange', (s) => {
234
255
  this._onState(s)
235
256
  if (s === 'poweredOn') this._startScan()
257
+ else if (s === 'poweredOff' || s === 'resetting') this._onRadioDown()
236
258
  })
237
259
  this.central.on('discover', (peripheral) => this._onDiscover(peripheral))
238
260
  this.central.on('connect', (peripheral) => this._onConnect(peripheral))
@@ -250,6 +272,39 @@ export class BLETransport extends ReadyResource {
250
272
  this._dataChar = new Characteristic(DATA_UUID, { write: true, notify: true })
251
273
  this.server.addService(new Service(this.serviceUUID, [this._dataChar]))
252
274
  }
275
+ if (this.pipe === 'l2cap') this._publishListener()
276
+ }
277
+
278
+ _publishListener() {
279
+ if (this._psm !== null || typeof this.server.publishChannel !== 'function') return
280
+ try {
281
+ // unencrypted: cero's own protocols provide the crypto; encryption here
282
+ // would demand BLE pairing and stall centrals that never trigger it
283
+ this.server.publishChannel({})
284
+ } catch (err) {
285
+ safetyCatch(err)
286
+ }
287
+ }
288
+
289
+ _unpublishListener() {
290
+ if (this._psm === null) return
291
+ if (typeof this.server?.unpublishChannel === 'function') {
292
+ try {
293
+ this.server.unpublishChannel(this._psm)
294
+ } catch (err) {
295
+ safetyCatch(err)
296
+ }
297
+ }
298
+ this._psm = null
299
+ }
300
+
301
+ // Fresh listener, fresh psm — the next hello advertises it. A dead session
302
+ // leaves its channel state on the shared radio link, and the OS refuses a
303
+ // second open to a psm it remembers there.
304
+ _cycleListener() {
305
+ if (this.pipe !== 'l2cap' || this._suspended || this.closing || this.closed) return
306
+ this._unpublishListener()
307
+ this._publishListener()
253
308
  }
254
309
 
255
310
  _maybeAdvertise() {
@@ -282,55 +337,136 @@ export class BLETransport extends ReadyResource {
282
337
  return
283
338
  }
284
339
  const sid = b4a.from(f.sid) // copy: f.sid views the transient request buffer
285
- const stream = new GattStream({
286
- send: (payload) => this._enqueueNotify(frame(TYPE_DATA, sid, payload)),
287
- onclose: () => this._closeServerSession(f.sidHex, sid)
288
- })
289
- const session = { stream, sid, conn: null, name: null }
340
+ const session = { stream: null, sid, conn: null, name: null, pipeTimer: null }
290
341
  this._sessions.set(f.sidHex, session)
291
- session.conn = this._onChannel(stream, false, null)
342
+ if (this.pipe === 'l2cap' && this._psm !== null) {
343
+ // the session has no stream until the central opens our channel and
344
+ // its sid preamble matches — reap it if that never happens
345
+ session.pipeTimer = setTimeout(() => {
346
+ session.pipeTimer = null
347
+ if (this._sessions.get(f.sidHex) !== session || session.stream) return
348
+ this._reapSession(f.sidHex, session)
349
+ this._enqueueNotify(frame(TYPE_CLOSE, sid)).catch(safetyCatch)
350
+ }, PIPE_PENDING_TIMEOUT)
351
+ if (session.pipeTimer.unref) session.pipeTimer.unref()
352
+ } else {
353
+ this._openServerGatt(f.sidHex, session)
354
+ }
292
355
  this._enqueueNotify(frame(TYPE_HELLO, sid, this._helloPayload())).catch(safetyCatch)
293
356
  } else if (f.type === TYPE_DATA) {
294
357
  const s = this._sessions.get(f.sidHex)
295
- if (s) s.stream.receive(b4a.from(f.payload))
358
+ if (!s) return
359
+ if (!s.stream) {
360
+ // gatt data on a session awaiting its l2cap channel is a pipe
361
+ // mismatch — close instead of silently degrading
362
+ this._reapSession(f.sidHex, s)
363
+ this._enqueueNotify(frame(TYPE_CLOSE, f.sid)).catch(safetyCatch)
364
+ return
365
+ }
366
+ s.stream.receive(b4a.from(f.payload))
296
367
  } else if (f.type === TYPE_HELLO) {
297
368
  const s = this._sessions.get(f.sidHex)
298
369
  if (s) this._applyPeerName(s, f.payload)
299
370
  } else if (f.type === TYPE_CLOSE) {
300
371
  const s = this._sessions.get(f.sidHex)
301
372
  if (s) {
302
- this._sessions.delete(f.sidHex)
303
- s.stream.remoteEnd()
373
+ this._reapSession(f.sidHex, s)
374
+ if (s.stream) s.stream.remoteEnd()
304
375
  }
305
376
  }
306
377
  }
307
378
 
308
- _closeServerSession(sidHex, sid) {
309
- if (!this._sessions.has(sidHex)) return
379
+ _openServerGatt(sidHex, session) {
380
+ const sid = session.sid
381
+ session.stream = new GattStream({
382
+ send: (payload) => this._enqueueNotify(frame(TYPE_DATA, sid, payload)),
383
+ onclose: () => this._closeServerSession(sidHex, sid)
384
+ })
385
+ session.conn = this._onChannel(session.stream, false, null)
386
+ }
387
+
388
+ // Incoming l2cap channel: the central writes its 8-byte session id first,
389
+ // matching the channel to the session negotiated over the characteristic.
390
+ _onServerChannel(channel) {
391
+ if (this.closing || this.closed || this._suspended) {
392
+ try {
393
+ channel.destroy()
394
+ } catch (err) {
395
+ safetyCatch(err)
396
+ }
397
+ return
398
+ }
399
+ readSidPreamble(
400
+ channel,
401
+ SID_LEN,
402
+ (sid, leftover) => {
403
+ const sidHex = sid !== null ? b4a.toString(sid, 'hex') : null
404
+ const session = sidHex !== null ? this._sessions.get(sidHex) : undefined
405
+ if (!session || session.stream) {
406
+ try {
407
+ channel.destroy()
408
+ } catch (err) {
409
+ safetyCatch(err)
410
+ }
411
+ return
412
+ }
413
+ if (session.pipeTimer) clearTimeout(session.pipeTimer)
414
+ session.pipeTimer = null
415
+ session.stream = new L2CAPStream(channel, {
416
+ onclose: () => this._closeServerSession(sidHex, session.sid)
417
+ })
418
+ session.conn = this._onChannel(session.stream, false, null)
419
+ if (session.name && session.conn) session.conn._peerName = session.name
420
+ if (leftover.byteLength) session.stream.receive(leftover)
421
+ },
422
+ this._l2capTimeout
423
+ )
424
+ }
425
+
426
+ _reapSession(sidHex, session) {
427
+ if (session.pipeTimer) clearTimeout(session.pipeTimer)
428
+ session.pipeTimer = null
310
429
  this._sessions.delete(sidHex)
430
+ this._cycleListener()
431
+ }
432
+
433
+ _closeServerSession(sidHex, sid) {
434
+ const session = this._sessions.get(sidHex)
435
+ if (!session) return
436
+ this._reapSession(sidHex, session)
311
437
  this._enqueueNotify(frame(TYPE_CLOSE, sid)).catch(safetyCatch)
312
438
  }
313
439
 
314
440
  // ─── peer display name (hello frame) ──────────────────────────────────────
315
441
 
316
442
  _helloPayload() {
317
- return b4a.from(JSON.stringify({ n: this.name || '' }))
443
+ const hello = { n: this.name || '' }
444
+ // servers advertise their l2cap listener so the central can open a channel
445
+ if (this.pipe === 'l2cap' && this._psm !== null) hello.p = this._psm
446
+ return b4a.from(JSON.stringify(hello))
318
447
  }
319
448
 
449
+ /**
450
+ * @param {Uint8Array} payload
451
+ * @returns {{ name: string, psm: number | null } | null}
452
+ */
320
453
  _parseHello(payload) {
321
454
  try {
322
- const { n } = JSON.parse(b4a.toString(payload))
323
- return typeof n === 'string' ? n : ''
455
+ const { n, p } = JSON.parse(b4a.toString(payload))
456
+ return {
457
+ name: typeof n === 'string' ? n : '',
458
+ psm: Number.isInteger(p) ? p : null
459
+ }
324
460
  } catch {
325
461
  return null
326
462
  }
327
463
  }
328
464
 
329
465
  _applyPeerName(session, payload) {
330
- const name = this._parseHello(payload)
331
- if (name === null) return
332
- session.name = name
333
- if (session.conn) session.conn._peerName = name
466
+ const hello = this._parseHello(payload)
467
+ if (hello === null) return
468
+ session.name = hello.name
469
+ if (session.conn) session.conn._peerName = hello.name
334
470
  this.emit('update')
335
471
  }
336
472
 
@@ -408,6 +544,41 @@ export class BLETransport extends ReadyResource {
408
544
  }
409
545
  }
410
546
 
547
+ /**
548
+ * A radio power cycle invalidates the GATT service, advertising, scans,
549
+ * subscriptions and every open link, but the bookkeeping flags survive —
550
+ * without a reset the device never re-registers or re-advertises and goes
551
+ * dark until the app-level toggle is cycled. Reset so the poweredOn
552
+ * handlers bootstrap everything from scratch.
553
+ */
554
+ _onRadioDown() {
555
+ this._serviceAdded = false
556
+ this._advertising = false
557
+ this._scanning = false
558
+ // the power cycle wiped the GATT db — the listener is gone with it
559
+ this._psm = null
560
+ for (const d of this._devices.values()) if (d.timer) clearTimeout(d.timer)
561
+ this._devices.clear()
562
+ for (const conn of this.peers.values()) {
563
+ try {
564
+ conn.destroy()
565
+ } catch (err) {
566
+ safetyCatch(err)
567
+ }
568
+ }
569
+ for (const { stream, pipeTimer } of this._sessions.values()) {
570
+ if (pipeTimer) clearTimeout(pipeTimer)
571
+ try {
572
+ if (stream) stream.destroy()
573
+ } catch (err) {
574
+ safetyCatch(err)
575
+ }
576
+ }
577
+ this._sessions.clear()
578
+ for (const item of this._notifyQueue) item.reject(new Error('radio down'))
579
+ this._notifyQueue = []
580
+ }
581
+
411
582
  _onState(raw) {
412
583
  const next = STATE[raw] ?? 'waiting'
413
584
  if (next === this.state) return
@@ -441,7 +612,13 @@ export class BLETransport extends ReadyResource {
441
612
 
442
613
  _onConnect(peripheral) {
443
614
  this._device(peripheral.id).peripheral = peripheral
444
- peripheral.on('error', () => this._abortDial(peripheral, 'peripheral-error'))
615
+ peripheral.on('error', (err) => {
616
+ // platforms emit benign error events during openL2CAPChannel — aborting
617
+ // would hang up the link under the in-flight open. The open's own
618
+ // deadline covers a genuinely dead link.
619
+ if (peripheral._session?.upgrading) return
620
+ this._abortDial(peripheral, err?.message ?? 'peripheral-error')
621
+ })
445
622
  peripheral.once('servicesDiscover', (services) => {
446
623
  const svc = findByUUID(services, this.serviceUUID)
447
624
  if (svc) peripheral.discoverCharacteristics(svc, [DATA_UUID])
@@ -470,48 +647,144 @@ export class BLETransport extends ReadyResource {
470
647
  _startCentralSession(peripheral, char) {
471
648
  const sid = randomBytes(SID_LEN)
472
649
  const sidHex = b4a.toString(sid, 'hex')
473
- peripheral._session = { sidHex, sid }
650
+ peripheral._session = { sidHex, sid, upgrading: false }
474
651
  peripheral._char = char // suspend's goodbye writes reuse it
475
652
  peripheral.on('notify', (_char, data) => this._onCentralNotify(peripheral, data))
476
- const stream = new GattStream({
477
- send: (payload) => this._centralSend(peripheral, char, frame(TYPE_DATA, sid, payload)),
478
- onclose: () => {
479
- this._centralSend(peripheral, char, frame(TYPE_CLOSE, sid)).catch(safetyCatch)
480
- const d = this._devices.get(peripheral.id)
481
- if (d) d.peripheral = null
482
- try {
483
- this.central.disconnect(peripheral)
484
- } catch (err) {
485
- safetyCatch(err)
486
- }
487
- }
488
- })
489
- peripheral._stream = stream
490
653
  // open frame first: it registers the session on the server before any data
491
654
  this._centralSend(peripheral, char, frame(TYPE_OPEN, sid)).catch(safetyCatch)
492
655
  this._centralSend(peripheral, char, frame(TYPE_HELLO, sid, this._helloPayload())).catch(
493
656
  safetyCatch
494
657
  )
658
+ if (this.pipe === 'l2cap') {
659
+ // no stream yet — the server's hello carries the psm to open a channel
660
+ // to; the dial timer keeps running until the channel binds
661
+ return
662
+ }
663
+ this._openCentralGatt(peripheral, peripheral._session)
664
+ }
665
+
666
+ _openCentralGatt(peripheral, sess) {
667
+ const stream = new GattStream({
668
+ send: (payload) =>
669
+ this._centralSend(peripheral, peripheral._char, frame(TYPE_DATA, sess.sid, payload)),
670
+ onclose: () => this._closeCentralSession(peripheral, sess)
671
+ })
672
+ peripheral._stream = stream
495
673
  this._clearDial(peripheral.id)
496
674
  peripheral._conn = this._onChannel(stream, true, peripheral.id)
675
+ if (peripheral._peerName && peripheral._conn) peripheral._conn._peerName = peripheral._peerName
676
+ }
677
+
678
+ _closeCentralSession(peripheral, sess) {
679
+ this._centralSend(peripheral, peripheral._char, frame(TYPE_CLOSE, sess.sid)).catch(safetyCatch)
680
+ // platforms reuse peripheral objects across reconnects — stale refs here
681
+ // would make the next session look live and get dropped
682
+ peripheral._stream = null
683
+ peripheral._session = null
684
+ const d = this._devices.get(peripheral.id)
685
+ if (d) d.peripheral = null
686
+ try {
687
+ this.central.disconnect(peripheral)
688
+ } catch (err) {
689
+ safetyCatch(err)
690
+ }
691
+ }
692
+
693
+ // One open attempt under a deadline; failure aborts the dial and the
694
+ // cooldown/redial cycle tries again.
695
+ async _openCentralL2CAP(peripheral, sess, psm) {
696
+ const gone = () =>
697
+ peripheral._session !== sess || this.closing || this.closed || this._suspended
698
+ sess.upgrading = true
699
+ try {
700
+ const channel = await this._openChannel(peripheral, psm)
701
+ if (gone()) {
702
+ if (channel) {
703
+ try {
704
+ channel.destroy()
705
+ } catch (err) {
706
+ safetyCatch(err)
707
+ }
708
+ }
709
+ return
710
+ }
711
+ if (!channel) {
712
+ this._abortDial(peripheral, 'l2cap-failed')
713
+ return
714
+ }
715
+ // sid preamble first: the server matches the channel to the session
716
+ channel.write(sess.sid)
717
+ const stream = new L2CAPStream(channel, {
718
+ onclose: () => this._closeCentralSession(peripheral, sess)
719
+ })
720
+ peripheral._stream = stream
721
+ this._clearDial(peripheral.id)
722
+ peripheral._conn = this._onChannel(stream, true, peripheral.id)
723
+ if (peripheral._peerName && peripheral._conn) {
724
+ peripheral._conn._peerName = peripheral._peerName
725
+ }
726
+ } finally {
727
+ sess.upgrading = false
728
+ }
729
+ }
730
+
731
+ _openChannel(peripheral, psm) {
732
+ return new Promise((resolve) => {
733
+ let done = false
734
+ const finish = (channel) => {
735
+ if (done) {
736
+ if (channel) {
737
+ try {
738
+ channel.destroy()
739
+ } catch (err) {
740
+ safetyCatch(err)
741
+ }
742
+ }
743
+ return
744
+ }
745
+ done = true
746
+ clearTimeout(timer)
747
+ peripheral.removeListener('channelOpen', finish)
748
+ resolve(channel || null)
749
+ }
750
+ const timer = setTimeout(() => finish(null), this._l2capTimeout)
751
+ if (timer.unref) timer.unref()
752
+ peripheral.once('channelOpen', finish)
753
+ try {
754
+ peripheral.openL2CAPChannel(psm)
755
+ } catch (err) {
756
+ safetyCatch(err)
757
+ finish(null)
758
+ }
759
+ })
497
760
  }
498
761
 
499
762
  _onCentralNotify(peripheral, data) {
500
763
  const sess = peripheral._session
501
- if (!sess || !peripheral._stream) return
764
+ if (!sess) return
502
765
  const f = parseFrame(data)
503
766
  if (!f) return
504
767
  if (f.sidHex !== sess.sidHex) return // not our session
505
- if (f.type === TYPE_DATA) peripheral._stream.receive(b4a.from(f.payload))
506
- else if (f.type === TYPE_HELLO) {
507
- const name = this._parseHello(f.payload)
508
- if (name === null) return
509
- peripheral._peerName = name
510
- if (peripheral._conn) peripheral._conn._peerName = name
768
+ if (f.type === TYPE_DATA) {
769
+ if (peripheral._stream) peripheral._stream.receive(b4a.from(f.payload))
770
+ } else if (f.type === TYPE_HELLO) {
771
+ const hello = this._parseHello(f.payload)
772
+ if (hello === null) return
773
+ peripheral._peerName = hello.name
774
+ if (peripheral._conn) peripheral._conn._peerName = hello.name
511
775
  this.emit('update')
776
+ if (this.pipe === 'l2cap' && !peripheral._stream && !sess.upgrading) {
777
+ if (hello.psm === null) {
778
+ // the server has no l2cap listener — pipe mismatch, never degrade
779
+ this._abortDial(peripheral, 'no-l2cap')
780
+ return
781
+ }
782
+ this._openCentralL2CAP(peripheral, sess, hello.psm).catch(safetyCatch)
783
+ }
512
784
  } else if (f.type === TYPE_CLOSE) {
513
785
  peripheral._session = null
514
- peripheral._stream.remoteEnd()
786
+ if (peripheral._stream) peripheral._stream.remoteEnd()
787
+ else this._abortDial(peripheral, 'refused')
515
788
  }
516
789
  }
517
790
 
@@ -722,9 +995,10 @@ export class BLETransport extends ReadyResource {
722
995
  }
723
996
  }
724
997
  this.peers.clear()
725
- for (const { stream } of this._sessions.values()) {
998
+ for (const { stream, pipeTimer } of this._sessions.values()) {
999
+ if (pipeTimer) clearTimeout(pipeTimer)
726
1000
  try {
727
- stream.destroy()
1001
+ if (stream) stream.destroy()
728
1002
  } catch (err) {
729
1003
  safetyCatch(err)
730
1004
  }
@@ -732,6 +1006,9 @@ export class BLETransport extends ReadyResource {
732
1006
  this._sessions.clear()
733
1007
  for (const item of this._notifyQueue) item.reject(new Error('suspended'))
734
1008
  this._notifyQueue = []
1009
+ // macOS only: a listener that lives through destroyed channels wedges the
1010
+ // resumed manager. iOS tolerates no manager surgery — keep its listener.
1011
+ if (PLATFORM === 'darwin') this._unpublishListener()
735
1012
  this.state = 'off'
736
1013
  this.emit('update')
737
1014
  }
@@ -745,6 +1022,7 @@ export class BLETransport extends ReadyResource {
745
1022
  this._suspended = false
746
1023
  if (this.closing || this.closed) return
747
1024
  this._advertising = false
1025
+ if (this.pipe === 'l2cap') this._publishListener()
748
1026
  this._maybeAdvertise()
749
1027
  this._startScan()
750
1028
  const raw = this.central?.state ?? this.server?.state
@@ -781,6 +1059,9 @@ export class BLETransport extends ReadyResource {
781
1059
  // tear this down when we're actually dropping the links
782
1060
  for (const item of this._notifyQueue) item.reject(new Error('closed'))
783
1061
  this._notifyQueue = []
1062
+ for (const { pipeTimer } of this._sessions.values()) {
1063
+ if (pipeTimer) clearTimeout(pipeTimer)
1064
+ }
784
1065
  this._sessions.clear()
785
1066
  }
786
1067
  this.peers.clear()
@@ -0,0 +1,100 @@
1
+ import { Duplex } from 'streamx'
2
+ import b4a from 'b4a'
3
+
4
+ /**
5
+ * Byte pipe over an L2CAP channel — the GattStream-shaped wrapper so
6
+ * BLETransport can treat both pipes identically. The channel is already a
7
+ * reliable ordered duplex with credit-based flow control, so no framing or
8
+ * fragmentation is needed.
9
+ *
10
+ * @extends Duplex
11
+ */
12
+ export class L2CAPStream extends Duplex {
13
+ /**
14
+ * @param {any} channel bare-bluetooth L2CAPChannel (a duplex).
15
+ * @param {object} [opts]
16
+ * @param {() => void} [opts.onclose] Called once on teardown (send a close frame, disconnect).
17
+ */
18
+ constructor(channel, { onclose } = {}) {
19
+ super()
20
+ this.channel = channel
21
+ this._onclose = onclose || null
22
+
23
+ channel.on('data', (data) => {
24
+ // propagate read backpressure: hold the channel while our buffer is full
25
+ if (!this.push(b4a.from(data))) channel.pause()
26
+ })
27
+ channel.on('end', () => this.push(null))
28
+ channel.on('error', () => this.destroy())
29
+ channel.on('close', () => this.destroy())
30
+ }
31
+
32
+ _read(cb) {
33
+ this.channel.resume()
34
+ cb(null)
35
+ }
36
+
37
+ _write(chunk, cb) {
38
+ if (this.channel.write(chunk)) cb(null)
39
+ else this.channel.once('drain', () => cb(null))
40
+ }
41
+
42
+ receive(buffer) {
43
+ this.push(buffer)
44
+ }
45
+
46
+ remoteEnd() {
47
+ this.push(null)
48
+ }
49
+
50
+ _destroy(cb) {
51
+ const onclose = this._onclose
52
+ this._onclose = null
53
+ if (onclose) {
54
+ try {
55
+ onclose()
56
+ } catch {
57
+ // teardown is best-effort
58
+ }
59
+ }
60
+ try {
61
+ this.channel.destroy()
62
+ } catch {
63
+ // channel may already be gone
64
+ }
65
+ cb(null)
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Accumulate the session-id preamble a central writes first on a fresh
71
+ * channel, then hand (sid, leftover) to the callback exactly once. Owns the
72
+ * channel's data events until then so no bytes race past the switchover.
73
+ * Timeout → callback with (null, null).
74
+ *
75
+ * @param {any} channel
76
+ * @param {number} sidLen
77
+ * @param {(sid: Uint8Array | null, leftover: Uint8Array | null) => void} onSid
78
+ * @param {number} timeout
79
+ */
80
+ export function readSidPreamble(channel, sidLen, onSid, timeout) {
81
+ let buf = b4a.alloc(0)
82
+ let done = false
83
+
84
+ const finish = (sid, leftover) => {
85
+ if (done) return
86
+ done = true
87
+ clearTimeout(timer)
88
+ channel.removeListener('data', onData)
89
+ onSid(sid, leftover)
90
+ }
91
+
92
+ const onData = (data) => {
93
+ buf = b4a.concat([buf, b4a.from(data)])
94
+ if (buf.byteLength >= sidLen) finish(buf.subarray(0, sidLen), buf.subarray(sidLen))
95
+ }
96
+
97
+ const timer = setTimeout(() => finish(null, null), timeout)
98
+ if (timer.unref) timer.unref()
99
+ channel.on('data', onData)
100
+ }
@@ -69,7 +69,8 @@ export class Pairing extends ReadyResource {
69
69
  host = true,
70
70
  inviteEncoding = null,
71
71
  joinerEncoding = null,
72
- onerror = safetyCatch
72
+ onerror = safetyCatch,
73
+ onconsume = null
73
74
  } = {}) {
74
75
  super()
75
76
  if (!network) throw CeroError.REQUIRED('network')
@@ -82,6 +83,9 @@ export class Pairing extends ReadyResource {
82
83
  this.inviteEncoding = inviteEncoding
83
84
  this.joinerEncoding = joinerEncoding
84
85
  this._onerror = onerror
86
+ // Fired with an invite's hex id when this instance stops serving it
87
+ // (single-use settled, or expired) — lets the owner drop its persisted row.
88
+ this.onconsume = onconsume
85
89
 
86
90
  this._blind = null
87
91
  this._member = null
@@ -168,12 +172,68 @@ export class Pairing extends ReadyResource {
168
172
  seed: blind.seed,
169
173
  publicKey: blind.publicKey,
170
174
  invite,
171
- reuse
175
+ reuse,
176
+ // minted here and possibly not yet persisted/replicated — syncRows must
177
+ // never drop it for being absent from the store
178
+ local: true
172
179
  })
173
180
 
174
181
  return invite.toString()
175
182
  }
176
183
 
184
+ /**
185
+ * Look up the in-memory record for a minted invite by its string form.
186
+ *
187
+ * @param {string} inviteStr
188
+ * @returns {{ id: Uint8Array, seed: Uint8Array, publicKey: Uint8Array, invite: import('./invite.js').Invite, reuse: boolean } | null}
189
+ */
190
+ recordOf(inviteStr) {
191
+ for (const record of this._invites.values()) {
192
+ if (record.invite.toString() === inviteStr) return record
193
+ }
194
+ return null
195
+ }
196
+
197
+ /**
198
+ * Reconcile the served-invite set with persisted rows (the room's `invites`
199
+ * collection). Adds unknown rows so ANY member replica can serve them across
200
+ * restarts; drops replicated records whose row disappeared (revoked or
201
+ * consumed elsewhere). Locally-minted records not yet visible as rows are
202
+ * kept.
203
+ *
204
+ * @param {Array<{ id: string, invite: Uint8Array, publicKey: Uint8Array, seed: Uint8Array, reuse?: boolean }>} rows
205
+ */
206
+ syncRows(rows) {
207
+ const seen = new Set()
208
+ for (const row of rows) {
209
+ if (!row?.id || !row.invite || !row.publicKey || !row.seed) continue
210
+ seen.add(row.id)
211
+ const existing = this._invites.get(row.id)
212
+ if (existing) {
213
+ existing.local = false
214
+ continue
215
+ }
216
+ let invite
217
+ try {
218
+ invite = Invite.parse(b4a.toString(row.invite))
219
+ } catch {
220
+ continue
221
+ }
222
+ if (invite.expired) continue
223
+ this._invites.set(row.id, {
224
+ id: b4a.from(row.id, 'hex'),
225
+ seed: row.seed,
226
+ publicKey: row.publicKey,
227
+ invite,
228
+ reuse: !!row.reuse,
229
+ local: false
230
+ })
231
+ }
232
+ for (const [id, record] of this._invites) {
233
+ if (!seen.has(id) && !record.local) this._invites.delete(id)
234
+ }
235
+ }
236
+
177
237
  /**
178
238
  * Forget a previously-minted invite. New candidates carrying it will be
179
239
  * dropped silently.
@@ -273,6 +333,7 @@ export class Pairing extends ReadyResource {
273
333
  if (!record) return
274
334
  if (record.invite.expired) {
275
335
  this._invites.delete(id)
336
+ this.onconsume?.(id)
276
337
  return
277
338
  }
278
339
 
@@ -292,7 +353,10 @@ export class Pairing extends ReadyResource {
292
353
  seed: record.seed,
293
354
  userData,
294
355
  onsettle: () => {
295
- if (!record.reuse) this._invites.delete(id)
356
+ if (!record.reuse) {
357
+ this._invites.delete(id)
358
+ this.onconsume?.(id)
359
+ }
296
360
  }
297
361
  })
298
362
 
@@ -84,6 +84,7 @@ export class EpochAutobee {
84
84
  _epochRetrySeen: number;
85
85
  _bootState(): Promise<void>;
86
86
  _bumpPendingWriters(): Promise<boolean>;
87
+ _catchupMigratedNodes: any;
87
88
  _scheduleEpochRetry(): void;
88
89
  _close(): Promise<any>;
89
90
  }
@@ -33,8 +33,10 @@ export class BLETransport extends ReadyResource {
33
33
  * @param {{ scanMode?: any }} [opts.scanOptions] Platform scan options (e.g. Android low-power).
34
34
  * @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).
35
35
  * @param {string} [opts.name] Local app-user display name, sent to peers over a hello frame.
36
+ * @param {'l2cap' | 'gatt'} [opts.pipe] Data pipe: 'l2cap' (default — a real channel per session, several times faster) or 'gatt' (framed characteristic stream). Both peers must match.
37
+ * @param {{ timeout?: number }} [opts.l2cap] Deadline for an l2cap channel open.
36
38
  */
37
- constructor({ backend, network, uuid, nodeId, tag, maxOutbound, maxInbound, scanOptions, keepLinks, name }: {
39
+ constructor({ backend, network, uuid, nodeId, tag, maxOutbound, maxInbound, scanOptions, keepLinks, name, pipe, l2cap }: {
38
40
  backend: any;
39
41
  network: import("../index.js").Network;
40
42
  uuid: Uint8Array;
@@ -47,6 +49,10 @@ export class BLETransport extends ReadyResource {
47
49
  };
48
50
  keepLinks?: boolean;
49
51
  name?: string;
52
+ pipe?: "l2cap" | "gatt";
53
+ l2cap?: {
54
+ timeout?: number;
55
+ };
50
56
  });
51
57
  backend: any;
52
58
  network: import("../index.js").Network;
@@ -60,10 +66,14 @@ export class BLETransport extends ReadyResource {
60
66
  scanMode?: any;
61
67
  };
62
68
  keepLinks: boolean;
69
+ pipe: "l2cap" | "gatt";
70
+ _l2capTimeout: number;
63
71
  state: string;
64
72
  central: any;
65
73
  server: any;
66
74
  _dataChar: any;
75
+ /** the published l2cap listener's psm, advertised to centrals over hello */
76
+ _psm: any;
67
77
  /** sessionId hex → { stream, sid } for server-side (peripheral) sessions */
68
78
  _sessions: Map<any, any>;
69
79
  /** serialized server notify queue: { frame, resolve, reject } */
@@ -92,22 +102,47 @@ export class BLETransport extends ReadyResource {
92
102
  _device(id: any): any;
93
103
  _prune(id: any): void;
94
104
  _startServer(Service: any, Characteristic: any): void;
105
+ _publishListener(): void;
106
+ _unpublishListener(): void;
107
+ _cycleListener(): void;
95
108
  _maybeAdvertise(): void;
96
109
  _onWriteRequests(requests: any): void;
97
110
  _onServerFrame(data: any): void;
111
+ _openServerGatt(sidHex: any, session: any): void;
112
+ _onServerChannel(channel: any): void;
113
+ _reapSession(sidHex: any, session: any): void;
98
114
  _closeServerSession(sidHex: any, sid: any): void;
99
115
  _helloPayload(): any;
100
- _parseHello(payload: any): string;
116
+ /**
117
+ * @param {Uint8Array} payload
118
+ * @returns {{ name: string, psm: number | null } | null}
119
+ */
120
+ _parseHello(payload: Uint8Array): {
121
+ name: string;
122
+ psm: number | null;
123
+ } | null;
101
124
  _applyPeerName(session: any, payload: any): void;
102
125
  _enqueueNotify(f: any): Promise<any>;
103
126
  _drainNotify(): void;
104
127
  _startScan(): void;
105
128
  _armScanRestart(): void;
106
129
  _stopScan(): void;
130
+ /**
131
+ * A radio power cycle invalidates the GATT service, advertising, scans,
132
+ * subscriptions and every open link, but the bookkeeping flags survive —
133
+ * without a reset the device never re-registers or re-advertises and goes
134
+ * dark until the app-level toggle is cycled. Reset so the poweredOn
135
+ * handlers bootstrap everything from scratch.
136
+ */
137
+ _onRadioDown(): void;
107
138
  _onState(raw: any): void;
108
139
  _onDiscover(peripheral: any): void;
109
140
  _onConnect(peripheral: any): void;
110
141
  _startCentralSession(peripheral: any, char: any): void;
142
+ _openCentralGatt(peripheral: any, sess: any): void;
143
+ _closeCentralSession(peripheral: any, sess: any): void;
144
+ _openCentralL2CAP(peripheral: any, sess: any, psm: any): Promise<void>;
145
+ _openChannel(peripheral: any, psm: any): Promise<any>;
111
146
  _onCentralNotify(peripheral: any, data: any): void;
112
147
  _centralSend(peripheral: any, char: any, f: any): any;
113
148
  _writeOnce(peripheral: any, char: any, f: any): Promise<any>;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Accumulate the session-id preamble a central writes first on a fresh
3
+ * channel, then hand (sid, leftover) to the callback exactly once. Owns the
4
+ * channel's data events until then so no bytes race past the switchover.
5
+ * Timeout → callback with (null, null).
6
+ *
7
+ * @param {any} channel
8
+ * @param {number} sidLen
9
+ * @param {(sid: Uint8Array | null, leftover: Uint8Array | null) => void} onSid
10
+ * @param {number} timeout
11
+ */
12
+ export function readSidPreamble(channel: any, sidLen: number, onSid: (sid: Uint8Array | null, leftover: Uint8Array | null) => void, timeout: number): void;
13
+ /**
14
+ * Byte pipe over an L2CAP channel — the GattStream-shaped wrapper so
15
+ * BLETransport can treat both pipes identically. The channel is already a
16
+ * reliable ordered duplex with credit-based flow control, so no framing or
17
+ * fragmentation is needed.
18
+ *
19
+ * @extends Duplex
20
+ */
21
+ export class L2CAPStream extends Duplex<import("streamx").DuplexEvents> {
22
+ /**
23
+ * @param {any} channel bare-bluetooth L2CAPChannel (a duplex).
24
+ * @param {object} [opts]
25
+ * @param {() => void} [opts.onclose] Called once on teardown (send a close frame, disconnect).
26
+ */
27
+ constructor(channel: any, { onclose }?: {
28
+ onclose?: () => void;
29
+ });
30
+ channel: any;
31
+ _onclose: () => void;
32
+ _read(cb: any): void;
33
+ _write(chunk: any, cb: any): void;
34
+ receive(buffer: any): void;
35
+ remoteEnd(): void;
36
+ _destroy(cb: any): void;
37
+ }
38
+ import { Duplex } from 'streamx';
@@ -57,7 +57,7 @@ export class Pairing extends ReadyResource {
57
57
  */
58
58
  static isInvite(str: unknown): boolean;
59
59
  /** @param {PairingOpts} [opts] */
60
- constructor({ network, identity, topic, host, inviteEncoding, joinerEncoding, onerror }?: PairingOpts);
60
+ constructor({ network, identity, topic, host, inviteEncoding, joinerEncoding, onerror, onconsume }?: PairingOpts);
61
61
  network: import("../index.js").Network;
62
62
  identity: import("../index.js").Identity;
63
63
  topic: Uint8Array<ArrayBufferLike>;
@@ -65,6 +65,7 @@ export class Pairing extends ReadyResource {
65
65
  inviteEncoding: any;
66
66
  joinerEncoding: any;
67
67
  _onerror: (err: Error) => void;
68
+ onconsume: any;
68
69
  _blind: any;
69
70
  _member: any;
70
71
  _invites: Map<any, any>;
@@ -76,6 +77,35 @@ export class Pairing extends ReadyResource {
76
77
  * @returns {Promise<string>}
77
78
  */
78
79
  createInvite({ role, expiresIn, data, reuse }?: CreateInviteOpts): Promise<string>;
80
+ /**
81
+ * Look up the in-memory record for a minted invite by its string form.
82
+ *
83
+ * @param {string} inviteStr
84
+ * @returns {{ id: Uint8Array, seed: Uint8Array, publicKey: Uint8Array, invite: import('./invite.js').Invite, reuse: boolean } | null}
85
+ */
86
+ recordOf(inviteStr: string): {
87
+ id: Uint8Array;
88
+ seed: Uint8Array;
89
+ publicKey: Uint8Array;
90
+ invite: import("./invite.js").Invite;
91
+ reuse: boolean;
92
+ } | null;
93
+ /**
94
+ * Reconcile the served-invite set with persisted rows (the room's `invites`
95
+ * collection). Adds unknown rows so ANY member replica can serve them across
96
+ * restarts; drops replicated records whose row disappeared (revoked or
97
+ * consumed elsewhere). Locally-minted records not yet visible as rows are
98
+ * kept.
99
+ *
100
+ * @param {Array<{ id: string, invite: Uint8Array, publicKey: Uint8Array, seed: Uint8Array, reuse?: boolean }>} rows
101
+ */
102
+ syncRows(rows: Array<{
103
+ id: string;
104
+ invite: Uint8Array;
105
+ publicKey: Uint8Array;
106
+ seed: Uint8Array;
107
+ reuse?: boolean;
108
+ }>): void;
79
109
  /**
80
110
  * Forget a previously-minted invite. New candidates carrying it will be
81
111
  * dropped silently.