@cero-base/core 1.9.0 → 1.10.1

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.9.0",
3
+ "version": "1.10.1",
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, readIdPreamble } 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
@@ -40,17 +48,19 @@ const DRAIN_MS = 300
40
48
  const EMPTY = b4a.alloc(0)
41
49
 
42
50
  /**
43
- * Build a wire frame [type][sessionId][payload].
51
+ * Build a wire frame [type][sessionId][payload]. The session id is a hex
52
+ * string end to end — the one canonical form (it is also the sessions map
53
+ * key); the codec is the only place it touches bytes.
44
54
  *
45
55
  * @param {number} type
46
- * @param {Uint8Array} sid 8-byte session id.
56
+ * @param {string} id Session id (hex).
47
57
  * @param {Uint8Array} [payload]
48
58
  * @returns {Buffer}
49
59
  */
50
- function frame(type, sid, payload = EMPTY) {
60
+ function frame(type, id, payload = EMPTY) {
51
61
  const out = b4a.allocUnsafe(HEADER + payload.byteLength)
52
62
  out[0] = type
53
- b4a.copy(sid, out, 1)
63
+ b4a.write(out, id, 1, SID_LEN, 'hex')
54
64
  if (payload.byteLength) b4a.copy(payload, out, HEADER)
55
65
  return out
56
66
  }
@@ -59,15 +69,13 @@ function frame(type, sid, payload = EMPTY) {
59
69
  * Parse a wire frame. Short/empty buffers → null (caller drops).
60
70
  *
61
71
  * @param {Uint8Array} buf
62
- * @returns {{ type: number, sid: Uint8Array, sidHex: string, payload: Uint8Array } | null}
72
+ * @returns {{ type: number, id: string, payload: Uint8Array } | null}
63
73
  */
64
74
  function parseFrame(buf) {
65
75
  if (!buf || buf.byteLength < HEADER) return null
66
- const sid = buf.subarray(1, HEADER)
67
76
  return {
68
77
  type: buf[0],
69
- sid,
70
- sidHex: b4a.toString(sid, 'hex'),
78
+ id: b4a.toString(buf.subarray(1, HEADER), 'hex'),
71
79
  payload: buf.subarray(HEADER)
72
80
  }
73
81
  }
@@ -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,12 +168,16 @@ 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
164
- /** sessionId hex { stream, sid } for server-side (peripheral) sessions */
178
+ /** the published l2cap listener's psm, advertised to centrals over hello */
179
+ this._psm = null
180
+ /** id → { id, stream, conn, name, pipeTimer } for server-side (peripheral) sessions */
165
181
  this._sessions = new Map()
166
182
  /** serialized server notify queue: { frame, resolve, reject } */
167
183
  this._notifyQueue = []
@@ -170,9 +186,13 @@ export class BLETransport extends ReadyResource {
170
186
  this._serviceAdded = false
171
187
  /** peripheral id → per-peer dial state { timer, linked, coolUntil, failures, peerKey, peripheral } */
172
188
  this._devices = new Map()
189
+ /** rate-limited discoveries held for the next dial window */
190
+ this._candidates = new Map()
191
+ this._dialTimer = null
173
192
  /** last central.connect timestamp — global inter-dial rate limit */
174
193
  this._lastDial = 0
175
194
  this._scanTimer = null
195
+ this._cyclePending = false
176
196
  this._suspended = false
177
197
  /** live injected links keyed by remote node id hex */
178
198
  this.peers = new Map()
@@ -217,6 +237,7 @@ export class BLETransport extends ReadyResource {
217
237
  this.server.on('stateChange', (s) => {
218
238
  this._onState(s)
219
239
  if (s === 'poweredOn') this._startServer(Service, Characteristic)
240
+ else if (s === 'poweredOff' || s === 'resetting') this._onRadioDown()
220
241
  })
221
242
  this.server.on('serviceAdd', () => {
222
243
  this._serviceAdded = true
@@ -224,6 +245,10 @@ export class BLETransport extends ReadyResource {
224
245
  })
225
246
  this.server.on('writeRequest', (reqs) => this._onWriteRequests(reqs))
226
247
  this.server.on('readyToUpdate', () => this._drainNotify())
248
+ this.server.on('channelPublish', (psm) => {
249
+ this._psm = psm
250
+ })
251
+ this.server.on('channelOpen', (channel) => this._onServerChannel(channel))
227
252
  // writeRequests carry no central identifier, so an unsubscribe can't be
228
253
  // mapped to a session; teardown is left to _onChannel's keepalive/timeout.
229
254
  this.server.on('unsubscribe', () => {})
@@ -233,6 +258,7 @@ export class BLETransport extends ReadyResource {
233
258
  this.central.on('stateChange', (s) => {
234
259
  this._onState(s)
235
260
  if (s === 'poweredOn') this._startScan()
261
+ else if (s === 'poweredOff' || s === 'resetting') this._onRadioDown()
236
262
  })
237
263
  this.central.on('discover', (peripheral) => this._onDiscover(peripheral))
238
264
  this.central.on('connect', (peripheral) => this._onConnect(peripheral))
@@ -250,6 +276,47 @@ export class BLETransport extends ReadyResource {
250
276
  this._dataChar = new Characteristic(DATA_UUID, { write: true, notify: true })
251
277
  this.server.addService(new Service(this.serviceUUID, [this._dataChar]))
252
278
  }
279
+ if (this.pipe === 'l2cap') this._publishListener()
280
+ }
281
+
282
+ _publishListener() {
283
+ if (this._psm !== null || typeof this.server.publishChannel !== 'function') return
284
+ try {
285
+ // unencrypted: cero's own protocols provide the crypto; encryption here
286
+ // would demand BLE pairing and stall centrals that never trigger it
287
+ this.server.publishChannel({})
288
+ } catch (err) {
289
+ safetyCatch(err)
290
+ }
291
+ }
292
+
293
+ _unpublishListener() {
294
+ if (this._psm === null) return
295
+ if (typeof this.server?.unpublishChannel === 'function') {
296
+ try {
297
+ this.server.unpublishChannel(this._psm)
298
+ } catch (err) {
299
+ safetyCatch(err)
300
+ }
301
+ }
302
+ this._psm = null
303
+ }
304
+
305
+ // Fresh listener, fresh psm — the next hello advertises it. A dead session
306
+ // leaves its channel state on the shared radio link, and the OS refuses a
307
+ // second open to a psm it remembers there. Never yank the psm out from under
308
+ // a session still opening its channel — hold the rotation until the last
309
+ // pending session resolves (bind or reap).
310
+ _cycleListener() {
311
+ if (this.pipe !== 'l2cap' || this._suspended || this.closing || this.closed) return
312
+ for (const s of this._sessions.values()) {
313
+ if (s.stream) continue
314
+ this._cyclePending = true
315
+ return
316
+ }
317
+ this._cyclePending = false
318
+ this._unpublishListener()
319
+ this._publishListener()
253
320
  }
254
321
 
255
322
  _maybeAdvertise() {
@@ -273,64 +340,143 @@ export class BLETransport extends ReadyResource {
273
340
  _onServerFrame(data) {
274
341
  const f = parseFrame(data)
275
342
  if (!f) return
343
+ // an OPEN for a live session is a dup; every other type needs one
344
+ const s = this._sessions.get(f.id)
276
345
  if (f.type === TYPE_OPEN) {
277
- if (this._sessions.has(f.sidHex)) return
346
+ if (s) return
278
347
  if (this._sessions.size >= this.maxInbound) {
279
348
  // established links win: refuse newcomers with a CLOSE so the dialer's
280
349
  // stream ends cleanly and backs off — the mesh converges transitively.
281
- this._enqueueNotify(frame(TYPE_CLOSE, f.sid)).catch(safetyCatch)
350
+ this._notifyClose(f.id)
282
351
  return
283
352
  }
284
- 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 }
290
- this._sessions.set(f.sidHex, session)
291
- session.conn = this._onChannel(stream, false, null)
292
- this._enqueueNotify(frame(TYPE_HELLO, sid, this._helloPayload())).catch(safetyCatch)
353
+ const session = { id: f.id, stream: null, conn: null, name: null, pipeTimer: null }
354
+ this._sessions.set(f.id, session)
355
+ if (this.pipe === 'l2cap' && this._psm !== null) {
356
+ // the session has no stream until the central opens our channel and
357
+ // its id preamble matches — reap it if that never happens
358
+ session.pipeTimer = setTimeout(() => {
359
+ session.pipeTimer = null
360
+ if (this._sessions.get(f.id) !== session || session.stream) return
361
+ this._reapSession(f.id, session)
362
+ this._notifyClose(f.id)
363
+ }, PIPE_PENDING_TIMEOUT)
364
+ if (session.pipeTimer.unref) session.pipeTimer.unref()
365
+ } else {
366
+ this._openServerGatt(session)
367
+ }
368
+ this._enqueueNotify(frame(TYPE_HELLO, f.id, this._helloPayload())).catch(safetyCatch)
293
369
  } else if (f.type === TYPE_DATA) {
294
- const s = this._sessions.get(f.sidHex)
295
- if (s) s.stream.receive(b4a.from(f.payload))
370
+ if (!s) return
371
+ if (!s.stream) {
372
+ // gatt data on a session awaiting its l2cap channel is a pipe
373
+ // mismatch — close instead of silently degrading
374
+ this._closeServerSession(f.id)
375
+ return
376
+ }
377
+ s.stream.receive(b4a.from(f.payload))
296
378
  } else if (f.type === TYPE_HELLO) {
297
- const s = this._sessions.get(f.sidHex)
298
379
  if (s) this._applyPeerName(s, f.payload)
299
380
  } else if (f.type === TYPE_CLOSE) {
300
- const s = this._sessions.get(f.sidHex)
301
- if (s) {
302
- this._sessions.delete(f.sidHex)
303
- s.stream.remoteEnd()
381
+ if (!s) return
382
+ this._reapSession(f.id, s)
383
+ if (s.stream) s.stream.remoteEnd()
384
+ }
385
+ }
386
+
387
+ _openServerGatt(session) {
388
+ const stream = new GattStream({
389
+ send: (payload) => this._enqueueNotify(frame(TYPE_DATA, session.id, payload))
390
+ })
391
+ this._bindServerStream(session, stream)
392
+ }
393
+
394
+ // Incoming l2cap channel: the central writes its 8-byte session id first,
395
+ // matching the channel to the session negotiated over the characteristic.
396
+ async _onServerChannel(channel) {
397
+ if (this.closing || this.closed || this._suspended) {
398
+ try {
399
+ channel.destroy()
400
+ } catch (err) {
401
+ safetyCatch(err)
402
+ }
403
+ return
404
+ }
405
+ const { id, rest } = await readIdPreamble(channel, SID_LEN, this._l2capTimeout)
406
+ const session = id !== null ? this._sessions.get(id) : undefined
407
+ if (!session || session.stream) {
408
+ try {
409
+ channel.destroy()
410
+ } catch (err) {
411
+ safetyCatch(err)
304
412
  }
413
+ return
305
414
  }
415
+ const stream = new L2CAPStream(channel)
416
+ this._bindServerStream(session, stream)
417
+ if (rest.byteLength) stream.receive(rest)
418
+ }
419
+
420
+ // A pipe stream binds here — session wiring and close-time cleanup are
421
+ // transport concerns shared by both pipes.
422
+ _bindServerStream(session, stream) {
423
+ if (session.pipeTimer) clearTimeout(session.pipeTimer)
424
+ session.pipeTimer = null
425
+ session.stream = stream
426
+ stream.on('close', () => this._closeServerSession(session.id))
427
+ session.conn = this._onChannel(stream, false, null)
428
+ if (session.name && session.conn) session.conn._peerName = session.name
429
+ if (this._cyclePending) this._cycleListener()
430
+ }
431
+
432
+ _reapSession(id, session) {
433
+ if (session.pipeTimer) clearTimeout(session.pipeTimer)
434
+ session.pipeTimer = null
435
+ this._sessions.delete(id)
436
+ this._cycleListener()
437
+ }
438
+
439
+ _closeServerSession(id) {
440
+ const session = this._sessions.get(id)
441
+ if (!session) return
442
+ this._reapSession(id, session)
443
+ this._notifyClose(id)
306
444
  }
307
445
 
308
- _closeServerSession(sidHex, sid) {
309
- if (!this._sessions.has(sidHex)) return
310
- this._sessions.delete(sidHex)
311
- this._enqueueNotify(frame(TYPE_CLOSE, sid)).catch(safetyCatch)
446
+ _notifyClose(id) {
447
+ this._enqueueNotify(frame(TYPE_CLOSE, id)).catch(safetyCatch)
312
448
  }
313
449
 
314
450
  // ─── peer display name (hello frame) ──────────────────────────────────────
315
451
 
316
452
  _helloPayload() {
317
- return b4a.from(JSON.stringify({ n: this.name || '' }))
453
+ const hello = { n: this.name || '' }
454
+ // servers advertise their l2cap listener so the central can open a channel
455
+ if (this.pipe === 'l2cap' && this._psm !== null) hello.p = this._psm
456
+ return b4a.from(JSON.stringify(hello))
318
457
  }
319
458
 
459
+ /**
460
+ * @param {Uint8Array} payload
461
+ * @returns {{ name: string, psm: number | null } | null}
462
+ */
320
463
  _parseHello(payload) {
321
464
  try {
322
- const { n } = JSON.parse(b4a.toString(payload))
323
- return typeof n === 'string' ? n : ''
465
+ const { n, p } = JSON.parse(b4a.toString(payload))
466
+ return {
467
+ name: typeof n === 'string' ? n : '',
468
+ psm: Number.isInteger(p) ? p : null
469
+ }
324
470
  } catch {
325
471
  return null
326
472
  }
327
473
  }
328
474
 
329
475
  _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
476
+ const hello = this._parseHello(payload)
477
+ if (hello === null) return
478
+ session.name = hello.name
479
+ if (session.conn) session.conn._peerName = hello.name
334
480
  this.emit('update')
335
481
  }
336
482
 
@@ -408,6 +554,43 @@ export class BLETransport extends ReadyResource {
408
554
  }
409
555
  }
410
556
 
557
+ /**
558
+ * A radio power cycle invalidates the GATT service, advertising, scans,
559
+ * subscriptions and every open link, but the bookkeeping flags survive —
560
+ * without a reset the device never re-registers or re-advertises and goes
561
+ * dark until the app-level toggle is cycled. Reset so the poweredOn
562
+ * handlers bootstrap everything from scratch.
563
+ */
564
+ _onRadioDown() {
565
+ this._serviceAdded = false
566
+ this._advertising = false
567
+ this._scanning = false
568
+ // the power cycle wiped the GATT db — the listener is gone with it
569
+ this._psm = null
570
+ this._cyclePending = false
571
+ for (const d of this._devices.values()) if (d.timer) clearTimeout(d.timer)
572
+ this._devices.clear()
573
+ this._clearCandidates()
574
+ for (const conn of this.peers.values()) {
575
+ try {
576
+ conn.destroy()
577
+ } catch (err) {
578
+ safetyCatch(err)
579
+ }
580
+ }
581
+ for (const { stream, pipeTimer } of this._sessions.values()) {
582
+ if (pipeTimer) clearTimeout(pipeTimer)
583
+ try {
584
+ if (stream) stream.destroy()
585
+ } catch (err) {
586
+ safetyCatch(err)
587
+ }
588
+ }
589
+ this._sessions.clear()
590
+ for (const item of this._notifyQueue) item.reject(new Error('radio down'))
591
+ this._notifyQueue = []
592
+ }
593
+
411
594
  _onState(raw) {
412
595
  const next = STATE[raw] ?? 'waiting'
413
596
  if (next === this.state) return
@@ -425,7 +608,20 @@ export class BLETransport extends ReadyResource {
425
608
  if (d.timer) return // already connecting to this one
426
609
  }
427
610
  if (this.linkCount >= this.maxOutbound) return // gossip covers the rest
428
- if (Date.now() - this._lastDial < DIAL_MIN_INTERVAL) return
611
+ const wait = DIAL_MIN_INTERVAL - (Date.now() - this._lastDial)
612
+ if (wait > 0) {
613
+ // hold rate-limited discoveries (the radio may not re-report them until
614
+ // the next scan cycle) and dial the strongest signal when the window opens
615
+ this._candidates.set(peripheral.id, peripheral)
616
+ if (!this._dialTimer) {
617
+ this._dialTimer = setTimeout(() => {
618
+ this._dialTimer = null
619
+ this._flushCandidates()
620
+ }, wait)
621
+ if (this._dialTimer.unref) this._dialTimer.unref()
622
+ }
623
+ return
624
+ }
429
625
  // dial every discovery and open a session; a redundant link is dropped by
430
626
  // _track's dedup
431
627
  this._lastDial = Date.now()
@@ -439,9 +635,29 @@ export class BLETransport extends ReadyResource {
439
635
  }
440
636
  }
441
637
 
638
+ // strongest signal first — the nearest peer makes the best link. The first
639
+ // dial re-rate-limits the rest, so each window dials the strongest remaining.
640
+ _flushCandidates() {
641
+ const held = [...this._candidates.values()].sort((a, b) => (b.rssi ?? -100) - (a.rssi ?? -100))
642
+ this._candidates.clear()
643
+ for (const peripheral of held) this._onDiscover(peripheral)
644
+ }
645
+
646
+ _clearCandidates() {
647
+ if (this._dialTimer) clearTimeout(this._dialTimer)
648
+ this._dialTimer = null
649
+ this._candidates.clear()
650
+ }
651
+
442
652
  _onConnect(peripheral) {
443
653
  this._device(peripheral.id).peripheral = peripheral
444
- peripheral.on('error', () => this._abortDial(peripheral, 'peripheral-error'))
654
+ peripheral.on('error', (err) => {
655
+ // platforms emit benign error events during openL2CAPChannel — aborting
656
+ // would hang up the link under the in-flight open. The open's own
657
+ // deadline covers a genuinely dead link.
658
+ if (peripheral._session?.upgrading) return
659
+ this._abortDial(peripheral, err?.message ?? 'peripheral-error')
660
+ })
445
661
  peripheral.once('servicesDiscover', (services) => {
446
662
  const svc = findByUUID(services, this.serviceUUID)
447
663
  if (svc) peripheral.discoverCharacteristics(svc, [DATA_UUID])
@@ -468,50 +684,143 @@ export class BLETransport extends ReadyResource {
468
684
  // ─── central side ─────────────────────────────────────────────────────────
469
685
 
470
686
  _startCentralSession(peripheral, char) {
471
- const sid = randomBytes(SID_LEN)
472
- const sidHex = b4a.toString(sid, 'hex')
473
- peripheral._session = { sidHex, sid }
687
+ const id = b4a.toString(randomBytes(SID_LEN), 'hex')
688
+ peripheral._session = { id, upgrading: false }
474
689
  peripheral._char = char // suspend's goodbye writes reuse it
475
690
  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
691
  // open frame first: it registers the session on the server before any data
491
- this._centralSend(peripheral, char, frame(TYPE_OPEN, sid)).catch(safetyCatch)
492
- this._centralSend(peripheral, char, frame(TYPE_HELLO, sid, this._helloPayload())).catch(
692
+ this._centralSend(peripheral, char, frame(TYPE_OPEN, id)).catch(safetyCatch)
693
+ this._centralSend(peripheral, char, frame(TYPE_HELLO, id, this._helloPayload())).catch(
493
694
  safetyCatch
494
695
  )
696
+ if (this.pipe === 'l2cap') {
697
+ // no stream yet — the server's hello carries the psm to open a channel
698
+ // to; the dial timer keeps running until the channel binds
699
+ return
700
+ }
701
+ this._openCentralGatt(peripheral, peripheral._session)
702
+ }
703
+
704
+ _openCentralGatt(peripheral, sess) {
705
+ const stream = new GattStream({
706
+ send: (payload) =>
707
+ this._centralSend(peripheral, peripheral._char, frame(TYPE_DATA, sess.id, payload))
708
+ })
709
+ this._bindCentralStream(peripheral, sess, stream)
710
+ }
711
+
712
+ // A pipe stream binds here — dial state, peripheral refs and close-time
713
+ // cleanup are transport concerns shared by both pipes.
714
+ _bindCentralStream(peripheral, sess, stream) {
715
+ stream.on('close', () => this._closeCentralSession(peripheral, sess))
716
+ peripheral._stream = stream
495
717
  this._clearDial(peripheral.id)
496
718
  peripheral._conn = this._onChannel(stream, true, peripheral.id)
719
+ if (peripheral._peerName && peripheral._conn) peripheral._conn._peerName = peripheral._peerName
720
+ }
721
+
722
+ _closeCentralSession(peripheral, sess) {
723
+ this._centralSend(peripheral, peripheral._char, frame(TYPE_CLOSE, sess.id)).catch(safetyCatch)
724
+ // platforms reuse peripheral objects across reconnects — stale refs here
725
+ // would make the next session look live and get dropped
726
+ peripheral._stream = null
727
+ peripheral._session = null
728
+ const d = this._devices.get(peripheral.id)
729
+ if (d) d.peripheral = null
730
+ try {
731
+ this.central.disconnect(peripheral)
732
+ } catch (err) {
733
+ safetyCatch(err)
734
+ }
735
+ }
736
+
737
+ // One open attempt under a deadline; failure aborts the dial and the
738
+ // cooldown/redial cycle tries again.
739
+ async _openCentralL2CAP(peripheral, sess, psm) {
740
+ const gone = () =>
741
+ peripheral._session !== sess || this.closing || this.closed || this._suspended
742
+ sess.upgrading = true
743
+ try {
744
+ const channel = await this._openChannel(peripheral, psm)
745
+ if (gone()) {
746
+ if (channel) {
747
+ try {
748
+ channel.destroy()
749
+ } catch (err) {
750
+ safetyCatch(err)
751
+ }
752
+ }
753
+ return
754
+ }
755
+ if (!channel) {
756
+ this._abortDial(peripheral, 'l2cap-failed')
757
+ return
758
+ }
759
+ // id preamble first: the server matches the channel to the session
760
+ channel.write(b4a.from(sess.id, 'hex'))
761
+ this._bindCentralStream(peripheral, sess, new L2CAPStream(channel))
762
+ } finally {
763
+ sess.upgrading = false
764
+ }
765
+ }
766
+
767
+ _openChannel(peripheral, psm) {
768
+ return new Promise((resolve) => {
769
+ let done = false
770
+ const finish = (channel) => {
771
+ if (done) {
772
+ if (channel) {
773
+ try {
774
+ channel.destroy()
775
+ } catch (err) {
776
+ safetyCatch(err)
777
+ }
778
+ }
779
+ return
780
+ }
781
+ done = true
782
+ clearTimeout(timer)
783
+ peripheral.removeListener('channelOpen', finish)
784
+ resolve(channel || null)
785
+ }
786
+ const timer = setTimeout(() => finish(null), this._l2capTimeout)
787
+ if (timer.unref) timer.unref()
788
+ peripheral.once('channelOpen', finish)
789
+ try {
790
+ peripheral.openL2CAPChannel(psm)
791
+ } catch (err) {
792
+ safetyCatch(err)
793
+ finish(null)
794
+ }
795
+ })
497
796
  }
498
797
 
499
798
  _onCentralNotify(peripheral, data) {
500
799
  const sess = peripheral._session
501
- if (!sess || !peripheral._stream) return
800
+ if (!sess) return
502
801
  const f = parseFrame(data)
503
802
  if (!f) return
504
- 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
803
+ if (f.id !== sess.id) return // not our session
804
+ if (f.type === TYPE_DATA) {
805
+ if (peripheral._stream) peripheral._stream.receive(b4a.from(f.payload))
806
+ } else if (f.type === TYPE_HELLO) {
807
+ const hello = this._parseHello(f.payload)
808
+ if (hello === null) return
809
+ peripheral._peerName = hello.name
810
+ if (peripheral._conn) peripheral._conn._peerName = hello.name
511
811
  this.emit('update')
812
+ if (this.pipe === 'l2cap' && !peripheral._stream && !sess.upgrading) {
813
+ if (hello.psm === null) {
814
+ // the server has no l2cap listener — pipe mismatch, never degrade
815
+ this._abortDial(peripheral, 'no-l2cap')
816
+ return
817
+ }
818
+ this._openCentralL2CAP(peripheral, sess, hello.psm).catch(safetyCatch)
819
+ }
512
820
  } else if (f.type === TYPE_CLOSE) {
513
821
  peripheral._session = null
514
- peripheral._stream.remoteEnd()
822
+ if (peripheral._stream) peripheral._stream.remoteEnd()
823
+ else this._abortDial(peripheral, 'refused')
515
824
  }
516
825
  }
517
826
 
@@ -665,14 +974,14 @@ export class BLETransport extends ReadyResource {
665
974
  // flush. Resolves regardless: suspend must never hang on a wedged radio.
666
975
  async _sayGoodbye() {
667
976
  const sent = []
668
- for (const { sid } of this._sessions.values()) {
669
- sent.push(this._enqueueNotify(frame(TYPE_CLOSE, sid)).catch(safetyCatch))
977
+ for (const id of this._sessions.keys()) {
978
+ sent.push(this._enqueueNotify(frame(TYPE_CLOSE, id)).catch(safetyCatch))
670
979
  }
671
980
  for (const d of this._devices.values()) {
672
981
  const peripheral = d.peripheral
673
982
  const sess = peripheral && peripheral._session
674
983
  if (!sess || !peripheral._char) continue
675
- const f = frame(TYPE_CLOSE, sess.sid)
984
+ const f = frame(TYPE_CLOSE, sess.id)
676
985
  sent.push(this._centralSend(peripheral, peripheral._char, f).catch(safetyCatch))
677
986
  }
678
987
  if (!sent.length) return
@@ -689,7 +998,9 @@ export class BLETransport extends ReadyResource {
689
998
  this._suspended = true
690
999
  if (this._scanTimer) clearTimeout(this._scanTimer)
691
1000
  this._scanTimer = null
1001
+ this._cyclePending = false // suspend tears the sessions down; resume republishes fresh
692
1002
  for (const d of this._devices.values()) if (d.timer) clearTimeout(d.timer)
1003
+ this._clearCandidates()
693
1004
  // say goodbye BEFORE teardown so the remote reacts in <1s instead of waiting
694
1005
  // out the 15s keepalive: close frames, a short drain, then an ACL disconnect
695
1006
  // (an instant OS-level signal on both roles).
@@ -722,9 +1033,10 @@ export class BLETransport extends ReadyResource {
722
1033
  }
723
1034
  }
724
1035
  this.peers.clear()
725
- for (const { stream } of this._sessions.values()) {
1036
+ for (const { stream, pipeTimer } of this._sessions.values()) {
1037
+ if (pipeTimer) clearTimeout(pipeTimer)
726
1038
  try {
727
- stream.destroy()
1039
+ if (stream) stream.destroy()
728
1040
  } catch (err) {
729
1041
  safetyCatch(err)
730
1042
  }
@@ -732,6 +1044,9 @@ export class BLETransport extends ReadyResource {
732
1044
  this._sessions.clear()
733
1045
  for (const item of this._notifyQueue) item.reject(new Error('suspended'))
734
1046
  this._notifyQueue = []
1047
+ // macOS only: a listener that lives through destroyed channels wedges the
1048
+ // resumed manager. iOS tolerates no manager surgery — keep its listener.
1049
+ if (PLATFORM === 'darwin') this._unpublishListener()
735
1050
  this.state = 'off'
736
1051
  this.emit('update')
737
1052
  }
@@ -745,6 +1060,7 @@ export class BLETransport extends ReadyResource {
745
1060
  this._suspended = false
746
1061
  if (this.closing || this.closed) return
747
1062
  this._advertising = false
1063
+ if (this.pipe === 'l2cap') this._publishListener()
748
1064
  this._maybeAdvertise()
749
1065
  this._startScan()
750
1066
  const raw = this.central?.state ?? this.server?.state
@@ -759,6 +1075,7 @@ export class BLETransport extends ReadyResource {
759
1075
  // stop advertising/scanning and let the runtime reclaim.
760
1076
  for (const d of this._devices.values()) if (d.timer) clearTimeout(d.timer)
761
1077
  this._devices.clear()
1078
+ this._clearCandidates()
762
1079
  try {
763
1080
  this.central?.stopScan()
764
1081
  } catch (err) {
@@ -781,6 +1098,9 @@ export class BLETransport extends ReadyResource {
781
1098
  // tear this down when we're actually dropping the links
782
1099
  for (const item of this._notifyQueue) item.reject(new Error('closed'))
783
1100
  this._notifyQueue = []
1101
+ for (const { pipeTimer } of this._sessions.values()) {
1102
+ if (pipeTimer) clearTimeout(pipeTimer)
1103
+ }
784
1104
  this._sessions.clear()
785
1105
  }
786
1106
  this.peers.clear()
@@ -15,12 +15,10 @@ export class GattStream extends Duplex {
15
15
  /**
16
16
  * @param {object} opts
17
17
  * @param {(buffer: Uint8Array) => Promise<void>} opts.send Transmit one payload piece (transport frames it).
18
- * @param {() => void} [opts.onclose] Called once on teardown (send a close frame, disconnect).
19
18
  */
20
- constructor({ send, onclose } = {}) {
19
+ constructor({ send } = {}) {
21
20
  super()
22
21
  this._send = send
23
- this._onclose = onclose || null
24
22
  }
25
23
 
26
24
  async _write(chunk, cb) {
@@ -41,17 +39,4 @@ export class GattStream extends Duplex {
41
39
  remoteEnd() {
42
40
  this.push(null)
43
41
  }
44
-
45
- _destroy(cb) {
46
- const onclose = this._onclose
47
- this._onclose = null
48
- if (onclose) {
49
- try {
50
- onclose()
51
- } catch {
52
- // teardown is best-effort
53
- }
54
- }
55
- cb(null)
56
- }
57
42
  }
@@ -0,0 +1,90 @@
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
+ */
16
+ constructor(channel) {
17
+ super()
18
+ this.channel = channel
19
+
20
+ channel.on('data', (data) => {
21
+ // propagate read backpressure: hold the channel while our buffer is full
22
+ if (!this.push(b4a.from(data))) channel.pause()
23
+ })
24
+ channel.on('end', () => this.push(null))
25
+ channel.on('error', () => this.destroy())
26
+ channel.on('close', () => this.destroy())
27
+ }
28
+
29
+ _read(cb) {
30
+ this.channel.resume()
31
+ cb(null)
32
+ }
33
+
34
+ _write(chunk, cb) {
35
+ if (this.channel.write(chunk)) cb(null)
36
+ else this.channel.once('drain', () => cb(null))
37
+ }
38
+
39
+ receive(buffer) {
40
+ this.push(buffer)
41
+ }
42
+
43
+ remoteEnd() {
44
+ this.push(null)
45
+ }
46
+
47
+ _destroy(cb) {
48
+ try {
49
+ this.channel.destroy()
50
+ } catch {
51
+ // channel may already be gone
52
+ }
53
+ cb(null)
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Accumulate the session-id preamble a central writes first on a fresh
59
+ * channel. Resolves { id, rest } — id as a hex string and rest being any bytes
60
+ * delivered past it (the channel is a byte stream; the first payload bytes may
61
+ * arrive glued to the id). Owns the channel's data events until then so no
62
+ * bytes race past the switchover. Timeout resolves { id: null, rest: null }.
63
+ *
64
+ * @param {any} channel
65
+ * @param {number} idLen
66
+ * @param {number} timeout
67
+ * @returns {Promise<{ id: string | null, rest: Uint8Array | null }>}
68
+ */
69
+ export function readIdPreamble(channel, idLen, timeout) {
70
+ return new Promise((resolve) => {
71
+ let buf = b4a.alloc(0)
72
+
73
+ const finish = (id, rest) => {
74
+ clearTimeout(timer)
75
+ channel.removeListener('data', onData)
76
+ resolve({ id, rest })
77
+ }
78
+
79
+ const onData = (data) => {
80
+ buf = b4a.concat([buf, b4a.from(data)])
81
+ if (buf.byteLength >= idLen) {
82
+ finish(b4a.toString(buf.subarray(0, idLen), 'hex'), buf.subarray(idLen))
83
+ }
84
+ }
85
+
86
+ const timer = setTimeout(() => finish(null, null), timeout)
87
+ if (timer.unref) timer.unref()
88
+ channel.on('data', onData)
89
+ })
90
+ }
@@ -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,11 +66,15 @@ 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;
67
- /** sessionId hex { stream, sid } for server-side (peripheral) sessions */
75
+ /** the published l2cap listener's psm, advertised to centrals over hello */
76
+ _psm: any;
77
+ /** id → { id, stream, conn, name, pipeTimer } for server-side (peripheral) sessions */
68
78
  _sessions: Map<any, any>;
69
79
  /** serialized server notify queue: { frame, resolve, reject } */
70
80
  _notifyQueue: any[];
@@ -73,9 +83,13 @@ export class BLETransport extends ReadyResource {
73
83
  _serviceAdded: boolean;
74
84
  /** peripheral id → per-peer dial state { timer, linked, coolUntil, failures, peerKey, peripheral } */
75
85
  _devices: Map<any, any>;
86
+ /** rate-limited discoveries held for the next dial window */
87
+ _candidates: Map<any, any>;
88
+ _dialTimer: any;
76
89
  /** last central.connect timestamp — global inter-dial rate limit */
77
90
  _lastDial: number;
78
91
  _scanTimer: any;
92
+ _cyclePending: boolean;
79
93
  _suspended: boolean;
80
94
  /** live injected links keyed by remote node id hex */
81
95
  peers: Map<any, any>;
@@ -92,22 +106,52 @@ export class BLETransport extends ReadyResource {
92
106
  _device(id: any): any;
93
107
  _prune(id: any): void;
94
108
  _startServer(Service: any, Characteristic: any): void;
109
+ _publishListener(): void;
110
+ _unpublishListener(): void;
111
+ _cycleListener(): void;
95
112
  _maybeAdvertise(): void;
96
113
  _onWriteRequests(requests: any): void;
97
114
  _onServerFrame(data: any): void;
98
- _closeServerSession(sidHex: any, sid: any): void;
115
+ _openServerGatt(session: any): void;
116
+ _onServerChannel(channel: any): Promise<void>;
117
+ _bindServerStream(session: any, stream: any): void;
118
+ _reapSession(id: any, session: any): void;
119
+ _closeServerSession(id: any): void;
120
+ _notifyClose(id: any): void;
99
121
  _helloPayload(): any;
100
- _parseHello(payload: any): string;
122
+ /**
123
+ * @param {Uint8Array} payload
124
+ * @returns {{ name: string, psm: number | null } | null}
125
+ */
126
+ _parseHello(payload: Uint8Array): {
127
+ name: string;
128
+ psm: number | null;
129
+ } | null;
101
130
  _applyPeerName(session: any, payload: any): void;
102
131
  _enqueueNotify(f: any): Promise<any>;
103
132
  _drainNotify(): void;
104
133
  _startScan(): void;
105
134
  _armScanRestart(): void;
106
135
  _stopScan(): void;
136
+ /**
137
+ * A radio power cycle invalidates the GATT service, advertising, scans,
138
+ * subscriptions and every open link, but the bookkeeping flags survive —
139
+ * without a reset the device never re-registers or re-advertises and goes
140
+ * dark until the app-level toggle is cycled. Reset so the poweredOn
141
+ * handlers bootstrap everything from scratch.
142
+ */
143
+ _onRadioDown(): void;
107
144
  _onState(raw: any): void;
108
145
  _onDiscover(peripheral: any): void;
146
+ _flushCandidates(): void;
147
+ _clearCandidates(): void;
109
148
  _onConnect(peripheral: any): void;
110
149
  _startCentralSession(peripheral: any, char: any): void;
150
+ _openCentralGatt(peripheral: any, sess: any): void;
151
+ _bindCentralStream(peripheral: any, sess: any, stream: any): void;
152
+ _closeCentralSession(peripheral: any, sess: any): void;
153
+ _openCentralL2CAP(peripheral: any, sess: any, psm: any): Promise<void>;
154
+ _openChannel(peripheral: any, psm: any): Promise<any>;
111
155
  _onCentralNotify(peripheral: any, data: any): void;
112
156
  _centralSend(peripheral: any, char: any, f: any): any;
113
157
  _writeOnce(peripheral: any, char: any, f: any): Promise<any>;
@@ -9,17 +9,13 @@ export class GattStream extends Duplex<import("streamx").DuplexEvents> {
9
9
  /**
10
10
  * @param {object} opts
11
11
  * @param {(buffer: Uint8Array) => Promise<void>} opts.send Transmit one payload piece (transport frames it).
12
- * @param {() => void} [opts.onclose] Called once on teardown (send a close frame, disconnect).
13
12
  */
14
- constructor({ send, onclose }?: {
13
+ constructor({ send }?: {
15
14
  send: (buffer: Uint8Array) => Promise<void>;
16
- onclose?: () => void;
17
15
  });
18
16
  _send: (buffer: Uint8Array) => Promise<void>;
19
- _onclose: () => void;
20
17
  _write(chunk: any, cb: any): Promise<void>;
21
18
  receive(buffer: any): void;
22
19
  remoteEnd(): void;
23
- _destroy(cb: any): void;
24
20
  }
25
21
  import { Duplex } from 'streamx';
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Accumulate the session-id preamble a central writes first on a fresh
3
+ * channel. Resolves { id, rest } — id as a hex string and rest being any bytes
4
+ * delivered past it (the channel is a byte stream; the first payload bytes may
5
+ * arrive glued to the id). Owns the channel's data events until then so no
6
+ * bytes race past the switchover. Timeout resolves { id: null, rest: null }.
7
+ *
8
+ * @param {any} channel
9
+ * @param {number} idLen
10
+ * @param {number} timeout
11
+ * @returns {Promise<{ id: string | null, rest: Uint8Array | null }>}
12
+ */
13
+ export function readIdPreamble(channel: any, idLen: number, timeout: number): Promise<{
14
+ id: string | null;
15
+ rest: Uint8Array | null;
16
+ }>;
17
+ /**
18
+ * Byte pipe over an L2CAP channel — the GattStream-shaped wrapper so
19
+ * BLETransport can treat both pipes identically. The channel is already a
20
+ * reliable ordered duplex with credit-based flow control, so no framing or
21
+ * fragmentation is needed.
22
+ *
23
+ * @extends Duplex
24
+ */
25
+ export class L2CAPStream extends Duplex<import("streamx").DuplexEvents> {
26
+ /**
27
+ * @param {any} channel bare-bluetooth L2CAPChannel (a duplex).
28
+ */
29
+ constructor(channel: any);
30
+ channel: any;
31
+ _read(cb: any): void;
32
+ _write(chunk: any, cb: any): void;
33
+ receive(buffer: any): void;
34
+ remoteEnd(): void;
35
+ _destroy(cb: any): void;
36
+ }
37
+ import { Duplex } from 'streamx';