@cero-base/cero 1.12.0 → 1.14.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/cero",
3
- "version": "1.12.0",
3
+ "version": "1.14.0",
4
4
  "description": "The ideal p2p API — everything is a handle, handles contain refs, refs contain rows.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -95,12 +95,13 @@
95
95
  "test:node": "ls test/*.test.js | xargs -P1 -n1 brittle-node"
96
96
  },
97
97
  "dependencies": {
98
- "@cero-base/core": "^1.12.0",
98
+ "@cero-base/core": "^1.14.0",
99
99
  "b4a": "^1.8.1",
100
100
  "bare-abort-controller": "^1.1.2",
101
101
  "bare-crypto": "^1.15.3",
102
102
  "bare-fs": "^4.8.0",
103
103
  "bare-path": "^3.1.1",
104
+ "ble-swarm": "^2.2.0",
104
105
  "compact-encoding": "^3.3.2",
105
106
  "corestore": "^7.12.0",
106
107
  "hrpc": "^4.3.1",
@@ -113,6 +114,7 @@
113
114
  "ready-resource": "^1.2.0",
114
115
  "safety-catch": "^1.0.3",
115
116
  "streamx": "^2.28.0",
117
+ "suspendify": "^1.7.2",
116
118
  "z32": "^1.1.0"
117
119
  },
118
120
  "devDependencies": {
@@ -124,14 +126,5 @@
124
126
  "brittle": "^4.1.0",
125
127
  "typescript": "^5.9.3"
126
128
  },
127
- "license": "Apache-2.0",
128
- "x": 1,
129
- "peerDependencies": {
130
- "bare-bluetooth": ">=0.2.0"
131
- },
132
- "peerDependenciesMeta": {
133
- "bare-bluetooth": {
134
- "optional": true
135
- }
136
- }
129
+ "license": "Apache-2.0"
137
130
  }
package/src/bluetooth.js CHANGED
@@ -1,33 +1,22 @@
1
1
  import ReadyResource from 'ready-resource'
2
+ import BluetoothSwarm from 'ble-swarm'
3
+ import crypto from 'hypercore-crypto'
2
4
  import safetyCatch from 'safety-catch'
5
+ import b4a from 'b4a'
3
6
 
4
- import { BLETransport } from '@cero-base/core/network/transports/ble'
5
7
  import { Pairing } from '@cero-base/core/pairing'
6
8
  import { Invite } from '@cero-base/core/invite'
7
9
 
