@cero-base/core 1.10.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.10.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,
@@ -4,7 +4,7 @@ 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
+ import { L2CAPStream, readIdPreamble } from './l2cap.js'
8
8
 
9
9
  // One data characteristic (write + notify) carries the framed control traffic
10
10
  // both ways — and, on the gatt pipe, the data too.
@@ -48,17 +48,19 @@ const DRAIN_MS = 300
48
48
  const EMPTY = b4a.alloc(0)
49
49
 
50
50
  /**
51
- * 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.
52
54
  *
53
55
  * @param {number} type
54
- * @param {Uint8Array} sid 8-byte session id.
56
+ * @param {string} id Session id (hex).
55
57
  * @param {Uint8Array} [payload]
56
58
  * @returns {Buffer}
57
59
  */
58
- function frame(type, sid, payload = EMPTY) {
60
+ function frame(type, id, payload = EMPTY) {
59
61
  const out = b4a.allocUnsafe(HEADER + payload.byteLength)
60
62
  out[0] = type
61
- b4a.copy(sid, out, 1)
63
+ b4a.write(out, id, 1, SID_LEN, 'hex')
62
64
  if (payload.byteLength) b4a.copy(payload, out, HEADER)
63
65
  return out
64
66
  }
@@ -67,15 +69,13 @@ function frame(type, sid, payload = EMPTY) {
67
69
  * Parse a wire frame. Short/empty buffers → null (caller drops).
68
70
  *
69
71
  * @param {Uint8Array} buf
70
- * @returns {{ type: number, sid: Uint8Array, sidHex: string, payload: Uint8Array } | null}
72
+ * @returns {{ type: number, id: string, payload: Uint8Array } | null}
71
73
  */
72
74
  function parseFrame(buf) {
73
75
  if (!buf || buf.byteLength < HEADER) return null
74
- const sid = buf.subarray(1, HEADER)
75
76
  return {
76
77
  type: buf[0],
77
- sid,
78
- sidHex: b4a.toString(sid, 'hex'),
78
+ id: b4a.toString(buf.subarray(1, HEADER), 'hex'),
79
79
  payload: buf.subarray(HEADER)
80
80
  }
81
81
  }
@@ -177,7 +177,7 @@ export class BLETransport extends ReadyResource {
177
177
  this._dataChar = null
178
178
  /** the published l2cap listener's psm, advertised to centrals over hello */
179
179
  this._psm = null
180
- /** sessionId hex → { stream, sid } for server-side (peripheral) sessions */
180
+ /** id → { id, stream, conn, name, pipeTimer } for server-side (peripheral) sessions */
181
181
  this._sessions = new Map()
182
182
  /** serialized server notify queue: { frame, resolve, reject } */
183
183
  this._notifyQueue = []
@@ -186,9 +186,13 @@ export class BLETransport extends ReadyResource {
186
186
  this._serviceAdded = false
187
187
  /** peripheral id → per-peer dial state { timer, linked, coolUntil, failures, peerKey, peripheral } */
188
188
  this._devices = new Map()
189
+ /** rate-limited discoveries held for the next dial window */
190
+ this._candidates = new Map()
191
+ this._dialTimer = null
189
192
  /** last central.connect timestamp — global inter-dial rate limit */
190
193
  this._lastDial = 0
191
194
  this._scanTimer = null
195
+ this._cyclePending = false
192
196
  this._suspended = false
193
197
  /** live injected links keyed by remote node id hex */
194
198
  this.peers = new Map()
@@ -300,9 +304,17 @@ export class BLETransport extends ReadyResource {
300
304
 
301
305
  // Fresh listener, fresh psm — the next hello advertises it. A dead session
302
306
  // leaves its channel state on the shared radio link, and the OS refuses a
303
- // second open to a psm it remembers there.
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).
304
310
  _cycleListener() {
305
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
306
318
  this._unpublishListener()
307
319
  this._publishListener()
308
320
  }
@@ -328,66 +340,60 @@ export class BLETransport extends ReadyResource {
328
340
  _onServerFrame(data) {
329
341
  const f = parseFrame(data)
330
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)
331
345
  if (f.type === TYPE_OPEN) {
332
- if (this._sessions.has(f.sidHex)) return
346
+ if (s) return
333
347
  if (this._sessions.size >= this.maxInbound) {
334
348
  // established links win: refuse newcomers with a CLOSE so the dialer's
335
349
  // stream ends cleanly and backs off — the mesh converges transitively.
336
- this._enqueueNotify(frame(TYPE_CLOSE, f.sid)).catch(safetyCatch)
350
+ this._notifyClose(f.id)
337
351
  return
338
352
  }
339
- const sid = b4a.from(f.sid) // copy: f.sid views the transient request buffer
340
- const session = { stream: null, sid, conn: null, name: null, pipeTimer: null }
341
- this._sessions.set(f.sidHex, session)
353
+ const session = { id: f.id, stream: null, conn: null, name: null, pipeTimer: null }
354
+ this._sessions.set(f.id, session)
342
355
  if (this.pipe === 'l2cap' && this._psm !== null) {
343
356
  // the session has no stream until the central opens our channel and
344
- // its sid preamble matches — reap it if that never happens
357
+ // its id preamble matches — reap it if that never happens
345
358
  session.pipeTimer = setTimeout(() => {
346
359
  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)
360
+ if (this._sessions.get(f.id) !== session || session.stream) return
361
+ this._reapSession(f.id, session)
362
+ this._notifyClose(f.id)
350
363
  }, PIPE_PENDING_TIMEOUT)
351
364
  if (session.pipeTimer.unref) session.pipeTimer.unref()
352
365
  } else {
353
- this._openServerGatt(f.sidHex, session)
366
+ this._openServerGatt(session)
354
367
  }
355
- this._enqueueNotify(frame(TYPE_HELLO, sid, this._helloPayload())).catch(safetyCatch)
368
+ this._enqueueNotify(frame(TYPE_HELLO, f.id, this._helloPayload())).catch(safetyCatch)
356
369
  } else if (f.type === TYPE_DATA) {
357
- const s = this._sessions.get(f.sidHex)
358
370
  if (!s) return
359
371
  if (!s.stream) {
360
372
  // gatt data on a session awaiting its l2cap channel is a pipe
361
373
  // mismatch — close instead of silently degrading
362
- this._reapSession(f.sidHex, s)
363
- this._enqueueNotify(frame(TYPE_CLOSE, f.sid)).catch(safetyCatch)
374
+ this._closeServerSession(f.id)
364
375
  return
365
376
  }
366
377
  s.stream.receive(b4a.from(f.payload))
367
378
  } else if (f.type === TYPE_HELLO) {
368
- const s = this._sessions.get(f.sidHex)
369
379
  if (s) this._applyPeerName(s, f.payload)
370
380
  } else if (f.type === TYPE_CLOSE) {
371
- const s = this._sessions.get(f.sidHex)
372
- if (s) {
373
- this._reapSession(f.sidHex, s)
374
- if (s.stream) s.stream.remoteEnd()
375
- }
381
+ if (!s) return
382
+ this._reapSession(f.id, s)
383
+ if (s.stream) s.stream.remoteEnd()
376
384
  }
377
385
  }
378
386
 
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)
387
+ _openServerGatt(session) {
388
+ const stream = new GattStream({
389
+ send: (payload) => this._enqueueNotify(frame(TYPE_DATA, session.id, payload))
384
390
  })
385
- session.conn = this._onChannel(session.stream, false, null)
391
+ this._bindServerStream(session, stream)
386
392
  }
387
393
 
388
394
  // Incoming l2cap channel: the central writes its 8-byte session id first,
389
395
  // matching the channel to the session negotiated over the characteristic.
390
- _onServerChannel(channel) {
396
+ async _onServerChannel(channel) {
391
397
  if (this.closing || this.closed || this._suspended) {
392
398
  try {
393
399
  channel.destroy()
@@ -396,45 +402,49 @@ export class BLETransport extends ReadyResource {
396
402
  }
397
403
  return
398
404
  }
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
- )
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)
412
+ }
413
+ return
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()
424
430
  }
425
431
 
426
- _reapSession(sidHex, session) {
432
+ _reapSession(id, session) {
427
433
  if (session.pipeTimer) clearTimeout(session.pipeTimer)
428
434
  session.pipeTimer = null
429
- this._sessions.delete(sidHex)
435
+ this._sessions.delete(id)
430
436
  this._cycleListener()
431
437
  }
432
438
 
433
- _closeServerSession(sidHex, sid) {
434
- const session = this._sessions.get(sidHex)
439
+ _closeServerSession(id) {
440
+ const session = this._sessions.get(id)
435
441
  if (!session) return
436
- this._reapSession(sidHex, session)
437
- this._enqueueNotify(frame(TYPE_CLOSE, sid)).catch(safetyCatch)
442
+ this._reapSession(id, session)
443
+ this._notifyClose(id)
444
+ }
445
+
446
+ _notifyClose(id) {
447
+ this._enqueueNotify(frame(TYPE_CLOSE, id)).catch(safetyCatch)
438
448
  }
439
449
 
440
450
  // ─── peer display name (hello frame) ──────────────────────────────────────
@@ -557,8 +567,10 @@ export class BLETransport extends ReadyResource {
557
567
  this._scanning = false
558
568
  // the power cycle wiped the GATT db — the listener is gone with it
559
569
  this._psm = null
570
+ this._cyclePending = false
560
571
  for (const d of this._devices.values()) if (d.timer) clearTimeout(d.timer)
561
572
  this._devices.clear()
573
+ this._clearCandidates()
562
574
  for (const conn of this.peers.values()) {
563
575
  try {
564
576
  conn.destroy()
@@ -596,7 +608,20 @@ export class BLETransport extends ReadyResource {
596
608
  if (d.timer) return // already connecting to this one
597
609
  }
598
610
  if (this.linkCount >= this.maxOutbound) return // gossip covers the rest
599
- 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
+ }
600
625
  // dial every discovery and open a session; a redundant link is dropped by
601
626
  // _track's dedup
602
627
  this._lastDial = Date.now()
@@ -610,6 +635,20 @@ export class BLETransport extends ReadyResource {
610
635
  }
611
636
  }
612
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
+
613
652
  _onConnect(peripheral) {
614
653
  this._device(peripheral.id).peripheral = peripheral
615
654
  peripheral.on('error', (err) => {
@@ -645,14 +684,13 @@ export class BLETransport extends ReadyResource {
645
684
  // ─── central side ─────────────────────────────────────────────────────────
646
685
 
647
686
  _startCentralSession(peripheral, char) {
648
- const sid = randomBytes(SID_LEN)
649
- const sidHex = b4a.toString(sid, 'hex')
650
- peripheral._session = { sidHex, sid, upgrading: false }
687
+ const id = b4a.toString(randomBytes(SID_LEN), 'hex')
688
+ peripheral._session = { id, upgrading: false }
651
689
  peripheral._char = char // suspend's goodbye writes reuse it
652
690
  peripheral.on('notify', (_char, data) => this._onCentralNotify(peripheral, data))
653
691
  // open frame first: it registers the session on the server before any data
654
- this._centralSend(peripheral, char, frame(TYPE_OPEN, sid)).catch(safetyCatch)
655
- 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(
656
694
  safetyCatch
657
695
  )
658
696
  if (this.pipe === 'l2cap') {
@@ -666,9 +704,15 @@ export class BLETransport extends ReadyResource {
666
704
  _openCentralGatt(peripheral, sess) {
667
705
  const stream = new GattStream({
668
706
  send: (payload) =>
669
- this._centralSend(peripheral, peripheral._char, frame(TYPE_DATA, sess.sid, payload)),
670
- onclose: () => this._closeCentralSession(peripheral, sess)
707
+ this._centralSend(peripheral, peripheral._char, frame(TYPE_DATA, sess.id, payload))
671
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))
672
716
  peripheral._stream = stream
673
717
  this._clearDial(peripheral.id)
674
718
  peripheral._conn = this._onChannel(stream, true, peripheral.id)
@@ -676,7 +720,7 @@ export class BLETransport extends ReadyResource {
676
720
  }
677
721
 
678
722
  _closeCentralSession(peripheral, sess) {
679
- this._centralSend(peripheral, peripheral._char, frame(TYPE_CLOSE, sess.sid)).catch(safetyCatch)
723
+ this._centralSend(peripheral, peripheral._char, frame(TYPE_CLOSE, sess.id)).catch(safetyCatch)
680
724
  // platforms reuse peripheral objects across reconnects — stale refs here
681
725
  // would make the next session look live and get dropped
682
726
  peripheral._stream = null
@@ -712,17 +756,9 @@ export class BLETransport extends ReadyResource {
712
756
  this._abortDial(peripheral, 'l2cap-failed')
713
757
  return
714
758
  }
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
- }
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))
726
762
  } finally {
727
763
  sess.upgrading = false
728
764
  }
@@ -764,7 +800,7 @@ export class BLETransport extends ReadyResource {
764
800
  if (!sess) return
765
801
  const f = parseFrame(data)
766
802
  if (!f) return
767
- if (f.sidHex !== sess.sidHex) return // not our session
803
+ if (f.id !== sess.id) return // not our session
768
804
  if (f.type === TYPE_DATA) {
769
805
  if (peripheral._stream) peripheral._stream.receive(b4a.from(f.payload))
770
806
  } else if (f.type === TYPE_HELLO) {
@@ -938,14 +974,14 @@ export class BLETransport extends ReadyResource {
938
974
  // flush. Resolves regardless: suspend must never hang on a wedged radio.
939
975
  async _sayGoodbye() {
940
976
  const sent = []
941
- for (const { sid } of this._sessions.values()) {
942
- 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))
943
979
  }
944
980
  for (const d of this._devices.values()) {
945
981
  const peripheral = d.peripheral
946
982
  const sess = peripheral && peripheral._session
947
983
  if (!sess || !peripheral._char) continue
948
- const f = frame(TYPE_CLOSE, sess.sid)
984
+ const f = frame(TYPE_CLOSE, sess.id)
949
985
  sent.push(this._centralSend(peripheral, peripheral._char, f).catch(safetyCatch))
950
986
  }
951
987
  if (!sent.length) return
@@ -962,7 +998,9 @@ export class BLETransport extends ReadyResource {
962
998
  this._suspended = true
963
999
  if (this._scanTimer) clearTimeout(this._scanTimer)
964
1000
  this._scanTimer = null
1001
+ this._cyclePending = false // suspend tears the sessions down; resume republishes fresh
965
1002
  for (const d of this._devices.values()) if (d.timer) clearTimeout(d.timer)
1003
+ this._clearCandidates()
966
1004
  // say goodbye BEFORE teardown so the remote reacts in <1s instead of waiting
967
1005
  // out the 15s keepalive: close frames, a short drain, then an ACL disconnect
968
1006
  // (an instant OS-level signal on both roles).
@@ -1037,6 +1075,7 @@ export class BLETransport extends ReadyResource {
1037
1075
  // stop advertising/scanning and let the runtime reclaim.
1038
1076
  for (const d of this._devices.values()) if (d.timer) clearTimeout(d.timer)
1039
1077
  this._devices.clear()
1078
+ this._clearCandidates()
1040
1079
  try {
1041
1080
  this.central?.stopScan()
1042
1081
  } catch (err) {
@@ -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
  }
@@ -12,13 +12,10 @@ import b4a from 'b4a'
12
12
  export class L2CAPStream extends Duplex {
13
13
  /**
14
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
15
  */
18
- constructor(channel, { onclose } = {}) {
16
+ constructor(channel) {
19
17
  super()
20
18
  this.channel = channel
21
- this._onclose = onclose || null
22
19
 
23
20
  channel.on('data', (data) => {
24
21
  // propagate read backpressure: hold the channel while our buffer is full
@@ -48,15 +45,6 @@ export class L2CAPStream extends Duplex {
48
45
  }
49
46
 
50
47
  _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
48
  try {
61
49
  this.channel.destroy()
62
50
  } catch {
@@ -68,33 +56,35 @@ export class L2CAPStream extends Duplex {
68
56
 
69
57
  /**
70
58
  * 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).
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 }.
74
63
  *
75
64
  * @param {any} channel
76
- * @param {number} sidLen
77
- * @param {(sid: Uint8Array | null, leftover: Uint8Array | null) => void} onSid
65
+ * @param {number} idLen
78
66
  * @param {number} timeout
67
+ * @returns {Promise<{ id: string | null, rest: Uint8Array | null }>}
79
68
  */
80
- export function readSidPreamble(channel, sidLen, onSid, timeout) {
81
- let buf = b4a.alloc(0)
82
- let done = false
69
+ export function readIdPreamble(channel, idLen, timeout) {
70
+ return new Promise((resolve) => {
71
+ let buf = b4a.alloc(0)
83
72
 
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
- }
73
+ const finish = (id, rest) => {
74
+ clearTimeout(timer)
75
+ channel.removeListener('data', onData)
76
+ resolve({ id, rest })
77
+ }
91
78
 
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
- }
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
+ }
96
85
 
97
- const timer = setTimeout(() => finish(null, null), timeout)
98
- if (timer.unref) timer.unref()
99
- channel.on('data', onData)
86
+ const timer = setTimeout(() => finish(null, null), timeout)
87
+ if (timer.unref) timer.unref()
88
+ channel.on('data', onData)
89
+ })
100
90
  }
@@ -74,7 +74,7 @@ export class BLETransport extends ReadyResource {
74
74
  _dataChar: any;
75
75
  /** the published l2cap listener's psm, advertised to centrals over hello */
76
76
  _psm: any;
77
- /** sessionId hex → { stream, sid } for server-side (peripheral) sessions */
77
+ /** id → { id, stream, conn, name, pipeTimer } for server-side (peripheral) sessions */
78
78
  _sessions: Map<any, any>;
79
79
  /** serialized server notify queue: { frame, resolve, reject } */
80
80
  _notifyQueue: any[];
@@ -83,9 +83,13 @@ export class BLETransport extends ReadyResource {
83
83
  _serviceAdded: boolean;
84
84
  /** peripheral id → per-peer dial state { timer, linked, coolUntil, failures, peerKey, peripheral } */
85
85
  _devices: Map<any, any>;
86
+ /** rate-limited discoveries held for the next dial window */
87
+ _candidates: Map<any, any>;
88
+ _dialTimer: any;
86
89
  /** last central.connect timestamp — global inter-dial rate limit */
87
90
  _lastDial: number;
88
91
  _scanTimer: any;
92
+ _cyclePending: boolean;
89
93
  _suspended: boolean;
90
94
  /** live injected links keyed by remote node id hex */
91
95
  peers: Map<any, any>;
@@ -108,10 +112,12 @@ export class BLETransport extends ReadyResource {
108
112
  _maybeAdvertise(): void;
109
113
  _onWriteRequests(requests: any): void;
110
114
  _onServerFrame(data: any): void;
111
- _openServerGatt(sidHex: any, session: any): void;
112
- _onServerChannel(channel: any): void;
113
- _reapSession(sidHex: any, session: any): void;
114
- _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;
115
121
  _helloPayload(): any;
116
122
  /**
117
123
  * @param {Uint8Array} payload
@@ -137,9 +143,12 @@ export class BLETransport extends ReadyResource {
137
143
  _onRadioDown(): void;
138
144
  _onState(raw: any): void;
139
145
  _onDiscover(peripheral: any): void;
146
+ _flushCandidates(): void;
147
+ _clearCandidates(): void;
140
148
  _onConnect(peripheral: any): void;
141
149
  _startCentralSession(peripheral: any, char: any): void;
142
150
  _openCentralGatt(peripheral: any, sess: any): void;
151
+ _bindCentralStream(peripheral: any, sess: any, stream: any): void;
143
152
  _closeCentralSession(peripheral: any, sess: any): void;
144
153
  _openCentralL2CAP(peripheral: any, sess: any, psm: any): Promise<void>;
145
154
  _openChannel(peripheral: any, psm: 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';
@@ -1,15 +1,19 @@
1
1
  /**
2
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).
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 }.
6
7
  *
7
8
  * @param {any} channel
8
- * @param {number} sidLen
9
- * @param {(sid: Uint8Array | null, leftover: Uint8Array | null) => void} onSid
9
+ * @param {number} idLen
10
10
  * @param {number} timeout
11
+ * @returns {Promise<{ id: string | null, rest: Uint8Array | null }>}
11
12
  */
12
- export function readSidPreamble(channel: any, sidLen: number, onSid: (sid: Uint8Array | null, leftover: Uint8Array | null) => void, timeout: number): void;
13
+ export function readIdPreamble(channel: any, idLen: number, timeout: number): Promise<{
14
+ id: string | null;
15
+ rest: Uint8Array | null;
16
+ }>;
13
17
  /**
14
18
  * Byte pipe over an L2CAP channel — the GattStream-shaped wrapper so
15
19
  * BLETransport can treat both pipes identically. The channel is already a
@@ -21,14 +25,9 @@ export function readSidPreamble(channel: any, sidLen: number, onSid: (sid: Uint8
21
25
  export class L2CAPStream extends Duplex<import("streamx").DuplexEvents> {
22
26
  /**
23
27
  * @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
28
  */
27
- constructor(channel: any, { onclose }?: {
28
- onclose?: () => void;
29
- });
29
+ constructor(channel: any);
30
30
  channel: any;
31
- _onclose: () => void;
32
31
  _read(cb: any): void;
33
32
  _write(chunk: any, cb: any): void;
34
33
  receive(buffer: any): void;