@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 +5 -12
- package/src/bluetooth.js +131 -150
- package/src/handle/index.js +62 -10
- package/src/index.js +3 -2
- package/types/bluetooth.d.ts +49 -31
- package/types/handle/index.d.ts +3 -1
- package/types/index.d.ts +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cero-base/cero",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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);
|
|
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
|
-
|
|
56
|
-
this.
|
|
57
|
-
this.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
*
|
|
67
|
-
* the
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
|
|
72
|
-
|
|
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.
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
this.
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
if (
|
|
111
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
if (!
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
*
|
|
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
|
|
137
|
-
|
|
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
|
-
*
|
|
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
|
|
194
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
}
|
package/src/handle/index.js
CHANGED
|
@@ -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
|
-
|
|
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 }) =>
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
838
|
-
|
|
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.
|
|
855
|
-
|
|
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()
|
package/types/bluetooth.d.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `me.bluetooth` — the
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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);
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
-
|
|
63
|
-
|
|
83
|
+
_stopAnnounce(): void;
|
|
84
|
+
_clearAnnounce(): void;
|
|
64
85
|
/**
|
|
65
|
-
*
|
|
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
|
-
|
|
90
|
+
suspend(): Promise<void>;
|
|
71
91
|
/**
|
|
72
|
-
*
|
|
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
|
-
|
|
96
|
+
resume(): Promise<void>;
|
|
78
97
|
}
|
|
79
98
|
import ReadyResource from 'ready-resource';
|
|
80
|
-
import { BLETransport } from '@cero-base/core/network/transports/ble';
|
package/types/handle/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
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';
|