@cero-base/core 1.2.0 → 1.4.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.
@@ -1,324 +0,0 @@
1
- import ReadyResource from 'ready-resource'
2
- import b4a from 'b4a'
3
- import { hash } from 'hypercore-crypto'
4
- import safetyCatch from 'safety-catch'
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'
11
- const DEFAULT_CAP = 4
12
- const CONNECT_TIMEOUT = 15000
13
-
14
- /**
15
- * Derive a stable 128-bit BLE service UUID from a topic. Only devices that
16
- * compute the same UUID (same channel / same invite) ever discover each other.
17
- *
18
- * @param {Uint8Array} topic
19
- * @param {string} [tag] Namespace so channel and invite meshes never collide.
20
- * @returns {string}
21
- */
22
- export function toServiceUUID(topic, tag = 'cero-ble') {
23
- const h = hash([b4a.from(tag), topic])
24
- const hex = b4a.toString(h.subarray(0, 16), 'hex')
25
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
26
- }
27
-
28
- // Backend adapter state → the app-facing state the facade exposes.
29
- const STATE = {
30
- poweredOn: 'on',
31
- poweredOff: 'waiting',
32
- unauthorized: 'unauthorized',
33
- unsupported: 'unsupported',
34
- resetting: 'waiting',
35
- unknown: 'waiting'
36
- }
37
-
38
- const uuidEq = (a, b) =>
39
- String(a || '')
40
- .toLowerCase()
41
- .replace(/-/g, '') ===
42
- String(b || '')
43
- .toLowerCase()
44
- .replace(/-/g, '')
45
-
46
- const findByUUID = (items, uuid) => (items || []).find((i) => uuidEq(i.uuid, uuid)) || null
47
-
48
- /**
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
51
- * there replication and pairing are transport-agnostic (see Network.inject).
52
- *
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.
57
- *
58
- * ponytail: capability-handshake DoS link-scoring is deferred — it needs a
59
- * replication-progress signal (design §4b). v1 caps links + times out dials.
60
- *
61
- * @extends ReadyResource
62
- */
63
- export class BluetoothTransport extends ReadyResource {
64
- /**
65
- * @param {object} opts
66
- * @param {any} opts.backend bare-bluetooth-shaped module (Central, Server, Service, Characteristic).
67
- * @param {import('./index.js').Network} opts.network
68
- * @param {Uint8Array} opts.uuid The 32-byte topic the service UUID derives from.
69
- * @param {Uint8Array} opts.nodeId Stable local id (identity/device key) for the initiate tie-break.
70
- * @param {string} [opts.tag] UUID namespace (channel mesh vs invite mesh).
71
- * @param {number} [opts.cap] Max concurrent links; gossip covers the rest.
72
- * @param {{ scanMode?: any }} [opts.scanOptions] Platform scan options (e.g. Android low-power).
73
- * @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).
74
- */
75
- constructor({
76
- backend,
77
- network,
78
- uuid,
79
- nodeId,
80
- tag = 'cero-ble',
81
- cap = DEFAULT_CAP,
82
- scanOptions,
83
- keepLinks = false
84
- }) {
85
- super()
86
- this.backend = backend
87
- this.network = network
88
- this.nodeId = nodeId
89
- this.nodeHex = b4a.toString(nodeId, 'hex')
90
- this.serviceUUID = toServiceUUID(uuid, tag)
91
- this.cap = cap
92
- this.scanOptions = scanOptions
93
- this.keepLinks = keepLinks
94
-
95
- this.state = 'off'
96
- this.central = null
97
- this.server = null
98
- this.psm = null
99
- this._scanning = false
100
- this._advertising = false
101
- this._serviceAdded = false
102
- /** peripheral id being dialed → its connect-timeout timer */
103
- this._dialing = new Map()
104
- /** live injected links keyed by remote node id hex */
105
- this.peers = new Map()
106
- }
107
-
108
- /**
109
- * Whether we should be the one to open the connection to `peerNodeId`. The
110
- * lexicographically smaller id initiates; the larger waits — so a pair
111
- * connects once, not twice. Equal (our own reflection) → false.
112
- *
113
- * @param {Uint8Array} peerNodeId
114
- * @returns {boolean}
115
- */
116
- shouldInitiate(peerNodeId) {
117
- return b4a.compare(this.nodeId, peerNodeId) < 0
118
- }
119
-
120
- get linkCount() {
121
- return this.peers.size
122
- }
123
-
124
- async _open() {
125
- const { Central, Server, Service, Characteristic } = this.backend
126
-
127
- this.server = new Server()
128
- this.server.on('stateChange', (s) => {
129
- this._onState(s)
130
- if (s === 'poweredOn') this._startServer(Service, Characteristic)
131
- })
132
- this.server.on('serviceAdd', () => {
133
- this._serviceAdded = true
134
- this._maybeAdvertise()
135
- })
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))
148
- this.server.on('error', safetyCatch)
149
-
150
- this.central = new Central()
151
- this.central.on('stateChange', (s) => {
152
- this._onState(s)
153
- if (s === 'poweredOn') this._startScan()
154
- })
155
- this.central.on('discover', (peripheral) => this._onDiscover(peripheral))
156
- this.central.on('connect', (peripheral) => this._onConnect(peripheral))
157
- this.central.on('disconnect', () => {})
158
- this.central.on('error', (err) => this._onCentralError(err))
159
-
160
- this._startServer(Service, Characteristic)
161
- this._startScan()
162
- this.state = 'starting'
163
- }
164
-
165
- _startServer(Service, Characteristic) {
166
- if (this.server.state !== 'poweredOn') return // wait for the adapter (stateChange)
167
- if (!this._serviceAdded) {
168
- const char = new Characteristic(PSM_UUID, { read: true })
169
- this.server.addService(new Service(this.serviceUUID, [char]))
170
- }
171
- if (this.psm == null) this.server.publishChannel({})
172
- }
173
-
174
- _maybeAdvertise() {
175
- if (this._advertising || !this._serviceAdded || this.psm == null) return
176
- this._advertising = true
177
- this.server.startAdvertising({ serviceUUIDs: [this.serviceUUID] })
178
- }
179
-
180
- _startScan() {
181
- if (this._scanning || this.central.state !== 'poweredOn') return
182
- this._scanning = true
183
- this.central.startScan([this.serviceUUID], this.scanOptions)
184
- }
185
-
186
- _onState(raw) {
187
- const next = STATE[raw] ?? 'waiting'
188
- if (next === this.state) return
189
- this.state = next
190
- this.emit('update')
191
- }
192
-
193
- _onDiscover(peripheral) {
194
- if (this.closing || this.closed) return
195
- if (this._dialing.has(peripheral.id)) return // already connecting to this one
196
- 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.
199
- const timer = setTimeout(() => this._abortDial(peripheral, 'timeout'), CONNECT_TIMEOUT)
200
- this._dialing.set(peripheral.id, timer)
201
- try {
202
- this.central.connect(peripheral)
203
- } catch (err) {
204
- this._abortDial(peripheral, err)
205
- }
206
- }
207
-
208
- _onConnect(peripheral) {
209
- peripheral.on('error', () => this._abortDial(peripheral, 'peripheral-error'))
210
- peripheral.once('servicesDiscover', (services) => {
211
- const svc = findByUUID(services, this.serviceUUID)
212
- if (svc) peripheral.discoverCharacteristics(svc, [PSM_UUID])
213
- else this._abortDial(peripheral, 'no-service')
214
- })
215
- 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')
219
- })
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')
228
- return
229
- }
230
- peripheral.once('channelOpen', (l2cap) => {
231
- this._clearDial(peripheral.id)
232
- this._onChannel(l2cap, true, peripheral.id)
233
- })
234
- peripheral.openL2CAPChannel(psm)
235
- })
236
- peripheral.discoverServices([this.serviceUUID])
237
- }
238
-
239
- _abortDial(peripheral, _reason) {
240
- const id = peripheral?.id
241
- this._clearDial(id)
242
- try {
243
- this.central.disconnect(peripheral)
244
- } catch (err) {
245
- safetyCatch(err)
246
- }
247
- }
248
-
249
- _clearDial(id) {
250
- if (id == null) return
251
- const timer = this._dialing.get(id)
252
- if (timer) clearTimeout(timer)
253
- this._dialing.delete(id) // free the slot — rediscovery can re-dial
254
- }
255
-
256
- // iOS & Android report errored connects as 'error' with a code, not
257
- // 'disconnect' — without this a failed dial pins the peripheral forever.
258
- _onCentralError(err) {
259
- safetyCatch(err)
260
- const code = err && err.code
261
- if (code === 'CONNECTION_FAILED' || code === 'DISCONNECT') {
262
- for (const id of [...this._dialing.keys()]) this._clearDial(id)
263
- }
264
- }
265
-
266
- _onChannel(l2cap, isInitiator, peripheralId) {
267
- if (this.closing || this.closed) {
268
- l2cap.destroy()
269
- return
270
- }
271
- const conn = this.network.inject(l2cap, { isInitiator })
272
- conn.on('open', () => this._track(conn, peripheralId))
273
- conn.on('close', () => this._untrack(conn, peripheralId))
274
- }
275
-
276
- _track(conn, peripheralId) {
277
- if (this.closing || this.closed) return
278
- const key = b4a.toString(conn.remotePublicKey, 'hex')
279
- const existing = this.peers.get(key)
280
- if (existing && existing !== conn) {
281
- conn.destroy() // duplicate link to the same peer — keep the first
282
- return
283
- }
284
- conn._peripheralId = peripheralId
285
- conn._peerKey = key
286
- this.peers.set(key, conn)
287
- this.emit('update')
288
- }
289
-
290
- _untrack(conn) {
291
- const key = conn._peerKey
292
- if (key && this.peers.get(key) === conn) this.peers.delete(key)
293
- if (!this.closing && !this.closed) this.emit('update')
294
- }
295
-
296
- async _close() {
297
- // doctor-app rule: never call central/server.destroy() — it double-frees in
298
- // the native teardown; stop advertising/scanning and let the runtime reclaim.
299
- for (const timer of this._dialing.values()) clearTimeout(timer)
300
- this._dialing.clear()
301
- try {
302
- this.central?.stopScan()
303
- } catch (err) {
304
- safetyCatch(err)
305
- }
306
- try {
307
- this.server?.stopAdvertising()
308
- } catch (err) {
309
- safetyCatch(err)
310
- }
311
- if (!this.keepLinks) {
312
- // channel mesh: toggling nearby off means stop syncing nearby
313
- for (const conn of this.peers.values()) {
314
- try {
315
- conn.destroy()
316
- } catch (err) {
317
- safetyCatch(err)
318
- }
319
- }
320
- }
321
- this.peers.clear()
322
- this.state = 'off'
323
- }
324
- }