@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.
- package/package.json +8 -4
- package/src/network/index.js +40 -54
- package/src/network/transports/ble.js +779 -0
- package/src/network/transports/dht.js +110 -0
- package/src/network/transports/gatt.js +57 -0
- package/types/network/index.d.ts +7 -3
- package/types/network/{bluetooth.d.ts → transports/ble.d.ts} +59 -19
- package/types/network/transports/dht.d.ts +75 -0
- package/types/network/transports/gatt.d.ts +25 -0
- package/src/network/bluetooth.js +0 -324
package/src/network/bluetooth.js
DELETED
|
@@ -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
|
-
}
|