8
10
  /**
9
- * Lazily resolve the bare-bluetooth backend. Absent (Linux, or dep not
10
- * bundled) null, which the facade reports as `state: 'unsupported'`.
11
- * Never throws a missing optional dep is a state, not a crash.
12
- *
13
- * @returns {Promise<any | null>}
14
- */
15
- async function loadBackend() {
16
- try {
17
- return await import('bare-bluetooth')
18
- } catch {
19
- return null
20
- }
21
- }
22
-
23
- /**
24
- * `me.bluetooth` — the whole app-facing surface for nearby (Bluetooth) sync.
25
- * Bluetooth is a cero feature, not a parallel API: it only changes how peers
26
- * meet and carry bytes; capability-gated replication still decides what syncs.
11
+ * `me.bluetooth` the app-facing surface for nearby (Bluetooth) sync, a thin
12
+ * facade over ble-swarm. Bluetooth only changes how peers meet and carry
13
+ * bytes; capability-gated replication still decides what syncs. Discovery is
14
+ * one topic-derived service UUID at a time (tag `cero-ble`) — the data service
15
+ * sits on a fixed per-tag UUID, so switching topics only retunes the radio.
27
16
  *
28
17
  * ```js
29
18
  * const me = await cero(dir, spec, { channel, bluetooth: true })
30
- * me.bluetooth.state // 'unsupported' | 'unauthorized' | 'off' | 'waiting' | 'on'
19
+ * me.bluetooth.state // 'unsupported' | 'unauthorized' | 'off' | 'waiting' | 'starting' | 'on'
31
20
  * await me.bluetooth.start()
32
21
  * me.bluetooth.peers // Map of live BLE links
33
22
  * me.bluetooth.on('update', () => {})
@@ -39,7 +28,7 @@ export class Bluetooth extends ReadyResource {
39
28
  /**
40
29
  * @param {object} handle Root cero Handle (network + identity + channel).
41
30
  * @param {object} [opts]
42
- * @param {any} [opts.backend] Injected bare-bluetooth-shaped backend (tests); lazy-loaded when absent.
31
+ * @param {any} [opts.backend] Injected bare-bluetooth-shaped backend (tests); omitted → ble-swarm loads its own, null/false → unsupported.
43
32
  * @param {boolean} [opts.autoStart] Start on handle open (from `cero({ bluetooth: true })`).
44
33
  * @param {number} [opts.maxOutbound] Max concurrent outbound links; gossip covers the rest.
45
34
  * @param {number} [opts.maxInbound] Max concurrent inbound sessions; newcomers past this are refused.
@@ -48,28 +37,91 @@ export class Bluetooth extends ReadyResource {
48
37
  constructor(handle, { backend, autoStart, maxOutbound, maxInbound, pipe } = {}) {
49
38
  super()
50
39
  this._handle = handle
51
- // explicit null/false disables bluetooth; only an omitted backend lazy-loads
52
- this._backend = backend || null
53
- this._lazy = backend === undefined
54
40
  this._autoStart = autoStart === true
55
- this._maxOutbound = maxOutbound
56
- this._maxInbound = maxInbound
57
- this._pipe = pipe
58
- this._transport = null
59
- this._name = null
60
- this._announces = new Set()
61
- /** @type {'unsupported'|'unauthorized'|'off'|'waiting'|'starting'|'on'} */
62
- this.state = 'off'
41
+ /** @type {{ hex: string, count: number, timer: any } | null} active invite rendezvous (single topic — one at a time) */
42
+ this._announce = null
43
+ this._restorePending = false
44
+
45
+ const identity = handle.identity
46
+ // the mesh topic: one per channel, or a fixed global topic when
47
+ // channelless any nearby cero device links (global nearby; strangers
48
+ // still sync zero bytes, replication is capability-gated)
49
+ this._topic = crypto.hash(b4a.from(handle.network.channel || 'cero-ble'))
50
+ this.swarm = new BluetoothSwarm({
51
+ backend,
52
+ // injected links authenticate with the same long-lived identity as the
53
+ // swarm, so one person reached over Wi-Fi and BLE dedupes to one peer
54
+ keyPair: { publicKey: identity.publicKey, secretKey: identity.secretKey },
55
+ topic: this._topic,
56
+ tag: 'cero-ble',
57
+ pipe,
58
+ maxOutbound,
59
+ maxInbound
60
+ })
61
+ this.swarm.on('update', () => {
62
+ // an ended rendezvous retunes back to the mesh only once its links
63
+ // drain — retuning drops links, and the pairing link still carries
64
+ // the joiner's initial replication
65
+ if (this._restorePending && this.swarm.peers.size === 0) {
66
+ this._restorePending = false
67
+ this.swarm.setTopic(this._topic).catch(safetyCatch)
68
+ }
69
+ this.emit('update')
70
+ })
71
+ this.swarm.on('connection', (conn) => {
72
+ // the same treatment as any transport: wakeup, replication, pairing
73
+ this._handle.network.inject(conn)
74
+ })
75
+ }
76
+
77
+ /** @returns {'unsupported'|'unauthorized'|'off'|'waiting'|'starting'|'on'} */
78
+ get state() {
79
+ return this.swarm.state
80
+ }
81
+
82
+ /** @returns {Map<string, any>} Live BLE links, keyed by peer public key. */
83
+ get peers() {
84
+ return this.swarm.peers
85
+ }
86
+
87
+ async _open() {
88
+ if (this._autoStart) await this.start()
89
+ }
90
+
91
+ /**
92
+ * Begin advertising + scanning. Idempotent; no-op when unsupported.
93
+ *
94
+ * @returns {Promise<void>}
95
+ */
96
+ async start() {
97
+ await this.swarm.start()
63
98
  }
64
99
 
65
100
  /**
66
- * Offline join rendezvous. Both sides derive the same BLE service UUID from
67
- * the invite, so they find each other with zero DHT: the host calls this
68
- * while the invite QR is on screen; the joiner's `open(me.room, invite)`
69
- * calls it automatically for the duration of the join. Returns a stop
70
- * function — closing the QR must stop the advertisement so a photographed
71
- * invite doesn't stay an ambient admission ticket. Auto-stops at the
72
- * invite's expiry, on `bluetooth.stop()`, and on close.
101
+ * Stop advertising/scanning and drop links; open invite rendezvous end with
102
+ * the radio. Idempotent. Local data and the rest of the network (DHT) are
103
+ * untouched.
104
+ *
105
+ * @returns {Promise<void>}
106
+ */
107
+ async stop() {
108
+ this._clearAnnounce()
109
+ await this.swarm.stop()
110
+ // while stopped setTopic just sticks — the next start() is back on the
111
+ // mesh. A start() + announce() that landed during the await wins.
112
+ if (!this._announce) await this.swarm.setTopic(this._topic)
113
+ }
114
+
115
+ /**
116
+ * Offline join rendezvous: retune the radio to the invite-derived topic so
117
+ * holder and joiner find each other with zero DHT. One topic at a time —
118
+ * announcing a new invite replaces the previous rendezvous. Returns a stop
119
+ * function — closing the QR must stop the rendezvous so a photographed
120
+ * invite doesn't stay an ambient discovery beacon (admission itself is
121
+ * always gated by blind-pairing verifying the invite). The retune back to
122
+ * the mesh topic waits for live links to drain: the link a join just
123
+ * established survives and carries the joiner's initial replication.
124
+ * Auto-stops at the invite's expiry, on `stop()`, and on close.
73
125
  *
74
126
  * Only active while nearby sync is on: before `start()` (and after `stop()`)
75
127
  * this is a no-op — the user controls the radio, and a join must not touch
@@ -79,141 +131,70 @@ export class Bluetooth extends ReadyResource {
79
131
  * @returns {() => void}
80
132
  */
