@cero-base/core 1.2.0 → 1.3.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.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "cero p2p primitives — identity, storage, network, database, blobs, rpc, pairing.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -1,15 +1,77 @@
1
1
  import ReadyResource from 'ready-resource'
2
2
  import b4a from 'b4a'
3
- import { hash } from 'hypercore-crypto'
3
+ import { hash, randomBytes } from 'hypercore-crypto'
4
4
  import safetyCatch from 'safety-catch'
5
5
 
6
- // Readable characteristic carrying "<psm>:<nodeId-hex>" — the L2CAP PSM to
7
- // connect on plus the advertiser's stable id for the initiate tie-break. Read
8
- // over GATT after connecting (bare-mobile-doctor reads the PSM the same way);
9
- // advertisement service-data is not a portable channel for this.
10
- const PSM_UUID = 'ce1a0003-0000-1000-8000-00805f9b34fb'
6
+ import { GattStream } from './gatt-stream.js'
7
+
8
+ // One data characteristic (write + notify) carries a framed byte stream both
9
+ // ways. iOS never answers an L2CAP channel opened by a Mac central and gives no
10
+ // L2CAP disconnect signal; GATT with app-level framing is the proven transport
11
+ // (the bitchat mesh app uses exactly this on iOS and Android).
12
+ const DATA_UUID = 'ce1a0004-0000-1000-8000-00805f9b34fb'
13
+
14
+ // Wire frame on the characteristic (both directions): [type:1][sessionId:8][payload].
15
+ const TYPE_OPEN = 1
16
+ const TYPE_DATA = 2
17
+ const TYPE_CLOSE = 3
18
+ const TYPE_HELLO = 4
19
+ const SID_LEN = 8
20
+ const HEADER = 1 + SID_LEN
21
+
11
22
  const DEFAULT_CAP = 4
12
23
  const CONNECT_TIMEOUT = 15000
24
+ // per-peer dial backoff bases (bitchat model): eager while unlinked, patient once
25
+ // we have a link. The actual cooldown grows exponentially per consecutive failure.
26
+ const DIAL_COOLDOWN_BASE = 8000
27
+ const DIAL_COOLDOWN_BASE_LONELY = 2000
28
+ const DIAL_COOLDOWN_MAX = 30000
29
+ // no two dials globally closer than this — one radio can't usefully dial faster
30
+ const DIAL_MIN_INTERVAL = 500
31
+ // Lonely: scan continuously, cycling every SCAN_RESTART_LONELY so a re-advertised
32
+ // peer is re-reported (iOS suppresses duplicate discoveries within a scan session).
33
+ const SCAN_RESTART_LONELY = 5000
34
+ // Linked: continuous scanning is the dominant BLE battery cost, so duty-cycle it
35
+ // (bitchat model) — scan for SCAN_DUTY_ON, then stay dark for SCAN_DUTY_OFF.
36
+ const SCAN_DUTY_ON = 5000
37
+ const SCAN_DUTY_OFF = 25000
38
+ // suspend() drain window: how long to let goodbye frames flush before hanging up.
39
+ const DRAIN_MS = 300
40
+
41
+ const EMPTY = b4a.alloc(0)
42
+
43
+ /**
44
+ * Build a wire frame [type][sessionId][payload].
45
+ *
46
+ * @param {number} type
47
+ * @param {Uint8Array} sid 8-byte session id.
48
+ * @param {Uint8Array} [payload]
49
+ * @returns {Buffer}
50
+ */
51
+ function frame(type, sid, payload = EMPTY) {
52
+ const out = b4a.allocUnsafe(HEADER + payload.byteLength)
53
+ out[0] = type
54
+ b4a.copy(sid, out, 1)
55
+ if (payload.byteLength) b4a.copy(payload, out, HEADER)
56
+ return out
57
+ }
58
+
59
+ /**
60
+ * Parse a wire frame. Short/empty buffers → null (the caller logs + drops).
61
+ *
62
+ * @param {Uint8Array} buf
63
+ * @returns {{ type: number, sid: Uint8Array, sidHex: string, payload: Uint8Array } | null}
64
+ */
65
+ function parseFrame(buf) {
66
+ if (!buf || buf.byteLength < HEADER) return null
67
+ const sid = buf.subarray(1, HEADER)
68
+ return {
69
+ type: buf[0],
70
+ sid,
71
+ sidHex: b4a.toString(sid, 'hex'),
72
+ payload: buf.subarray(HEADER)
73
+ }
74
+ }
13
75
 
14
76
  /**
15
77
  * Derive a stable 128-bit BLE service UUID from a topic. Only devices that
@@ -46,14 +108,15 @@ const uuidEq = (a, b) =>
46
108
  const findByUUID = (items, uuid) => (items || []).find((i) => uuidEq(i.uuid, uuid)) || null
47
109
 
48
110
  /**
49
- * Dual-role BLE transport: advertises + scans one service UUID, opens an L2CAP
50
- * channel to each discovered peer, and feeds it into `network.inject`. From
111
+ * Dual-role BLE transport: advertises + scans one service UUID, opens a GATT
112
+ * byte-stream to each discovered peer, and feeds it into `network.inject`. From
51
113
  * there replication and pairing are transport-agnostic (see Network.inject).
52
114
  *
53
- * Choreography mirrors the proven bare-mobile-doctor worklet: server adds a
54
- * readable PSM characteristic at startup and answers reads dynamically; the
55
- * central connects, reads "<psm>:<nodeId>", tie-breaks, then opens the channel.
56
- * `backend` is bare-bluetooth in production and a mock in tests.
115
+ * Choreography: the server adds one data characteristic (write + notify) and
116
+ * advertises. The central connects, discovers the characteristic, subscribes,
117
+ * then framed bytes flow both ways central→server as GATT writes, server→
118
+ * central as notifications each tagged with an 8-byte session id (bitchat
119
+ * model). `backend` is bare-bluetooth in production and a mock in tests.
57
120
  *
58
121
  * ponytail: capability-handshake DoS link-scoring is deferred — it needs a
59
122
  * replication-progress signal (design §4b). v1 caps links + times out dials.
@@ -71,6 +134,7 @@ export class BluetoothTransport extends ReadyResource {
71
134
  * @param {number} [opts.cap] Max concurrent links; gossip covers the rest.
72
135
  * @param {{ scanMode?: any }} [opts.scanOptions] Platform scan options (e.g. Android low-power).
73
136
  * @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).
137
+ * @param {string} [opts.name] Local app-user display name, sent to peers over a hello frame.
74
138
  */
