@cero-base/cero 1.12.0 → 1.13.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.13.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.13.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": "github:holepunchto/ble-swarm#63566f02d4b3a1f3849b9fb009e18666411592a5",
104
105
  "compact-encoding": "^3.3.2",
105
106
  "corestore": "^7.12.0",
106
107
  "hrpc": "^4.3.1",
@@ -124,14 +125,5 @@
124
125
  "brittle": "^4.1.0",
125
126
  "typescript": "^5.9.3"
126
127
  },
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
- }
128
+ "license": "Apache-2.0"
137
129
  }
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,90 @@ 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 mesh
111
+ await this.swarm.setTopic(this._topic)
112
+ }
113
+
114
+ /**
115
+ * Offline join rendezvous: retune the radio to the invite-derived topic so
116
+ * holder and joiner find each other with zero DHT. One topic at a time —
117
+ * announcing a new invite replaces the previous rendezvous. Returns a stop
118
+ * function — closing the QR must stop the rendezvous so a photographed
119
+ * invite doesn't stay an ambient discovery beacon (admission itself is
120
+ * always gated by blind-pairing verifying the invite). The retune back to
121
+ * the mesh topic waits for live links to drain: the link a join just
122
+ * established survives and carries the joiner's initial replication.
123
+ * Auto-stops at the invite's expiry, on `stop()`, and on close.
73
124
  *
74
125
  * Only active while nearby sync is on: before `start()` (and after `stop()`)
75
126
  * this is a no-op — the user controls the radio, and a join must not touch
@@ -79,141 +130,70 @@ export class Bluetooth extends ReadyResource {
79
130
  * @returns {() => void}
80
131
  */
81
132
  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
- )
133
+ if (!this.swarm.supported) return () => {}
134
+ if (this.state !== 'on' && this.state !== 'waiting' && this.state !== 'starting') {
87
135
  return () => {}
136
+ }
88
137
  const topic = Pairing.inviteTopic(invite)
89
138
  if (!topic) return () => {}
90
139
 
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
140
+ const hex = b4a.toString(topic, 'hex')
141
+ if (this._announce && this._announce.hex !== hex) this._stopAnnounce()
142
+ if (!this._announce) {
143
+ const entry = { hex, count: 0, timer: null }
144
+ const { expires } = Invite.parse(invite)
145
+ if (expires > 0) {
146
+ entry.timer = setTimeout(() => this._stopAnnounce(), Math.max(0, expires - Date.now()))
147
+ }
148
+ this._announce = entry
149
+ this._restorePending = false
150
+ this.swarm.setTopic(topic).catch(safetyCatch)
151
+ }
152
+ const entry = this._announce
153
+ entry.count++
107
154
 
108
- const stop = () => {
109
- if (timer) clearTimeout(timer)
110
- if (!this._announces.delete(transport)) return
111
- transport.close().catch(safetyCatch)
155
+ let stopped = false
156
+ return () => {
157
+ if (stopped) return
158
+ stopped = true
159
+ if (this._announce === entry && --entry.count <= 0) this._stopAnnounce()
112
160
  }
113
- return stop
114
161
  }
115
162
 
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()
163
+ _stopAnnounce() {
164
+ this._clearAnnounce()
165
+ if (this.swarm.peers.size > 0) this._restorePending = true
166
+ else this.swarm.setTopic(this._topic).catch(safetyCatch)
119
167
  }
120
168
 
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()
169
+ _clearAnnounce() {
170
+ const e = this._announce
171
+ if (!e) return
172
+ if (e.timer) clearTimeout(e.timer)
173
+ this._announce = null
174
+ this._restorePending = false
128
175
  }
129
176
 
130
177
  /**
131
- * Begin advertising + scanning on the channel-derived UUID. Idempotent.
132
- * No-op (stays `unsupported`) when no backend is present.
178
+ * Host-lifecycle pause (app backgrounded): radio down, user intent kept.
133
179
  *
134
180
  * @returns {Promise<void>}
135
181
  */
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')
182
+ async suspend() {
183
+ await this.swarm.suspend()
185
184
  }
186
185
 
187
186
  /**
188
- * Stop advertising/scanning and drop links. Idempotent. Sync stops; local
189
- * data and the rest of the network (DHT) are untouched.
187
+ * Resume after a host-lifecycle pause.
190
188
  *
191
189
  * @returns {Promise<void>}
192
190
  */
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')
191
+ async resume() {
192
+ await this.swarm.resume()
205
193
  }
206
194
 
207
195
  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')
196
+ this._clearAnnounce()
197
+ await this.swarm.destroy()
218
198
  }
219
199
  }
@@ -837,6 +837,7 @@ export class Handle extends ReadyResource {
837
837
  if (this.parent || this.closing || this.closed || this._suspended) return
838
838
  this._suspended = true
839
839
  await Promise.all([...this.children].map((c) => c.pair?.suspend()))
840
+ await this.bluetooth?.suspend()
840
841
  await this.network.suspend()
841
842
  try {
842
843
  await this.store.store.suspend()
@@ -859,6 +860,7 @@ export class Handle extends ReadyResource {
859
860
  this._onerror(err)
860
861
  }
861
862
  await this.network.resume()
863
+ await this.bluetooth?.resume()
862
864
  await Promise.all([...this.children].map((child) => child.pair?.resume()))
863
865
  }
864
866
 
package/src/index.js CHANGED
@@ -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';