81
133
  announce(invite) {
82
- if (!this._backend || this.state === 'unsupported') return () => {}
83
- if (
84
- !this._transport ||
85
- (this.state !== 'on' && this.state !== 'waiting' && this.state !== 'starting')
86
- )
134
+ if (!this.swarm.supported) return () => {}
135
+ if (this.state !== 'on' && this.state !== 'waiting' && this.state !== 'starting') {
87
136
  return () => {}
137
+ }
88
138
  const topic = Pairing.inviteTopic(invite)
89
139
  if (!topic) return () => {}
90
140
 
91
- const transport = new BLETransport({
92
- backend: this._backend,
93
- network: this._handle.network,
94
- uuid: topic,
95
- nodeId: this._handle.identity.publicKey,
96
- tag: 'cero-ble-invite',
97
- pipe: this._pipe,
98
- // the QR closing stops the rendezvous, not the just-established link —
99
- // that link carries the joiner's initial replication
100
- keepLinks: true
101
- })
102
- transport.ready().catch(safetyCatch)
103
- this._announces.add(transport)
104
-
105
- const { expires } = Invite.parse(invite)
106
- const timer = expires > 0 ? setTimeout(() => stop(), Math.max(0, expires - Date.now())) : null
141
+ const hex = b4a.toString(topic, 'hex')
142
+ if (this._announce && this._announce.hex !== hex) this._stopAnnounce()
143
+ if (!this._announce) {
144
+ const entry = { hex, count: 0, timer: null }
145
+ const { expires } = Invite.parse(invite)
146
+ if (expires > 0) {
147
+ entry.timer = setTimeout(() => this._stopAnnounce(), Math.max(0, expires - Date.now()))
148
+ }
149
+ this._announce = entry
150
+ this._restorePending = false
151
+ this.swarm.setTopic(topic).catch(safetyCatch)
152
+ }
153
+ const entry = this._announce
154
+ entry.count++
107
155
 
108
- const stop = () => {
109
- if (timer) clearTimeout(timer)
110
- if (!this._announces.delete(transport)) return
111
- transport.close().catch(safetyCatch)
156
+ let stopped = false
157
+ return () => {
158
+ if (stopped) return
159
+ stopped = true
160
+ if (this._announce === entry && --entry.count <= 0) this._stopAnnounce()
112
161
  }
113
- return stop
114
162
  }
115
163
 
116
- /** @returns {Map<string, any>} Live BLE links, keyed by peer node id. */
117
- get peers() {
118
- return this._transport ? this._transport.peers : new Map()
164
+ _stopAnnounce() {
165
+ this._clearAnnounce()
166
+ if (this.swarm.peers.size > 0) this._restorePending = true
167
+ else this.swarm.setTopic(this._topic).catch(safetyCatch)
119
168
  }
120
169
 
121
- async _open() {
122
- if (this._backend === null && this._lazy) this._backend = await loadBackend()
123
- if (!this._backend) {
124
- this.state = 'unsupported'
125
- return
126
- }
127
- if (this._autoStart) await this.start()
170
+ _clearAnnounce() {
171
+ const e = this._announce
172
+ if (!e) return
173
+ if (e.timer) clearTimeout(e.timer)
174
+ this._announce = null
175
+ this._restorePending = false
128
176
  }
129
177
 
130
178
  /**
131
- * Begin advertising + scanning on the channel-derived UUID. Idempotent.
132
- * No-op (stays `unsupported`) when no backend is present.
179
+ * Host-lifecycle pause (app backgrounded): radio down, user intent kept.
133
180
  *
134
181
  * @returns {Promise<void>}
135
182
  */
136
- async start({ name } = {}) {
137
- if (name !== undefined) this._name = name
138
- if (this.state === 'unsupported') return
139
- if (this._transport) {
140
- // reuse one transport across toggles — recreating leaks iOS CoreBluetooth
141
- // managers (can't destroy()) and their stale GATT service
142
- this._transport.name = this._name || ''
143
- this._transport.resume()
144
- this.state = this._transport.state
145
- this.emit('update')
146
- return
147
- }
148
- const handle = this._handle
149
-
150
- this._transport = new BLETransport({
151
- backend: this._backend,
152
- network: handle.network,
153
- // channel isolation for free: same channel → same UUID, like the swarm.
154
- // No channel → the identity topic (unchanged global-mesh semantics).
155
- uuid: handle.network.channel
156
- ? Buffer.from(handle.network.channel)
157
- : handle.identity.publicKey,
158
- nodeId: handle.identity.publicKey,
159
- name: this._name || '',
160
- pipe: this._pipe,
161
- maxOutbound: this._maxOutbound,
162
- maxInbound: this._maxInbound,
163
- // Android scans in a battery-saver mode by default — too slow for a mesh
164
- scanOptions:
165
- typeof Bare !== 'undefined' && Bare.platform === 'android'
166
- ? { scanMode: this._backend.Central.SCAN_MODE_LOW_LATENCY }
167
- : undefined
168
- })
169
- const transport = this._transport
170
- transport.on('update', () => {
171
- // a torn-down or replaced transport can still emit late adapter events
172
- if (this._transport !== transport) return
173
- this.state = transport.state
174
- this.emit('update')
175
- })
176
- try {
177
- await this._transport.ready()
178
- this.state = this._transport.state
179
- } catch (err) {
180
- this._transport = null
181
- this.state = 'off'
182
- throw err
183
- }
184
- this.emit('update')
183
+ async suspend() {
184
+ await this.swarm.suspend()
185
185
  }
186
186
 
187
187
  /**
188
- * Stop advertising/scanning and drop links. Idempotent. Sync stops; local
189
- * data and the rest of the network (DHT) are untouched.
188
+ * Resume after a host-lifecycle pause.
190
189
  *
191
190
  * @returns {Promise<void>}
192
191
  */
193
- async stop() {
194
- for (const t of [...this._announces]) {
195
- this._announces.delete(t)
196
- await t.close().catch(safetyCatch)
197
- }
198
- if (!this._transport) return
199
- // suspend, don't close: keep the transport (and its GATT service) so a later
200
- // start() resumes the same instance instead of leaking a new one. suspend()
201
- // is async (it says goodbye + drains before hanging up); await it.
202
- await this._transport.suspend()
203
- this.state = 'off'
204
- this.emit('update')
192
+ async resume() {
193
+ await this.swarm.resume()
205
194
  }
206
195
 
207
196
  async _close() {
208
- for (const t of [...this._announces]) {
209
- this._announces.delete(t)
210
- await t.close().catch(safetyCatch)
211
- }
212
- if (this._transport) {
213
- await this._transport.close().catch(safetyCatch)
214
- this._transport = null
215
- }
216
- this.state = 'off'
217
- this.emit('update')
197
+ this._clearAnnounce()
198
+ await this.swarm.destroy()
218
199
  }
219
200
  }
@@ -5,6 +5,7 @@ import Hypercore from 'hypercore'
5
5
  import { discoveryKey } from 'hypercore-crypto'
6
6
  import ReadyResource from 'ready-resource'
7
7
  import safetyCatch from 'safety-catch'
8
+ import Suspendify from 'suspendify'
8
9
  import z32 from 'z32'
9
10
 
10
11
  import { Identity } from '@cero-base/core/identity'
@@ -127,6 +128,10 @@ export class Handle extends ReadyResource {
127
128
  this._fileServer = null
128
129
  this._blobs = null
129
130
  this._epochBlobs = null
131
+ // root only — serializes suspend/resume, converges rapid bounces
132
+ this._sus = parent
133
+ ? null
134
+ : new Suspendify({ suspend: () => this._suspend(), resume: () => this._resume() })
130
135
  this._owned = new Set()
131
136
 
132
137
  this.store = new Database({
@@ -348,7 +353,10 @@ export class Handle extends ReadyResource {
348
353
  blobs
349
354
  .ready()
350
355
  .then(() => {
351
- if (blobs.key) this.root._coreKeys.set(b4a.toString(blobs.key, 'hex'), encryptionKey)
356
+ // close prunes _coreKeys first — a ready() resolving after that must
357
+ // not re-insert the entry (it would outlive the handle on the root)
358
+ if (this.closing || this.closed || !blobs.key) return
359
+ this.root._coreKeys.set(b4a.toString(blobs.key, 'hex'), encryptionKey)
352
360
  })
353
361
  .catch(this._onerror)
354
362
  return blobs
@@ -374,7 +382,9 @@ export class Handle extends ReadyResource {
374
382
  if (stamp === undefined) {
375
383
  this.store
376
384
  .get('files', id)
377
- .then(({ data }) => data && this._registerBlobCore(id, data.stamp || 0))
385
+ .then(({ data }) => {
386
+ if (data && !this.closing && !this.closed) this._registerBlobCore(id, data.stamp || 0)
387
+ })
378
388
  .catch(safetyCatch)
379
389
  return
380
390
  }
@@ -412,7 +422,7 @@ export class Handle extends ReadyResource {
412
422
  }
413
423
 
414
424
  get suspended() {
415
- return this._suspended === true
425
+ return this._sus?.suspended === true
416
426
  }
417
427
 
418
428
  /**
@@ -571,14 +581,14 @@ export class Handle extends ReadyResource {
571
581
  const sig = this.identity.sign(
572
582
  addWriterPayload(this.store.key, writerKey, this.store.writerKey)
573
583
  )
574
- await this.store.tx(async () => {
575
- await this.store.call('add-writer', {
584
+ await this.store.tx(async (tx) => {
585
+ await tx.call('add-writer', {
576
586
  sig,
577
587
  master: this.identity.publicKey,
578
588
  writer: writerKey,
579
589
  ts: member.updatedAt || Date.now()
580
590
  })
581
- await this.store.call('add-member', member)
591
+ await tx.call('add-member', member)
582
592
  })
583
593
  }
584
594
 
@@ -619,11 +629,25 @@ export class Handle extends ReadyResource {
619
629
  )
620
630
  // a failure after the child opens must close it — else it leaks its Database,
621
631
  // swarm session and attached bee, never reaped by the parent (not yet a child)
632
+ let publish = null
633
+ let abort = null
634
+ let inflightId = null
622
635
  try {
623
636
  await child.ready()
624
637
  child.name = name
625
638
 
626
639
  const id = toId(child.store.key)
640
+ // Close the create/open race: the `add-handle` write below makes the row
641
+ // visible before this create finishes, so a concurrent _load for the id
642
+ // must share this child — a duplicate Handle over the same core
643
+ // deadlocks in ready().
644
+ const inflight = new Promise((resolve, reject) => {
645
+ publish = resolve
646
+ abort = reject
647
+ })
648
+ inflight.catch(safetyCatch)
649
+ inflightId = id
650
+ this._loading?.set(id, inflight)
627
651
  await this._saveKeyPair(id, writer)
628
652
 
629
653
  const ts = Date.now()
@@ -658,8 +682,12 @@ export class Handle extends ReadyResource {
658
682
  bind(child, type)
659
683
  this.children.add(child)
660
684
  this.emit('handle', child, { name, role })
685
+ publish(child)
686
+ this._loading?.delete(id)
661
687
  return child
662
688
  } catch (err) {
689
+ if (abort) abort(err)
690
+ if (inflightId) this._loading?.delete(inflightId)
663
691
  await child.close().catch(safetyCatch)
664
692
  throw err
665
693
  }
@@ -738,11 +766,23 @@ export class Handle extends ReadyResource {
738
766
  )
739
767
  // whenWritable timing out (host offline) is a normal failure — close the
740
768
  // fully-opened child rather than leak its Database/pairing/swarm session
769
+ let publish = null
770
+ let abort = null
771
+ let inflightId = null
741
772
  try {
742
773
  await child.ready()
743
774
  if (!child.store.writable) await child.store.whenWritable({ timeout: deadline })
744
775
 
745
776
  const id = toId(child.store.key)
777
+ // same create/open race as _create: the add-handle row is visible before
778
+ // this join finishes — a concurrent _load must share this child
779
+ const inflight = new Promise((resolve, reject) => {
780
+ publish = resolve
781
+ abort = reject
782
+ })
783
+ inflight.catch(safetyCatch)
784
+ inflightId = id
785
+ this._loading?.set(id, inflight)
746
786
  await this._saveKeyPair(id, child.store.keyPair)
747
787
 
748
788
  const ts = Date.now()
@@ -759,8 +799,12 @@ export class Handle extends ReadyResource {
759
799
  bind(child, type)
760
800
  this.children.add(child)
761
801
  this.emit('handle', child, {})
802
+ publish(child)
803
+ this._loading?.delete(id)
762
804
  return child
763
805
  } catch (err) {
806
+ if (abort) abort(err)
807
+ if (inflightId) this._loading?.delete(inflightId)
764
808
  await child.close().catch(safetyCatch)
765
809
  throw err
766
810
  }
@@ -834,9 +878,13 @@ export class Handle extends ReadyResource {
834
878
  * @returns {Promise<void>}
835
879
  */
836
880
  async suspend() {
837
- if (this.parent || this.closing || this.closed || this._suspended) return
838
- this._suspended = true
881
+ if (this._sus) await this._sus.suspend()
882
+ }
883
+
884
+ async _suspend() {
885
+ if (this.closing || this.closed) return
839
886
  await Promise.all([...this.children].map((c) => c.pair?.suspend()))
887
+ await this.bluetooth?.suspend()
840
888
  await this.network.suspend()
841
889
  try {
842
890
  await this.store.store.suspend()
@@ -851,14 +899,18 @@ export class Handle extends ReadyResource {
851
899
  * @returns {Promise<void>}
852
900
  */
853
901
  async resume() {
854
- if (this.parent || this.closing || this.closed || !this._suspended) return
855
- this._suspended = false
902
+ if (this._sus) await this._sus.resume()
903
+ }
904
+
905
+ async _resume() {
906
+ if (this.closing || this.closed) return
856
907
  try {
857
908
  await this.store.store.resume()
858
909
  } catch (err) {
859
910
  this._onerror(err)
860
911
  }
861
912
  await this.network.resume()
913
+ await this.bluetooth?.resume()
862
914
  await Promise.all([...this.children].map((child) => child.pair?.resume()))
863
915
  }
864
916
 
package/src/index.js CHANGED
@@ -74,7 +74,7 @@ export { t, schema } from './lib/spec.js'
74
74
  * @property {number} [recoveryTimeout] Max wait for peer data + writer capability during recovery.
75
75
  * @property {Uint8Array} [storageKey] 32-byte key encrypting local key material (master seed, device keypairs) at rest. Source it from the OS keychain — cero never stores it.
76
76
  * @property {boolean} [extensions] `false` disables the bundled extensions (profileSync, handleSync) for this instance. Build with `{ extensions: false }` too so the spec matches.
77
- * @property {boolean | { autoStart?: boolean, backend?: any, maxOutbound?: number, maxInbound?: number }} [bluetooth] `true` enables nearby (Bluetooth) sync via `me.bluetooth` (auto-started). `{ autoStart: false }` creates the facade without starting the radio — the app calls `me.bluetooth.start()`/`stop()` (user toggle). `backend` injects a bare-bluetooth-shaped backend (tests). `maxOutbound`/`maxInbound` cap concurrent outbound links and inbound sessions. Absent backend on an unsupported host → `me.bluetooth.state === 'unsupported'`.
77
+ * @property {boolean | { autoStart?: boolean, backend?: any, maxOutbound?: number, maxInbound?: number, pipe?: 'l2cap' | 'gatt' }} [bluetooth] `true` enables nearby (Bluetooth) sync via `me.bluetooth` (auto-started). `{ autoStart: false }` creates the facade without starting the radio — the app calls `me.bluetooth.start()`/`stop()` (user toggle). `backend` injects a bare-bluetooth-shaped backend (tests). `maxOutbound`/`maxInbound` cap concurrent outbound links and inbound sessions. `pipe` picks the data pipe — `'l2cap'` (default, faster) or `'gatt'`; both peers must match. Absent backend on an unsupported host → `me.bluetooth.state === 'unsupported'`.
78
78
  */
79
79
 
80
80
  /**
@@ -202,7 +202,8 @@ export async function cero(dir, spec, opts = {}) {
202
202
  backend: bt.backend,
203
203
  autoStart: bt.autoStart !== false,
204
204
  maxOutbound: bt.maxOutbound,
205
- maxInbound: bt.maxInbound
205
+ maxInbound: bt.maxInbound,
206
+ pipe: bt.pipe
206
207
  })
207
208
  me.once('close', () => me.bluetooth.close().catch(safetyCatch))
208
209
  await me.bluetooth.ready()
@@ -1,11 +1,13 @@
1
1
  /**
2
- * `me.bluetooth` — the whole app-facing surface for nearby (Bluetooth) sync.
3
- * Bluetooth is a cero feature, not a parallel API: it only changes how peers
4
- * meet and carry bytes; capability-gated replication still decides what syncs.
2
+ * `me.bluetooth` — the app-facing surface for nearby (Bluetooth) sync, a thin
3
+ * facade over ble-swarm. Bluetooth only changes how peers meet and carry
4
+ * bytes; capability-gated replication still decides what syncs. Discovery is
5
+ * one topic-derived service UUID at a time (tag `cero-ble`) — the data service
6
+ * sits on a fixed per-tag UUID, so switching topics only retunes the radio.
5
7
  *
6
8
  * ```js
7
9
  * const me = await cero(dir, spec, { channel, bluetooth: true })
8
- * me.bluetooth.state // 'unsupported' | 'unauthorized' | 'off' | 'waiting' | 'on'
10
+ * me.bluetooth.state // 'unsupported' | 'unauthorized' | 'off' | 'waiting' | 'starting' | 'on'
9
11
  * await me.bluetooth.start()
10
12
  * me.bluetooth.peers // Map of live BLE links
11
13
  * me.bluetooth.on('update', () => {})
@@ -17,7 +19,7 @@ export class Bluetooth extends ReadyResource {
17
19
  /**
18
20
  * @param {object} handle Root cero Handle (network + identity + channel).
19
21
  * @param {object} [opts]
20
- * @param {any} [opts.backend] Injected bare-bluetooth-shaped backend (tests); lazy-loaded when absent.
22
+ * @param {any} [opts.backend] Injected bare-bluetooth-shaped backend (tests); omitted → ble-swarm loads its own, null/false → unsupported.
21
23
  * @param {boolean} [opts.autoStart] Start on handle open (from `cero({ bluetooth: true })`).
22
24
  * @param {number} [opts.maxOutbound] Max concurrent outbound links; gossip covers the rest.
23
25
  * @param {number} [opts.maxInbound] Max concurrent inbound sessions; newcomers past this are refused.
@@ -31,25 +33,44 @@ export class Bluetooth extends ReadyResource {
31
33
  pipe?: "l2cap" | "gatt";
32
34
  });
33
35
  _handle: any;
34
- _backend: any;
35
- _lazy: boolean;
36
36
  _autoStart: boolean;
37
- _maxOutbound: number;
38
- _maxInbound: number;
39
- _pipe: "l2cap" | "gatt";
40
- _transport: BLETransport;
41
- _name: any;
42
- _announces: Set<any>;
43
- /** @type {'unsupported'|'unauthorized'|'off'|'waiting'|'starting'|'on'} */
44
- state: "unsupported" | "unauthorized" | "off" | "waiting" | "starting" | "on";
37
+ /** @type {{ hex: string, count: number, timer: any } | null} active invite rendezvous (single topic — one at a time) */
38
+ _announce: {
39
+ hex: string;
40
+ count: number;
41
+ timer: any;
42
+ } | null;
43
+ _restorePending: boolean;
44
+ _topic: any;
45
+ swarm: any;
46
+ /** @returns {'unsupported'|'unauthorized'|'off'|'waiting'|'starting'|'on'} */
47
+ get state(): "unsupported" | "unauthorized" | "off" | "waiting" | "starting" | "on";
48
+ /** @returns {Map<string, any>} Live BLE links, keyed by peer public key. */
49
+ get peers(): Map<string, any>;
50
+ /**
51
+ * Begin advertising + scanning. Idempotent; no-op when unsupported.
52
+ *
53
+ * @returns {Promise<void>}
54
+ */
55
+ start(): Promise<void>;
56
+ /**
57
+ * Stop advertising/scanning and drop links; open invite rendezvous end with
58
+ * the radio. Idempotent. Local data and the rest of the network (DHT) are
59
+ * untouched.
60
+ *
61
+ * @returns {Promise<void>}
62
+ */
63
+ stop(): Promise<void>;
45
64
  /**
46
- * Offline join rendezvous. Both sides derive the same BLE service UUID from
47
- * the invite, so they find each other with zero DHT: the host calls this
48
- * while the invite QR is on screen; the joiner's `open(me.room, invite)`
49
- * calls it automatically for the duration of the join. Returns a stop
50
- * function closing the QR must stop the advertisement so a photographed
51
- * invite doesn't stay an ambient admission ticket. Auto-stops at the
52
- * invite's expiry, on `bluetooth.stop()`, and on close.
65
+ * Offline join rendezvous: retune the radio to the invite-derived topic so
66
+ * holder and joiner find each other with zero DHT. One topic at a time —
67
+ * announcing a new invite replaces the previous rendezvous. Returns a stop
68
+ * function closing the QR must stop the rendezvous so a photographed
69
+ * invite doesn't stay an ambient discovery beacon (admission itself is
70
+ * always gated by blind-pairing verifying the invite). The retune back to
71
+ * the mesh topic waits for live links to drain: the link a join just
72
+ * established survives and carries the joiner's initial replication.
73
+ * Auto-stops at the invite's expiry, on `stop()`, and on close.
53
74
  *
54
75
  * Only active while nearby sync is on: before `start()` (and after `stop()`)
55
76
  * this is a no-op — the user controls the radio, and a join must not touch
@@ -59,22 +80,19 @@ export class Bluetooth extends ReadyResource {
59
80
  * @returns {() => void}
60
81
  */
61
82
  announce(invite: string): () => void;
62
- /** @returns {Map<string, any>} Live BLE links, keyed by peer node id. */
63
- get peers(): Map<string, any>;
83
+ _stopAnnounce(): void;
84
+ _clearAnnounce(): void;
64
85
  /**
65
- * Begin advertising + scanning on the channel-derived UUID. Idempotent.
66
- * No-op (stays `unsupported`) when no backend is present.
86
+ * Host-lifecycle pause (app backgrounded): radio down, user intent kept.
67
87
  *
68
88
  * @returns {Promise<void>}
69
89
  */
70
- start({ name }?: {}): Promise<void>;
90
+ suspend(): Promise<void>;
71
91
  /**
72
- * Stop advertising/scanning and drop links. Idempotent. Sync stops; local
73
- * data and the rest of the network (DHT) are untouched.
92
+ * Resume after a host-lifecycle pause.
74
93
  *
75
94
  * @returns {Promise<void>}
76
95
  */
77
- stop(): Promise<void>;
96
+ resume(): Promise<void>;
78
97
  }
79
98
  import ReadyResource from 'ready-resource';
80
- import { BLETransport } from '@cero-base/core/network/transports/ble';
@@ -96,6 +96,7 @@ export class Handle extends ReadyResource {
96
96
  _fileServer: FileServer;
97
97
  _blobs: Blobs;
98
98
  _epochBlobs: any;
99
+ _sus: any;
99
100
  _owned: Set<any>;
100
101
  store: Database;
101
102
  pair: Pairing;
@@ -314,13 +315,14 @@ export class Handle extends ReadyResource {
314
315
  * @returns {Promise<void>}
315
316
  */
316
317
  suspend(): Promise<void>;
317
- _suspended: boolean;
318
+ _suspend(): Promise<void>;
318
319
  /**
319
320
  * Resume a suspended root handle. Idempotent; no-op on child handles.
320
321
  *
321
322
  * @returns {Promise<void>}
322
323
  */
323
324
  resume(): Promise<void>;
325
+ _resume(): Promise<void>;
324
326
  /**
325
327
  * @param {Handle} child
326
328
  * @param {{ role?: string }} [opts]
package/types/index.d.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  * @property {number} [recoveryTimeout] Max wait for peer data + writer capability during recovery.
21
21
  * @property {Uint8Array} [storageKey] 32-byte key encrypting local key material (master seed, device keypairs) at rest. Source it from the OS keychain — cero never stores it.
22
22
  * @property {boolean} [extensions] `false` disables the bundled extensions (profileSync, handleSync) for this instance. Build with `{ extensions: false }` too so the spec matches.
23
- * @property {boolean | { autoStart?: boolean, backend?: any, maxOutbound?: number, maxInbound?: number }} [bluetooth] `true` enables nearby (Bluetooth) sync via `me.bluetooth` (auto-started). `{ autoStart: false }` creates the facade without starting the radio — the app calls `me.bluetooth.start()`/`stop()` (user toggle). `backend` injects a bare-bluetooth-shaped backend (tests). `maxOutbound`/`maxInbound` cap concurrent outbound links and inbound sessions. Absent backend on an unsupported host → `me.bluetooth.state === 'unsupported'`.
23
+ * @property {boolean | { autoStart?: boolean, backend?: any, maxOutbound?: number, maxInbound?: number, pipe?: 'l2cap' | 'gatt' }} [bluetooth] `true` enables nearby (Bluetooth) sync via `me.bluetooth` (auto-started). `{ autoStart: false }` creates the facade without starting the radio — the app calls `me.bluetooth.start()`/`stop()` (user toggle). `backend` injects a bare-bluetooth-shaped backend (tests). `maxOutbound`/`maxInbound` cap concurrent outbound links and inbound sessions. `pipe` picks the data pipe — `'l2cap'` (default, faster) or `'gatt'`; both peers must match. Absent backend on an unsupported host → `me.bluetooth.state === 'unsupported'`.
24
24
  */
25
25
  /**
26
26
  * Open (or create) a cero handle at `dir`. Sets up storage, network and
@@ -140,13 +140,14 @@ export type CeroOpts = {
140
140
  */
141
141
  extensions?: boolean;
142
142
  /**
143
- * `true` enables nearby (Bluetooth) sync via `me.bluetooth` (auto-started). `{ autoStart: false }` creates the facade without starting the radio — the app calls `me.bluetooth.start()`/`stop()` (user toggle). `backend` injects a bare-bluetooth-shaped backend (tests). `maxOutbound`/`maxInbound` cap concurrent outbound links and inbound sessions. Absent backend on an unsupported host → `me.bluetooth.state === 'unsupported'`.
143
+ * `true` enables nearby (Bluetooth) sync via `me.bluetooth` (auto-started). `{ autoStart: false }` creates the facade without starting the radio — the app calls `me.bluetooth.start()`/`stop()` (user toggle). `backend` injects a bare-bluetooth-shaped backend (tests). `maxOutbound`/`maxInbound` cap concurrent outbound links and inbound sessions. `pipe` picks the data pipe — `'l2cap'` (default, faster) or `'gatt'`; both peers must match. Absent backend on an unsupported host → `me.bluetooth.state === 'unsupported'`.
144
144
  */
145
145
  bluetooth?: boolean | {
146
146
  autoStart?: boolean;
147
147
  backend?: any;
148
148
  maxOutbound?: number;
149
149
  maxInbound?: number;
150
+ pipe?: "l2cap" | "gatt";
150
151
  };
151
152
  };
152
153
  import { t } from './lib/spec.js';