75
139
  constructor({
76
140
  backend,
@@ -80,11 +144,13 @@ export class BluetoothTransport extends ReadyResource {
80
144
  tag = 'cero-ble',
81
145
  cap = DEFAULT_CAP,
82
146
  scanOptions,
83
- keepLinks = false
147
+ keepLinks = false,
148
+ name
84
149
  }) {
85
150
  super()
86
151
  this.backend = backend
87
152
  this.network = network
153
+ this.name = name || ''
88
154
  this.nodeId = nodeId
89
155
  this.nodeHex = b4a.toString(nodeId, 'hex')
90
156
  this.serviceUUID = toServiceUUID(uuid, tag)
@@ -95,12 +161,31 @@ export class BluetoothTransport extends ReadyResource {
95
161
  this.state = 'off'
96
162
  this.central = null
97
163
  this.server = null
98
- this.psm = null
164
+ this._dataChar = null
165
+ /** sessionId hex → { stream, sid } for server-side (peripheral) sessions */
166
+ this._sessions = new Map()
167
+ /** serialized server notify queue: { frame, resolve, reject } */
168
+ this._notifyQueue = []
99
169
  this._scanning = false
100
170
  this._advertising = false
101
171
  this._serviceAdded = false
102
172
  /** peripheral id being dialed → its connect-timeout timer */
103
173
  this._dialing = new Map()
174
+ /** peripheral ids that carry a live channel — never re-dialed (a second
175
+ * dial's failure would disconnect the peripheral and kill the good link) */
176
+ this._linked = new Set()
177
+ /** peripheral id → retry-after timestamp; failed dials back off */
178
+ this._coolUntil = new Map()
179
+ /** peripheral id → consecutive failure count; drives exponential backoff */
180
+ this._failures = new Map()
181
+ /** peripheral id → remote peer key, learned at handshake — dial guard */
182
+ this._peerByPeripheral = new Map()
183
+ /** live central-side peripheral wrappers — for goodbye + physical hang-up on suspend */
184
+ this._connectedPeripherals = new Set()
185
+ /** last central.connect timestamp — global inter-dial rate limit */
186
+ this._lastDial = 0
187
+ this._scanTimer = null
188
+ this._suspended = false
104
189
  /** live injected links keyed by remote node id hex */
105
190
  this.peers = new Map()
106
191
  }
@@ -133,18 +218,15 @@ export class BluetoothTransport extends ReadyResource {
133
218
  this._serviceAdded = true
134
219
  this._maybeAdvertise()
135
220
  })
136
- this.server.on('channelPublish', (psm) => {
137
- this.psm = psm
138
- this._maybeAdvertise()
139
- })
140
- // A central reads this to learn the PSM (+ our id) before opening the
141
- // channel. Answer dynamically: the PSM isn't known when the service is added.
142
- this.server.on('readRequest', (req) => {
143
- const ok = this.server.constructor.ATT_SUCCESS ?? 0
144
- const val = this.psm == null ? b4a.alloc(0) : b4a.from(`${this.psm}:${this.nodeHex}`)
145
- this.server.respondToRequest(req, ok, val)
146
- })
147
- this.server.on('channelOpen', (l2cap) => this._onChannel(l2cap, false, null))
221
+ this.server.on('writeRequest', (reqs) => this._onWriteRequests(reqs))
222
+ // Notify queue drain signal: the last updateValue was refused (queue full);
223
+ // retry the head frame now that the peripheral can accept more.
224
+ this.server.on('readyToUpdate', () => this._drainNotify())
225
+ // WriteRequests carry no central identifier (see peripheral-manager
226
+ // _onwriterequests), so sessions can't be mapped to the unsubscribing
227
+ // central; teardown is left to the per-link keepalive/timeout liveness in
228
+ // _onChannel (15s).
229
+ this.server.on('unsubscribe', () => {})
148
230
  this.server.on('error', safetyCatch)
149
231
 
150
232
  this.central = new Central()
@@ -165,22 +247,173 @@ export class BluetoothTransport extends ReadyResource {
165
247
  _startServer(Service, Characteristic) {
166
248
  if (this.server.state !== 'poweredOn') return // wait for the adapter (stateChange)
167
249
  if (!this._serviceAdded) {
168
- const char = new Characteristic(PSM_UUID, { read: true })
169
- this.server.addService(new Service(this.serviceUUID, [char]))
250
+ this._dataChar = new Characteristic(DATA_UUID, { write: true, notify: true })
251
+ this.server.addService(new Service(this.serviceUUID, [this._dataChar]))
170
252
  }
171
- if (this.psm == null) this.server.publishChannel({})
172
253
  }
173
254
 
174
255
  _maybeAdvertise() {
175
- if (this._advertising || !this._serviceAdded || this.psm == null) return
256
+ if (this._advertising || !this._serviceAdded) return
176
257
  this._advertising = true
177
258
  this.server.startAdvertising({ serviceUUIDs: [this.serviceUUID] })
178
259
  }
179
260
 
261
+ // ─── server (peripheral) side ─────────────────────────────────────────────
262
+
263
+ _onWriteRequests(requests) {
264
+ const ok = this.server.constructor.ATT_SUCCESS ?? 0
265
+ for (const req of requests) {
266
+ // respond within ms or the central times out — before any parsing
267
+ if (req.responseNeeded !== false) this.server.respondToRequest(req, ok)
268
+ if (this._suspended) continue // off means off: acknowledge but drop
269
+ this._onServerFrame(req.data)
270
+ }
271
+ }
272
+
273
+ _onServerFrame(data) {
274
+ const f = parseFrame(data)
275
+ if (!f) {
276
+ return
277
+ }
278
+ if (f.type === TYPE_OPEN) {
279
+ if (this._sessions.has(f.sidHex)) return // duplicate open
280
+ const sid = b4a.from(f.sid) // copy: f.sid views the transient request buffer
281
+ const stream = new GattStream({
282
+ send: (payload) => this._enqueueNotify(frame(TYPE_DATA, sid, payload)),
283
+ onclose: () => this._closeServerSession(f.sidHex, sid)
284
+ })
285
+ const session = { stream, sid, conn: null, name: null }
286
+ this._sessions.set(f.sidHex, session)
287
+ session.conn = this._onChannel(stream, false, null)
288
+ // greet the peer with our app-user name so it can label this link
289
+ this._enqueueNotify(frame(TYPE_HELLO, sid, this._helloPayload())).catch(safetyCatch)
290
+ } else if (f.type === TYPE_DATA) {
291
+ const s = this._sessions.get(f.sidHex)
292
+ if (s) s.stream.receive(b4a.from(f.payload))
293
+ } else if (f.type === TYPE_HELLO) {
294
+ const s = this._sessions.get(f.sidHex)
295
+ if (s) this._applyPeerName(s, f.payload)
296
+ } else if (f.type === TYPE_CLOSE) {
297
+ const s = this._sessions.get(f.sidHex)
298
+ if (s) {
299
+ this._sessions.delete(f.sidHex)
300
+ s.stream.remoteEnd()
301
+ }
302
+ } else {
303
+ }
304
+ }
305
+
306
+ _closeServerSession(sidHex, sid) {
307
+ if (!this._sessions.has(sidHex)) return // already torn down (peer sent close)
308
+ this._sessions.delete(sidHex)
309
+ this._enqueueNotify(frame(TYPE_CLOSE, sid)).catch(safetyCatch) // best effort
310
+ }
311
+
312
+ // ─── peer display name (hello frame) ──────────────────────────────────────
313
+
314
+ /** Our hello payload: the local app-user name the peer labels this link with. */
315
+ _helloPayload() {
316
+ return b4a.from(JSON.stringify({ n: this.name || '' }))
317
+ }
318
+
319
+ /**
320
+ * Parse a hello payload. Malformed → null (the caller ignores it).
321
+ *
322
+ * @param {Uint8Array} payload
323
+ * @returns {string | null}
324
+ */
325
+ _parseHello(payload) {
326
+ try {
327
+ const { n } = JSON.parse(b4a.toString(payload))
328
+ return typeof n === 'string' ? n : ''
329
+ } catch {
330
+ return null
331
+ }
332
+ }
333
+
334
+ /** Stash a peer's name onto a server session + its conn, then refresh mirrors. */
335
+ _applyPeerName(session, payload) {
336
+ const name = this._parseHello(payload)
337
+ if (name === null) return // malformed hello — ignore
338
+ session.name = name
339
+ if (session.conn) session.conn._peerName = name
340
+ this.emit('update')
341
+ }
342
+
343
+ // Serialize notifications through the single characteristic: updateValue
344
+ // returns false when the peripheral's queue is full — hold the frame and retry
345
+ // on the next 'readyToUpdate', preserving order.
346
+ _enqueueNotify(f) {
347
+ return new Promise((resolve, reject) => {
348
+ this._notifyQueue.push({ frame: f, resolve, reject })
349
+ this._drainNotify()
350
+ })
351
+ }
352
+
353
+ _drainNotify() {
354
+ while (this._notifyQueue.length) {
355
+ const item = this._notifyQueue[0]
356
+ let ok
357
+ try {
358
+ ok = this.server.updateValue(this._dataChar, item.frame)
359
+ } catch (err) {
360
+ this._notifyQueue.shift()
361
+ item.reject(err)
362
+ continue
363
+ }
364
+ if (!ok) return // queue full — wait for 'readyToUpdate', retry same frame
365
+ this._notifyQueue.shift()
366
+ item.resolve()
367
+ }
368
+ }
369
+
180
370
  _startScan() {
181
371
  if (this._scanning || this.central.state !== 'poweredOn') return
182
372
  this._scanning = true
183
373
  this.central.startScan([this.serviceUUID], this.scanOptions)
374
+ this._armScanRestart()
375
+ }
376
+
377
+ // Scan scheduler (bitchat model). Lonely: cycle the scan every
378
+ // SCAN_RESTART_LONELY so a re-advertised peer is re-reported (iOS suppresses
379
+ // duplicate discoveries within a scan session). Linked: duty-cycle to save the
380
+ // battery — scan SCAN_DUTY_ON, then go dark SCAN_DUTY_OFF, repeat. The next
381
+ // delay is derived from the current phase, so one timer drives the whole cycle.
382
+ _armScanRestart() {
383
+ if (this._scanTimer) clearTimeout(this._scanTimer)
384
+ const delay =
385
+ this.linkCount > 0 ? (this._scanning ? SCAN_DUTY_ON : SCAN_DUTY_OFF) : SCAN_RESTART_LONELY
386
+ this._scanTimer = setTimeout(() => {
387
+ this._scanTimer = null
388
+ if (this.closing || this.closed || this._suspended) return
389
+ // never toggle the scan mid-dial (would kill the connect) — defer a phase
390
+ if (this._dialing.size > 0) {
391
+ this._armScanRestart()
392
+ return
393
+ }
394
+ if (this.linkCount > 0) {
395
+ if (this._scanning) {
396
+ this._stopScan() // enter the dark half of the duty cycle
397
+ this._armScanRestart()
398
+ } else {
399
+ this._startScan() // wake up and re-arm for the next dark window
400
+ }
401
+ } else {
402
+ this._stopScan() // lonely: cycle so re-advertised peers re-report
403
+ this._startScan()
404
+ }
405
+ }, delay)
406
+ if (this._scanTimer.unref) this._scanTimer.unref()
407
+ }
408
+
409
+ _stopScan() {
410
+ if (!this._scanning) return
411
+ this._scanning = false
412
+ try {
413
+ this.central.stopScan()
414
+ } catch (err) {
415
+ safetyCatch(err)
416
+ }
184
417
  }
185
418
 
186
419
  _onState(raw) {
@@ -192,13 +425,24 @@ export class BluetoothTransport extends ReadyResource {
192
425
 
193
426
  _onDiscover(peripheral) {
194
427
  if (this.closing || this.closed) return
428
+ if (this._suspended) return
429
+ if (this._linked.has(peripheral.id)) return // already linked
430
+ const knownPeer = this._peerByPeripheral.get(peripheral.id)
431
+ if (knownPeer && this.peers.has(knownPeer)) return // device already linked via another channel
432
+ const cool = this._coolUntil.get(peripheral.id)
433
+ if (cool && cool > Date.now()) return // failed recently — back off
195
434
  if (this._dialing.has(peripheral.id)) return // already connecting to this one
196
435
  if (this.linkCount >= this.cap) return // gossip covers the rest
197
- // tie-break is decided post-read (we can't trust advertisement payloads);
198
- // dial now, learn the peer id from the PSM characteristic, decide there.
436
+ // global rate limit: one radio can't usefully dial faster than this, and
437
+ // back-to-back connects thrash CoreBluetooth (bitchat connect hygiene)
438
+ if (Date.now() - this._lastDial < DIAL_MIN_INTERVAL) return
439
+ // no tie-break: dial every discovery and open a session (bitchat model);
440
+ // a redundant link to the same peer is dropped by _track's dedup
441
+ this._lastDial = Date.now()
199
442
  const timer = setTimeout(() => this._abortDial(peripheral, 'timeout'), CONNECT_TIMEOUT)
200
443
  this._dialing.set(peripheral.id, timer)
201
444
  try {
445
+ this._stopScan()
202
446
  this.central.connect(peripheral)
203
447
  } catch (err) {
204
448
  this._abortDial(peripheral, err)
@@ -206,44 +450,137 @@ export class BluetoothTransport extends ReadyResource {
206
450
  }
207
451
 
208
452
  _onConnect(peripheral) {
453
+ this._connectedPeripherals.add(peripheral)
209
454
  peripheral.on('error', () => this._abortDial(peripheral, 'peripheral-error'))
210
455
  peripheral.once('servicesDiscover', (services) => {
211
456
  const svc = findByUUID(services, this.serviceUUID)
212
- if (svc) peripheral.discoverCharacteristics(svc, [PSM_UUID])
457
+ if (svc) peripheral.discoverCharacteristics(svc, [DATA_UUID])
213
458
  else this._abortDial(peripheral, 'no-service')
214
459
  })
215
460
  peripheral.once('characteristicsDiscover', (_svc, chars) => {
216
- const psmChar = findByUUID(chars, PSM_UUID)
217
- if (psmChar) peripheral.read(psmChar)
218
- else this._abortDial(peripheral, 'no-psm-char')
461
+ const dataChar = findByUUID(chars, DATA_UUID)
462
+ if (dataChar) peripheral.subscribe(dataChar)
463
+ else this._abortDial(peripheral, 'no-data-char')
219
464
  })
220
- peripheral.once('read', (_char, data) => {
221
- const [psmStr, peerHex] = b4a.toString(data).split(':')
222
- const psm = Number(psmStr)
223
- const peerId = peerHex ? b4a.from(peerHex, 'hex') : null
224
- // the smaller id opens the channel; the larger disconnects and waits for
225
- // the peer to dial back — a pair links once, not twice
226
- if (!psm || !peerId || !this.shouldInitiate(peerId)) {
227
- this._abortDial(peripheral, 'yield')
465
+ peripheral.once('notifyState', (char, isNotifying) => {
466
+ if (!isNotifying) {
467
+ this._abortDial(peripheral, 'subscribe-failed')
228
468
  return
229
469
  }
230
- peripheral.once('channelOpen', (l2cap) => {
231
- this._clearDial(peripheral.id)
232
- this._onChannel(l2cap, true, peripheral.id)
233
- })
234
- peripheral.openL2CAPChannel(psm)
470
+ this._startCentralSession(peripheral, char)
235
471
  })
472
+ // No tie-break: both sides open a session (bitchat model). On iOS the peer
473
+ // id isn't known until after connect, so a tie-break can only yield
474
+ // post-handshake — which deadlocks when the other side never dials back.
475
+ // Redundant links are tolerated; _track keeps the first and drops the dup.
236
476
  peripheral.discoverServices([this.serviceUUID])
237
477
  }
238
478
 
479
+ // ─── central side ─────────────────────────────────────────────────────────
480
+
481
+ _startCentralSession(peripheral, char) {
482
+ const sid = randomBytes(SID_LEN)
483
+ const sidHex = b4a.toString(sid, 'hex')
484
+ peripheral._session = { sidHex, sid }
485
+ peripheral._char = char // suspend's goodbye writes reuse it
486
+ peripheral.on('notify', (_char, data) => this._onCentralNotify(peripheral, data))
487
+ const stream = new GattStream({
488
+ send: (payload) => this._centralSend(peripheral, char, frame(TYPE_DATA, sid, payload)),
489
+ onclose: () => {
490
+ this._centralSend(peripheral, char, frame(TYPE_CLOSE, sid)).catch(safetyCatch)
491
+ this._connectedPeripherals.delete(peripheral)
492
+ try {
493
+ this.central.disconnect(peripheral)
494
+ } catch (err) {
495
+ safetyCatch(err)
496
+ }
497
+ }
498
+ })
499
+ peripheral._stream = stream
500
+ // open frame first: it registers the session on the server before any data
501
+ this._centralSend(peripheral, char, frame(TYPE_OPEN, sid)).catch(safetyCatch)
502
+ // greet the peer with our app-user name so it can label this link
503
+ this._centralSend(peripheral, char, frame(TYPE_HELLO, sid, this._helloPayload())).catch(
504
+ safetyCatch
505
+ )
506
+ this._clearDial(peripheral.id)
507
+ peripheral._conn = this._onChannel(stream, true, peripheral.id)
508
+ }
509
+
510
+ _onCentralNotify(peripheral, data) {
511
+ const sess = peripheral._session
512
+ if (!sess || !peripheral._stream) return
513
+ const f = parseFrame(data)
514
+ if (!f) {
515
+ return
516
+ }
517
+ if (f.sidHex !== sess.sidHex) return // not our session (broadcast to others)
518
+ if (f.type === TYPE_DATA) peripheral._stream.receive(b4a.from(f.payload))
519
+ else if (f.type === TYPE_HELLO) {
520
+ const name = this._parseHello(f.payload)
521
+ if (name === null) return // malformed hello — ignore
522
+ peripheral._peerName = name
523
+ if (peripheral._conn) peripheral._conn._peerName = name
524
+ this.emit('update')
525
+ } else if (f.type === TYPE_CLOSE) {
526
+ peripheral._session = null
527
+ peripheral._stream.remoteEnd()
528
+ }
529
+ }
530
+
531
+ // One write in flight per peripheral: chain each write behind the previous,
532
+ // resolving on the 'write' completion event before the next is issued.
533
+ _centralSend(peripheral, char, f) {
534
+ const prev = peripheral._writeChain || Promise.resolve()
535
+ const next = prev.then(() => this._writeOnce(peripheral, char, f))
536
+ peripheral._writeChain = next.catch(safetyCatch) // keep the chain alive
537
+ return next
538
+ }
539
+
540
+ _writeOnce(peripheral, char, f) {
541
+ return new Promise((resolve, reject) => {
542
+ const cleanup = () => {
543
+ peripheral.removeListener('write', onWrite)
544
+ peripheral.removeListener('error', onErr)
545
+ }
546
+ const onWrite = () => {
547
+ cleanup()
548
+ resolve()
549
+ }
550
+ const onErr = (err) => {
551
+ cleanup()
552
+ reject(err)
553
+ }
554
+ peripheral.once('write', onWrite)
555
+ peripheral.once('error', onErr)
556
+ try {
557
+ peripheral.write(char, f, true)
558
+ } catch (err) {
559
+ cleanup()
560
+ reject(err)
561
+ }
562
+ })
563
+ }
564
+
239
565
  _abortDial(peripheral, _reason) {
240
566
  const id = peripheral?.id
567
+ // eager when unlinked, patient once linked; per-peer exponential backoff so a
568
+ // peer that keeps failing is retried ever less often (bitchat connect hygiene)
569
+ if (id != null) {
570
+ const count = (this._failures.get(id) || 0) + 1
571
+ this._failures.set(id, count)
572
+ const base = this.linkCount === 0 ? DIAL_COOLDOWN_BASE_LONELY : DIAL_COOLDOWN_BASE
573
+ const cooldown = Math.min(DIAL_COOLDOWN_MAX, base * 2 ** Math.min(4, count - 1))
574
+ this._coolUntil.set(id, Date.now() + cooldown)
575
+ }
241
576
  this._clearDial(id)
577
+ this._connectedPeripherals.delete(peripheral)
242
578
  try {
243
579
  this.central.disconnect(peripheral)
244
580
  } catch (err) {
245
581
  safetyCatch(err)
246
582
  }
583
+ this._startScan()
247
584
  }
248
585
 
249
586
  _clearDial(id) {
@@ -264,36 +601,174 @@ export class BluetoothTransport extends ReadyResource {
264
601
  }
265
602
 
266
603
  _onChannel(l2cap, isInitiator, peripheralId) {
267
- if (this.closing || this.closed) {
604
+ if (this.closing || this.closed || this._suspended) {
268
605
  l2cap.destroy()
269
606
  return
270
607
  }
271
608
  const conn = this.network.inject(l2cap, { isInitiator })
272
- conn.on('open', () => this._track(conn, peripheralId))
609
+ // Liveness: iOS never signals an L2CAP disconnect, so ping the peer and
610
+ // drop the link if we stop hearing back (keepalive refreshes the timeout).
611
+ conn.setKeepAlive(5000)
612
+ conn.setTimeout(15000)
613
+ l2cap.on('error', safetyCatch)
614
+ // marked at channel-open (not handshake-open): rediscovery must not dial a
615
+ // peripheral whose channel is still handshaking
616
+ if (peripheralId != null) {
617
+ this._linked.add(peripheralId)
618
+ this._coolUntil.delete(peripheralId)
619
+ this._failures.delete(peripheralId) // reached a live session — reset backoff
620
+ conn.once('close', () => this._linked.delete(peripheralId))
621
+ }
622
+ conn.on('open', () => this._track(conn, peripheralId, isInitiator))
273
623
  conn.on('close', () => this._untrack(conn, peripheralId))
624
+ this._startScan()
625
+ return conn
274
626
  }
275
627
 
276
- _track(conn, peripheralId) {
628
+ _track(conn, peripheralId, isInitiator) {
629
+ if (peripheralId != null && conn.remotePublicKey) {
630
+ this._peerByPeripheral.set(peripheralId, b4a.toString(conn.remotePublicKey, 'hex'))
631
+ }
277
632
  if (this.closing || this.closed) return
278
633
  const key = b4a.toString(conn.remotePublicKey, 'hex')
279
634
  const existing = this.peers.get(key)
280
635
  if (existing && existing !== conn) {
281
- conn.destroy() // duplicate link to the same peer keep the first
282
- return
636
+ // Both sides dial (bitchat model) so a pair links twicetwo separate
637
+ // channels, each with an initiator end on one device and a responder end
638
+ // on the other. Dropping a channel closes it for BOTH devices, so the two
639
+ // devices MUST retire the same channel or one tears down the peer's kept
640
+ // link. Deterministic winner: keep the channel whose initiator has the
641
+ // smaller static key. Both peers compute it identically (initiator end:
642
+ // our key smaller; responder end: remote key smaller), so the loser
643
+ // channel is dropped on both ends and never cascades onto the survivor.
644
+ const initiatorIsUsSmaller = b4a.compare(conn.publicKey, conn.remotePublicKey) < 0
645
+ const preferred = isInitiator === initiatorIsUsSmaller
646
+ if (!preferred) {
647
+ conn.destroy()
648
+ return
649
+ }
283
650
  }
284
651
  conn._peripheralId = peripheralId
285
652
  conn._peerKey = key
286
653
  this.peers.set(key, conn)
654
+ if (existing && existing !== conn) existing.destroy() // retire the loser channel
287
655
  this.emit('update')
288
656
  }
289
657
 
290
658
  _untrack(conn) {
291
659
  const key = conn._peerKey
292
660
  if (key && this.peers.get(key) === conn) this.peers.delete(key)
293
- if (!this.closing && !this.closed) this.emit('update')
661
+ if (!this.closing && !this.closed) {
662
+ // last link gone → hunt immediately instead of waiting out a dark window
663
+ if (this.linkCount === 0 && !this._suspended) this._startScan()
664
+ this.emit('update')
665
+ }
666
+ }
667
+
668
+ /**
669
+ * Best-effort TYPE_CLOSE to every live session — server sessions over the
670
+ * notify path, central sessions over the write path — reusing the same helpers
671
+ * a normal stream close uses. Waits up to DRAIN_MS for the frames to flush,
672
+ * then resolves regardless: suspend must never hang on a wedged radio.
673
+ *
674
+ * @returns {Promise<void>}
675
+ */
676
+ async _sayGoodbye() {
677
+ const sent = []
678
+ for (const { sid } of this._sessions.values()) {
679
+ sent.push(this._enqueueNotify(frame(TYPE_CLOSE, sid)).catch(safetyCatch))
680
+ }
681
+ for (const peripheral of this._connectedPeripherals) {
682
+ const sess = peripheral._session
683
+ if (!sess || !peripheral._char) continue
684
+ const f = frame(TYPE_CLOSE, sess.sid)
685
+ sent.push(this._centralSend(peripheral, peripheral._char, f).catch(safetyCatch))
686
+ }
687
+ if (!sent.length) return
688
+ await Promise.race([Promise.all(sent), new Promise((r) => setTimeout(r, DRAIN_MS))])
689
+ }
690
+
691
+ /**
692
+ * Pause radio activity but KEEP the Server/Central instances and the
693
+ * registered GATT service alive — the toggle-friendly counterpart to _close.
694
+ * iOS CoreBluetooth managers can't be destroy()ed (native double-free), so a
695
+ * fresh transport per toggle leaks a manager whose stale peripheral-manager
696
+ * keeps a duplicate GATT service registered; remote centrals then subscribe to
697
+ * the dead service and hear silence. Reuse one instance instead. Idempotent.
698
+ */
699
+ async suspend() {
700
+ this._suspended = true
701
+ if (this._scanTimer) clearTimeout(this._scanTimer)
702
+ this._scanTimer = null
703
+ for (const timer of this._dialing.values()) clearTimeout(timer)
704
+ this._dialing.clear()
705
+ // Say goodbye BEFORE teardown so the remote reacts in <1s instead of waiting
706
+ // out the 15s keepalive: close frames, a short drain, then drop the physical
707
+ // links (an ACL disconnect is an instant OS-level signal on both roles).
708
+ await this._sayGoodbye()
709
+ for (const peripheral of this._connectedPeripherals) {
710
+ try {
711
+ this.central.disconnect(peripheral)
712
+ } catch (err) {
713
+ safetyCatch(err)
714
+ }
715
+ }
716
+ this._connectedPeripherals.clear()
717
+ try {
718
+ this._stopScan()
719
+ } catch (err) {
720
+ safetyCatch(err)
721
+ }
722
+ try {
723
+ this.server?.stopAdvertising()
724
+ } catch (err) {
725
+ safetyCatch(err)
726
+ }
727
+ this._advertising = false
728
+ for (const conn of this.peers.values()) {
729
+ try {
730
+ conn.destroy()
731
+ } catch (err) {
732
+ safetyCatch(err)
733
+ }
734
+ }
735
+ this.peers.clear()
736
+ for (const { stream } of this._sessions.values()) {
737
+ try {
738
+ stream.destroy()
739
+ } catch (err) {
740
+ safetyCatch(err)
741
+ }
742
+ }
743
+ this._sessions.clear()
744
+ for (const item of this._notifyQueue) item.reject(new Error('suspended'))
745
+ this._notifyQueue = []
746
+ this._linked.clear()
747
+ this._coolUntil.clear()
748
+ this._failures.clear()
749
+ this.state = 'off'
750
+ this.emit('update')
751
+ }
752
+
753
+ /**
754
+ * Restart advertising + scanning on the SAME Server/Central. `_serviceAdded`
755
+ * is still true (the service was never removed) so advertising resumes
756
+ * immediately. Safe to call repeatedly; no-op once closing/closed.
757
+ */
758
+ resume() {
759
+ this._suspended = false
760
+ if (this.closing || this.closed) return
761
+ this._advertising = false
762
+ this._maybeAdvertise()
763
+ this._startScan()
764
+ const raw = this.central?.state ?? this.server?.state
765
+ this.state = STATE[raw] ?? 'waiting'
766
+ this.emit('update')
294
767
  }
295
768
 
296
769
  async _close() {
770
+ if (this._scanTimer) clearTimeout(this._scanTimer)
771
+ this._scanTimer = null
297
772
  // doctor-app rule: never call central/server.destroy() — it double-frees in
298
773
  // the native teardown; stop advertising/scanning and let the runtime reclaim.
299
774
  for (const timer of this._dialing.values()) clearTimeout(timer)
@@ -317,6 +792,11 @@ export class BluetoothTransport extends ReadyResource {
317
792
  safetyCatch(err)
318
793
  }
319
794
  }
795
+ // kept links keep carrying frames through their sessions/notify queue; only
796
+ // tear this down when we're actually dropping the links
797
+ for (const item of this._notifyQueue) item.reject(new Error('closed'))
798
+ this._notifyQueue = []
799
+ this._sessions.clear()
320
800
  }
321
801
  this.peers.clear()
322
802
  this.state = 'off'
@@ -0,0 +1,59 @@
1
+ import { Duplex } from 'streamx'
2
+
3
+ // iOS caps a single GATT write (and a notify payload) at ATT_MTU − 3 ≈ 182
4
+ // bytes. 150 stays safely under that without negotiating an MTU. Raise later
5
+ // via the peripheral's maximumWriteValueLength + write-without-response.
6
+ const PAYLOAD = 150
7
+
8
+ /**
9
+ * A dumb byte-carrying duplex for the GATT transport. Framing and session logic
10
+ * live in BluetoothTransport; this only fragments outbound writes to fit a GATT
11
+ * write and pushes inbound payload bytes. NoiseSecretStream wraps it as a raw
12
+ * duplex, exactly like the old L2CAP channel.
13
+ *
14
+ * @extends Duplex
15
+ */
16
+ export class GattStream extends Duplex {
17
+ /**
18
+ * @param {object} opts
19
+ * @param {(buffer: Uint8Array) => Promise<void>} opts.send Transmit one payload piece (transport frames it).
20
+ * @param {() => void} [opts.onclose] Called once on teardown (send a close frame, disconnect).
21
+ */
22
+ constructor({ send, onclose } = {}) {
23
+ super()
24
+ this._send = send
25
+ this._onclose = onclose || null
26
+ }
27
+
28
+ async _write(chunk, cb) {
29
+ try {
30
+ for (let offset = 0; offset < chunk.byteLength; offset += PAYLOAD) {
31
+ await this._send(chunk.subarray(offset, offset + PAYLOAD))
32
+ }
33
+ cb(null)
34
+ } catch (err) {
35
+ cb(err)
36
+ }
37
+ }
38
+
39
+ receive(buffer) {
40
+ this.push(buffer)
41
+ }
42
+
43
+ remoteEnd() {
44
+ this.push(null)
45
+ }
46
+
47
+ _destroy(cb) {
48
+ const onclose = this._onclose
49
+ this._onclose = null
50
+ if (onclose) {
51
+ try {
52
+ onclose()
53
+ } catch {
54
+ // teardown is best-effort
55
+ }
56
+ }
57
+ cb(null)
58
+ }
59
+ }
@@ -60,8 +60,16 @@ export class Network extends ReadyResource {
60
60
  inject(stream, { isInitiator } = {}) {
61
61
  if (this.closing || this.closed) throw CeroError.CLOSED('Network')
62
62
  if (!stream) throw CeroError.REQUIRED('stream')
63
+ // Injected links authenticate with the same long-lived identity as the
64
+ // swarm — an ephemeral key per link would defeat duplicate-peer detection
65
+ // (two radios between one pair would look like two different peers).
66
+ const keyPair = this.identity
67
+ ? { publicKey: this.identity.publicKey, secretKey: this.identity.secretKey }
68
+ : (this.swarm?.keyPair ?? undefined)
63
69
  const conn =
64
- stream.noiseStream === stream ? stream : new NoiseSecretStream(isInitiator === true, stream)
70
+ stream.noiseStream === stream
71
+ ? stream
72
+ : new NoiseSecretStream(isInitiator === true, stream, keyPair ? { keyPair } : undefined)
65
73
 
66
74
  // blind-pairing picks the lowest-`rtt` unvisited channel to send on; that
67
75
  // field only exists on real udx sockets. A raw injected duplex has none,
@@ -8,14 +8,15 @@
8
8
  */
9
9
  export function toServiceUUID(topic: Uint8Array, tag?: string): string;
10
10
  /**
11
- * Dual-role BLE transport: advertises + scans one service UUID, opens an L2CAP
12
- * channel to each discovered peer, and feeds it into `network.inject`. From
11
+ * Dual-role BLE transport: advertises + scans one service UUID, opens a GATT
12
+ * byte-stream to each discovered peer, and feeds it into `network.inject`. From
13
13
  * there replication and pairing are transport-agnostic (see Network.inject).
14
14
  *
15
- * Choreography mirrors the proven bare-mobile-doctor worklet: server adds a
16
- * readable PSM characteristic at startup and answers reads dynamically; the
17
- * central connects, reads "<psm>:<nodeId>", tie-breaks, then opens the channel.
18
- * `backend` is bare-bluetooth in production and a mock in tests.
15
+ * Choreography: the server adds one data characteristic (write + notify) and
16
+ * advertises. The central connects, discovers the characteristic, subscribes,
17
+ * then framed bytes flow both ways central→server as GATT writes, server→
18
+ * central as notifications each tagged with an 8-byte session id (bitchat
19
+ * model). `backend` is bare-bluetooth in production and a mock in tests.
19
20
  *
20
21
  * ponytail: capability-handshake DoS link-scoring is deferred — it needs a
21
22
  * replication-progress signal (design §4b). v1 caps links + times out dials.
@@ -33,8 +34,9 @@ export class BluetoothTransport extends ReadyResource {
33
34
  * @param {number} [opts.cap] Max concurrent links; gossip covers the rest.
34
35
  * @param {{ scanMode?: any }} [opts.scanOptions] Platform scan options (e.g. Android low-power).
35
36
  * @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).
37
+ * @param {string} [opts.name] Local app-user display name, sent to peers over a hello frame.
36
38
  */
37
- constructor({ backend, network, uuid, nodeId, tag, cap, scanOptions, keepLinks }: {
39
+ constructor({ backend, network, uuid, nodeId, tag, cap, scanOptions, keepLinks, name }: {
38
40
  backend: any;
39
41
  network: import("./index.js").Network;
40
42
  uuid: Uint8Array;
@@ -45,9 +47,11 @@ export class BluetoothTransport extends ReadyResource {
45
47
  scanMode?: any;
46
48
  };
47
49
  keepLinks?: boolean;
50
+ name?: string;
48
51
  });
49
52
  backend: any;
50
53
  network: import("./index.js").Network;
54
+ name: string;
51
55
  nodeId: Uint8Array<ArrayBufferLike>;
52
56
  nodeHex: any;
53
57
  serviceUUID: string;
@@ -59,12 +63,31 @@ export class BluetoothTransport extends ReadyResource {
59
63
  state: string;
60
64
  central: any;
61
65
  server: any;
62
- psm: any;
66
+ _dataChar: any;
67
+ /** sessionId hex → { stream, sid } for server-side (peripheral) sessions */
68
+ _sessions: Map<any, any>;
69
+ /** serialized server notify queue: { frame, resolve, reject } */
70
+ _notifyQueue: any[];
63
71
  _scanning: boolean;
64
72
  _advertising: boolean;
65
73
  _serviceAdded: boolean;
66
74
  /** peripheral id being dialed → its connect-timeout timer */
67
75
  _dialing: Map<any, any>;
76
+ /** peripheral ids that carry a live channel — never re-dialed (a second
77
+ * dial's failure would disconnect the peripheral and kill the good link) */
78
+ _linked: Set<any>;
79
+ /** peripheral id → retry-after timestamp; failed dials back off */
80
+ _coolUntil: Map<any, any>;
81
+ /** peripheral id → consecutive failure count; drives exponential backoff */
82
+ _failures: Map<any, any>;
83
+ /** peripheral id → remote peer key, learned at handshake — dial guard */
84
+ _peerByPeripheral: Map<any, any>;
85
+ /** live central-side peripheral wrappers — for goodbye + physical hang-up on suspend */
86
+ _connectedPeripherals: Set<any>;
87
+ /** last central.connect timestamp — global inter-dial rate limit */
88
+ _lastDial: number;
89
+ _scanTimer: any;
90
+ _suspended: boolean;
68
91
  /** live injected links keyed by remote node id hex */
69
92
  peers: Map<any, any>;
70
93
  /**
@@ -79,15 +102,61 @@ export class BluetoothTransport extends ReadyResource {
79
102
  get linkCount(): number;
80
103
  _startServer(Service: any, Characteristic: any): void;
81
104
  _maybeAdvertise(): void;
105
+ _onWriteRequests(requests: any): void;
106
+ _onServerFrame(data: any): void;
107
+ _closeServerSession(sidHex: any, sid: any): void;
108
+ /** Our hello payload: the local app-user name the peer labels this link with. */
109
+ _helloPayload(): any;
110
+ /**
111
+ * Parse a hello payload. Malformed → null (the caller ignores it).
112
+ *
113
+ * @param {Uint8Array} payload
114
+ * @returns {string | null}
115
+ */
116
+ _parseHello(payload: Uint8Array): string | null;
117
+ /** Stash a peer's name onto a server session + its conn, then refresh mirrors. */
118
+ _applyPeerName(session: any, payload: any): void;
119
+ _enqueueNotify(f: any): Promise<any>;
120
+ _drainNotify(): void;
82
121
  _startScan(): void;
122
+ _armScanRestart(): void;
123
+ _stopScan(): void;
83
124
  _onState(raw: any): void;
84
125
  _onDiscover(peripheral: any): void;
85
126
  _onConnect(peripheral: any): void;
127
+ _startCentralSession(peripheral: any, char: any): void;
128
+ _onCentralNotify(peripheral: any, data: any): void;
129
+ _centralSend(peripheral: any, char: any, f: any): any;
130
+ _writeOnce(peripheral: any, char: any, f: any): Promise<any>;
86
131
  _abortDial(peripheral: any, _reason: any): void;
87
132
  _clearDial(id: any): void;
88
133
  _onCentralError(err: any): void;
89
- _onChannel(l2cap: any, isInitiator: any, peripheralId: any): void;
90
- _track(conn: any, peripheralId: any): void;
134
+ _onChannel(l2cap: any, isInitiator: any, peripheralId: any): any;
135
+ _track(conn: any, peripheralId: any, isInitiator: any): void;
91
136
  _untrack(conn: any): void;
137
+ /**
138
+ * Best-effort TYPE_CLOSE to every live session — server sessions over the
139
+ * notify path, central sessions over the write path — reusing the same helpers
140
+ * a normal stream close uses. Waits up to DRAIN_MS for the frames to flush,
141
+ * then resolves regardless: suspend must never hang on a wedged radio.
142
+ *
143
+ * @returns {Promise<void>}
144
+ */
145
+ _sayGoodbye(): Promise<void>;
146
+ /**
147
+ * Pause radio activity but KEEP the Server/Central instances and the
148
+ * registered GATT service alive — the toggle-friendly counterpart to _close.
149
+ * iOS CoreBluetooth managers can't be destroy()ed (native double-free), so a
150
+ * fresh transport per toggle leaks a manager whose stale peripheral-manager
151
+ * keeps a duplicate GATT service registered; remote centrals then subscribe to
152
+ * the dead service and hear silence. Reuse one instance instead. Idempotent.
153
+ */
154
+ suspend(): Promise<void>;
155
+ /**
156
+ * Restart advertising + scanning on the SAME Server/Central. `_serviceAdded`
157
+ * is still true (the service was never removed) so advertising resumes
158
+ * immediately. Safe to call repeatedly; no-op once closing/closed.
159
+ */
160
+ resume(): void;
92
161
  }
93
162
  import ReadyResource from 'ready-resource';
@@ -0,0 +1,26 @@
1
+ /**
2
+ * A dumb byte-carrying duplex for the GATT transport. Framing and session logic
3
+ * live in BluetoothTransport; this only fragments outbound writes to fit a GATT
4
+ * write and pushes inbound payload bytes. NoiseSecretStream wraps it as a raw
5
+ * duplex, exactly like the old L2CAP channel.
6
+ *
7
+ * @extends Duplex
8
+ */
9
+ export class GattStream extends Duplex<import("streamx").DuplexEvents> {
10
+ /**
11
+ * @param {object} opts
12
+ * @param {(buffer: Uint8Array) => Promise<void>} opts.send Transmit one payload piece (transport frames it).
13
+ * @param {() => void} [opts.onclose] Called once on teardown (send a close frame, disconnect).
14
+ */
15
+ constructor({ send, onclose }?: {
16
+ send: (buffer: Uint8Array) => Promise<void>;
17
+ onclose?: () => void;
18
+ });
19
+ _send: (buffer: Uint8Array) => Promise<void>;
20
+ _onclose: () => void;
21
+ _write(chunk: any, cb: any): Promise<void>;
22
+ receive(buffer: any): void;
23
+ remoteEnd(): void;
24
+ _destroy(cb: any): void;
25
+ }
26
+ import { Duplex } from 'streamx';