@cero-base/core 1.3.0 → 1.5.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 +32 -15
- package/src/database/bootstrap.js +62 -8
- package/src/database/changes.js +120 -0
- package/src/database/dispatch.js +6 -6
- package/src/database/envelope.js +32 -0
- package/src/database/index.js +109 -7
- package/src/lib/spec/index.js +35 -2
- package/src/lib/spec/schema.json +23 -1
- package/src/network/index.js +63 -53
- package/src/network/{bluetooth.js → transports/ble.js} +152 -167
- package/src/network/transports/dht.js +110 -0
- package/src/network/{gatt-stream.js → transports/gatt.js} +4 -6
- package/src/rpc/index.js +23 -0
- package/types/database/changes.d.ts +15 -0
- package/types/database/envelope.d.ts +19 -0
- package/types/database/index.d.ts +32 -0
- package/types/lib/spec/index.d.ts +25 -12
- package/types/network/index.d.ts +21 -4
- package/types/network/{bluetooth.d.ts → transports/ble.d.ts} +25 -51
- package/types/network/transports/dht.d.ts +75 -0
- package/types/network/{gatt-stream.d.ts → transports/gatt.d.ts} +2 -3
- package/types/rpc/index.d.ts +10 -0
|
@@ -3,15 +3,14 @@ import b4a from 'b4a'
|
|
|
3
3
|
import { hash, randomBytes } from 'hypercore-crypto'
|
|
4
4
|
import safetyCatch from 'safety-catch'
|
|
5
5
|
|
|
6
|
-
import { GattStream } from './gatt
|
|
6
|
+
import { GattStream } from './gatt.js'
|
|
7
7
|
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// (the bitchat mesh app uses exactly this on iOS and Android).
|
|
8
|
+
// iOS never answers an L2CAP channel opened by a Mac central and gives no L2CAP
|
|
9
|
+
// disconnect signal, so one data characteristic (write + notify) carries a
|
|
10
|
+
// framed byte stream both ways instead.
|
|
12
11
|
const DATA_UUID = 'ce1a0004-0000-1000-8000-00805f9b34fb'
|
|
13
12
|
|
|
14
|
-
// Wire frame
|
|
13
|
+
// Wire frame both directions: [type:1][sessionId:8][payload].
|
|
15
14
|
const TYPE_OPEN = 1
|
|
16
15
|
const TYPE_DATA = 2
|
|
17
16
|
const TYPE_CLOSE = 3
|
|
@@ -19,23 +18,23 @@ const TYPE_HELLO = 4
|
|
|
19
18
|
const SID_LEN = 8
|
|
20
19
|
const HEADER = 1 + SID_LEN
|
|
21
20
|
|
|
22
|
-
const
|
|
21
|
+
const DEFAULT_MAX_OUTBOUND = 4
|
|
22
|
+
const DEFAULT_MAX_INBOUND = 8
|
|
23
23
|
const CONNECT_TIMEOUT = 15000
|
|
24
|
-
// per-peer dial backoff
|
|
25
|
-
//
|
|
24
|
+
// per-peer dial backoff: eager while unlinked, patient once linked; the cooldown
|
|
25
|
+
// grows exponentially per consecutive failure.
|
|
26
26
|
const DIAL_COOLDOWN_BASE = 8000
|
|
27
27
|
const DIAL_COOLDOWN_BASE_LONELY = 2000
|
|
28
28
|
const DIAL_COOLDOWN_MAX = 30000
|
|
29
|
-
//
|
|
29
|
+
// one radio can't usefully dial faster than this
|
|
30
30
|
const DIAL_MIN_INTERVAL = 500
|
|
31
|
-
//
|
|
32
|
-
//
|
|
31
|
+
// iOS reports a peripheral once per scan session; restart to re-report a
|
|
32
|
+
// re-advertised peer.
|
|
33
33
|
const SCAN_RESTART_LONELY = 5000
|
|
34
|
-
//
|
|
35
|
-
// (bitchat model) — scan for SCAN_DUTY_ON, then stay dark for SCAN_DUTY_OFF.
|
|
34
|
+
// linked: continuous scanning is the dominant battery cost, so duty-cycle it.
|
|
36
35
|
const SCAN_DUTY_ON = 5000
|
|
37
36
|
const SCAN_DUTY_OFF = 25000
|
|
38
|
-
// suspend() drain window:
|
|
37
|
+
// suspend() drain window: let goodbye frames flush before hanging up.
|
|
39
38
|
const DRAIN_MS = 300
|
|
40
39
|
|
|
41
40
|
const EMPTY = b4a.alloc(0)
|
|
@@ -57,7 +56,7 @@ function frame(type, sid, payload = EMPTY) {
|
|
|
57
56
|
}
|
|
58
57
|
|
|
59
58
|
/**
|
|
60
|
-
* Parse a wire frame. Short/empty buffers → null (
|
|
59
|
+
* Parse a wire frame. Short/empty buffers → null (caller drops).
|
|
61
60
|
*
|
|
62
61
|
* @param {Uint8Array} buf
|
|
63
62
|
* @returns {{ type: number, sid: Uint8Array, sidHex: string, payload: Uint8Array } | null}
|
|
@@ -112,26 +111,24 @@ const findByUUID = (items, uuid) => (items || []).find((i) => uuidEq(i.uuid, uui
|
|
|
112
111
|
* byte-stream to each discovered peer, and feeds it into `network.inject`. From
|
|
113
112
|
* there replication and pairing are transport-agnostic (see Network.inject).
|
|
114
113
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
* ponytail: capability-handshake DoS link-scoring is deferred — it needs a
|
|
122
|
-
* replication-progress signal (design §4b). v1 caps links + times out dials.
|
|
114
|
+
* The server adds one data characteristic (write + notify) and advertises. The
|
|
115
|
+
* central connects, discovers the characteristic, subscribes, then framed bytes
|
|
116
|
+
* flow both ways — central→server as GATT writes, server→central as
|
|
117
|
+
* notifications — each tagged with an 8-byte session id. `backend` is
|
|
118
|
+
* bare-bluetooth in production and a mock in tests.
|
|
123
119
|
*
|
|
124
120
|
* @extends ReadyResource
|
|
125
121
|
*/
|
|
126
|
-
export class
|
|
122
|
+
export class BLETransport extends ReadyResource {
|
|
127
123
|
/**
|
|
128
124
|
* @param {object} opts
|
|
129
125
|
* @param {any} opts.backend bare-bluetooth-shaped module (Central, Server, Service, Characteristic).
|
|
130
|
-
* @param {import('
|
|
126
|
+
* @param {import('../index.js').Network} opts.network
|
|
131
127
|
* @param {Uint8Array} opts.uuid The 32-byte topic the service UUID derives from.
|
|
132
128
|
* @param {Uint8Array} opts.nodeId Stable local id (identity/device key) for the initiate tie-break.
|
|
133
129
|
* @param {string} [opts.tag] UUID namespace (channel mesh vs invite mesh).
|
|
134
|
-
* @param {number} [opts.
|
|
130
|
+
* @param {number} [opts.maxOutbound] Max concurrent outbound dials/links; gossip covers the rest.
|
|
131
|
+
* @param {number} [opts.maxInbound] Max concurrent inbound sessions; newcomers past this are refused.
|
|
135
132
|
* @param {{ scanMode?: any }} [opts.scanOptions] Platform scan options (e.g. Android low-power).
|
|
136
133
|
* @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).
|
|
137
134
|
* @param {string} [opts.name] Local app-user display name, sent to peers over a hello frame.
|
|
@@ -142,7 +139,8 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
142
139
|
uuid,
|
|
143
140
|
nodeId,
|
|
144
141
|
tag = 'cero-ble',
|
|
145
|
-
|
|
142
|
+
maxOutbound = DEFAULT_MAX_OUTBOUND,
|
|
143
|
+
maxInbound = DEFAULT_MAX_INBOUND,
|
|
146
144
|
scanOptions,
|
|
147
145
|
keepLinks = false,
|
|
148
146
|
name
|
|
@@ -154,7 +152,8 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
154
152
|
this.nodeId = nodeId
|
|
155
153
|
this.nodeHex = b4a.toString(nodeId, 'hex')
|
|
156
154
|
this.serviceUUID = toServiceUUID(uuid, tag)
|
|
157
|
-
this.
|
|
155
|
+
this.maxOutbound = maxOutbound
|
|
156
|
+
this.maxInbound = maxInbound
|
|
158
157
|
this.scanOptions = scanOptions
|
|
159
158
|
this.keepLinks = keepLinks
|
|
160
159
|
|
|
@@ -169,19 +168,8 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
169
168
|
this._scanning = false
|
|
170
169
|
this._advertising = false
|
|
171
170
|
this._serviceAdded = false
|
|
172
|
-
/** peripheral id
|
|
173
|
-
this.
|
|
174
|
-
/** peripheral ids that carry a live channel — never re-dialed (a second
|
|
175
|
-
* dial's failure would disconnect the peripheral and kill the good link) */
|
|
176
|
-
this._linked = new Set()
|
|
177
|
-
/** peripheral id → retry-after timestamp; failed dials back off */
|
|
178
|
-
this._coolUntil = new Map()
|
|
179
|
-
/** peripheral id → consecutive failure count; drives exponential backoff */
|
|
180
|
-
this._failures = new Map()
|
|
181
|
-
/** peripheral id → remote peer key, learned at handshake — dial guard */
|
|
182
|
-
this._peerByPeripheral = new Map()
|
|
183
|
-
/** live central-side peripheral wrappers — for goodbye + physical hang-up on suspend */
|
|
184
|
-
this._connectedPeripherals = new Set()
|
|
171
|
+
/** peripheral id → per-peer dial state { timer, linked, coolUntil, failures, peerKey, peripheral } */
|
|
172
|
+
this._devices = new Map()
|
|
185
173
|
/** last central.connect timestamp — global inter-dial rate limit */
|
|
186
174
|
this._lastDial = 0
|
|
187
175
|
this._scanTimer = null
|
|
@@ -206,6 +194,22 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
206
194
|
return this.peers.size
|
|
207
195
|
}
|
|
208
196
|
|
|
197
|
+
_device(id) {
|
|
198
|
+
let d = this._devices.get(id)
|
|
199
|
+
if (!d) {
|
|
200
|
+
d = { timer: null, linked: false, coolUntil: 0, failures: 0, peerKey: null, peripheral: null }
|
|
201
|
+
this._devices.set(id, d)
|
|
202
|
+
}
|
|
203
|
+
return d
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
_prune(id) {
|
|
207
|
+
const d = this._devices.get(id)
|
|
208
|
+
if (d && !d.timer && !d.linked && !d.coolUntil && !d.failures && !d.peerKey && !d.peripheral) {
|
|
209
|
+
this._devices.delete(id)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
209
213
|
async _open() {
|
|
210
214
|
const { Central, Server, Service, Characteristic } = this.backend
|
|
211
215
|
|
|
@@ -219,13 +223,9 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
219
223
|
this._maybeAdvertise()
|
|
220
224
|
})
|
|
221
225
|
this.server.on('writeRequest', (reqs) => this._onWriteRequests(reqs))
|
|
222
|
-
// Notify queue drain signal: the last updateValue was refused (queue full);
|
|
223
|
-
// retry the head frame now that the peripheral can accept more.
|
|
224
226
|
this.server.on('readyToUpdate', () => this._drainNotify())
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
// central; teardown is left to the per-link keepalive/timeout liveness in
|
|
228
|
-
// _onChannel (15s).
|
|
227
|
+
// writeRequests carry no central identifier, so an unsubscribe can't be
|
|
228
|
+
// mapped to a session; teardown is left to _onChannel's keepalive/timeout.
|
|
229
229
|
this.server.on('unsubscribe', () => {})
|
|
230
230
|
this.server.on('error', safetyCatch)
|
|
231
231
|
|
|
@@ -245,7 +245,7 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
245
245
|
}
|
|
246
246
|
|
|
247
247
|
_startServer(Service, Characteristic) {
|
|
248
|
-
if (this.server.state !== 'poweredOn') return
|
|
248
|
+
if (this.server.state !== 'poweredOn') return
|
|
249
249
|
if (!this._serviceAdded) {
|
|
250
250
|
this._dataChar = new Characteristic(DATA_UUID, { write: true, notify: true })
|
|
251
251
|
this.server.addService(new Service(this.serviceUUID, [this._dataChar]))
|
|
@@ -263,20 +263,24 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
263
263
|
_onWriteRequests(requests) {
|
|
264
264
|
const ok = this.server.constructor.ATT_SUCCESS ?? 0
|
|
265
265
|
for (const req of requests) {
|
|
266
|
-
// respond within ms or the central times out — before any parsing
|
|
266
|
+
// must respond within ms or the central times out — before any parsing
|
|
267
267
|
if (req.responseNeeded !== false) this.server.respondToRequest(req, ok)
|
|
268
|
-
if (this._suspended) continue
|
|
268
|
+
if (this._suspended) continue
|
|
269
269
|
this._onServerFrame(req.data)
|
|
270
270
|
}
|
|
271
271
|
}
|
|
272
272
|
|
|
273
273
|
_onServerFrame(data) {
|
|
274
274
|
const f = parseFrame(data)
|
|
275
|
-
if (!f)
|
|
276
|
-
return
|
|
277
|
-
}
|
|
275
|
+
if (!f) return
|
|
278
276
|
if (f.type === TYPE_OPEN) {
|
|
279
|
-
if (this._sessions.has(f.sidHex)) return
|
|
277
|
+
if (this._sessions.has(f.sidHex)) return
|
|
278
|
+
if (this._sessions.size >= this.maxInbound) {
|
|
279
|
+
// established links win: refuse newcomers with a CLOSE so the dialer's
|
|
280
|
+
// stream ends cleanly and backs off — the mesh converges transitively.
|
|
281
|
+
this._enqueueNotify(frame(TYPE_CLOSE, f.sid)).catch(safetyCatch)
|
|
282
|
+
return
|
|
283
|
+
}
|
|
280
284
|
const sid = b4a.from(f.sid) // copy: f.sid views the transient request buffer
|
|
281
285
|
const stream = new GattStream({
|
|
282
286
|
send: (payload) => this._enqueueNotify(frame(TYPE_DATA, sid, payload)),
|
|
@@ -285,7 +289,6 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
285
289
|
const session = { stream, sid, conn: null, name: null }
|
|
286
290
|
this._sessions.set(f.sidHex, session)
|
|
287
291
|
session.conn = this._onChannel(stream, false, null)
|
|
288
|
-
// greet the peer with our app-user name so it can label this link
|
|
289
292
|
this._enqueueNotify(frame(TYPE_HELLO, sid, this._helloPayload())).catch(safetyCatch)
|
|
290
293
|
} else if (f.type === TYPE_DATA) {
|
|
291
294
|
const s = this._sessions.get(f.sidHex)
|
|
@@ -299,29 +302,21 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
299
302
|
this._sessions.delete(f.sidHex)
|
|
300
303
|
s.stream.remoteEnd()
|
|
301
304
|
}
|
|
302
|
-
} else {
|
|
303
305
|
}
|
|
304
306
|
}
|
|
305
307
|
|
|
306
308
|
_closeServerSession(sidHex, sid) {
|
|
307
|
-
if (!this._sessions.has(sidHex)) return
|
|
309
|
+
if (!this._sessions.has(sidHex)) return
|
|
308
310
|
this._sessions.delete(sidHex)
|
|
309
|
-
this._enqueueNotify(frame(TYPE_CLOSE, sid)).catch(safetyCatch)
|
|
311
|
+
this._enqueueNotify(frame(TYPE_CLOSE, sid)).catch(safetyCatch)
|
|
310
312
|
}
|
|
311
313
|
|
|
312
314
|
// ─── peer display name (hello frame) ──────────────────────────────────────
|
|
313
315
|
|
|
314
|
-
/** Our hello payload: the local app-user name the peer labels this link with. */
|
|
315
316
|
_helloPayload() {
|
|
316
317
|
return b4a.from(JSON.stringify({ n: this.name || '' }))
|
|
317
318
|
}
|
|
318
319
|
|
|
319
|
-
/**
|
|
320
|
-
* Parse a hello payload. Malformed → null (the caller ignores it).
|
|
321
|
-
*
|
|
322
|
-
* @param {Uint8Array} payload
|
|
323
|
-
* @returns {string | null}
|
|
324
|
-
*/
|
|
325
320
|
_parseHello(payload) {
|
|
326
321
|
try {
|
|
327
322
|
const { n } = JSON.parse(b4a.toString(payload))
|
|
@@ -331,18 +326,17 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
331
326
|
}
|
|
332
327
|
}
|
|
333
328
|
|
|
334
|
-
/** Stash a peer's name onto a server session + its conn, then refresh mirrors. */
|
|
335
329
|
_applyPeerName(session, payload) {
|
|
336
330
|
const name = this._parseHello(payload)
|
|
337
|
-
if (name === null) return
|
|
331
|
+
if (name === null) return
|
|
338
332
|
session.name = name
|
|
339
333
|
if (session.conn) session.conn._peerName = name
|
|
340
334
|
this.emit('update')
|
|
341
335
|
}
|
|
342
336
|
|
|
343
337
|
// Serialize notifications through the single characteristic: updateValue
|
|
344
|
-
// returns false when the peripheral's queue is full — hold the frame and
|
|
345
|
-
// on the next 'readyToUpdate', preserving order.
|
|
338
|
+
// returns false when the peripheral's queue is full — hold the head frame and
|
|
339
|
+
// retry on the next 'readyToUpdate', preserving order.
|
|
346
340
|
_enqueueNotify(f) {
|
|
347
341
|
return new Promise((resolve, reject) => {
|
|
348
342
|
this._notifyQueue.push({ frame: f, resolve, reject })
|
|
@@ -361,7 +355,7 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
361
355
|
item.reject(err)
|
|
362
356
|
continue
|
|
363
357
|
}
|
|
364
|
-
if (!ok) return
|
|
358
|
+
if (!ok) return
|
|
365
359
|
this._notifyQueue.shift()
|
|
366
360
|
item.resolve()
|
|
367
361
|
}
|
|
@@ -374,11 +368,9 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
374
368
|
this._armScanRestart()
|
|
375
369
|
}
|
|
376
370
|
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
// battery — scan SCAN_DUTY_ON, then go dark SCAN_DUTY_OFF, repeat. The next
|
|
381
|
-
// delay is derived from the current phase, so one timer drives the whole cycle.
|
|
371
|
+
// Lonely: cycle the scan every SCAN_RESTART_LONELY so a re-advertised peer is
|
|
372
|
+
// re-reported. Linked: duty-cycle SCAN_DUTY_ON on / SCAN_DUTY_OFF dark to save
|
|
373
|
+
// battery. One timer drives the whole cycle; the next delay derives from phase.
|
|
382
374
|
_armScanRestart() {
|
|
383
375
|
if (this._scanTimer) clearTimeout(this._scanTimer)
|
|
384
376
|
const delay =
|
|
@@ -387,19 +379,19 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
387
379
|
this._scanTimer = null
|
|
388
380
|
if (this.closing || this.closed || this._suspended) return
|
|
389
381
|
// never toggle the scan mid-dial (would kill the connect) — defer a phase
|
|
390
|
-
if (this.
|
|
382
|
+
if (this._isDialing()) {
|
|
391
383
|
this._armScanRestart()
|
|
392
384
|
return
|
|
393
385
|
}
|
|
394
386
|
if (this.linkCount > 0) {
|
|
395
387
|
if (this._scanning) {
|
|
396
|
-
this._stopScan()
|
|
388
|
+
this._stopScan()
|
|
397
389
|
this._armScanRestart()
|
|
398
390
|
} else {
|
|
399
|
-
this._startScan()
|
|
391
|
+
this._startScan()
|
|
400
392
|
}
|
|
401
393
|
} else {
|
|
402
|
-
this._stopScan()
|
|
394
|
+
this._stopScan()
|
|
403
395
|
this._startScan()
|
|
404
396
|
}
|
|
405
397
|
}, delay)
|
|
@@ -424,23 +416,21 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
424
416
|
}
|
|
425
417
|
|
|
426
418
|
_onDiscover(peripheral) {
|
|
427
|
-
if (this.closing || this.closed) return
|
|
428
|
-
|
|
429
|
-
if (
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
if (this.linkCount >= this.
|
|
436
|
-
// global rate limit: one radio can't usefully dial faster than this, and
|
|
437
|
-
// back-to-back connects thrash CoreBluetooth (bitchat connect hygiene)
|
|
419
|
+
if (this.closing || this.closed || this._suspended) return
|
|
420
|
+
const d = this._devices.get(peripheral.id)
|
|
421
|
+
if (d) {
|
|
422
|
+
if (d.linked) return
|
|
423
|
+
if (d.peerKey && this.peers.has(d.peerKey)) return // linked via another channel
|
|
424
|
+
if (d.coolUntil > Date.now()) return // failed recently — back off
|
|
425
|
+
if (d.timer) return // already connecting to this one
|
|
426
|
+
}
|
|
427
|
+
if (this.linkCount >= this.maxOutbound) return // gossip covers the rest
|
|
438
428
|
if (Date.now() - this._lastDial < DIAL_MIN_INTERVAL) return
|
|
439
|
-
//
|
|
440
|
-
//
|
|
429
|
+
// dial every discovery and open a session; a redundant link is dropped by
|
|
430
|
+
// _track's dedup
|
|
441
431
|
this._lastDial = Date.now()
|
|
442
432
|
const timer = setTimeout(() => this._abortDial(peripheral, 'timeout'), CONNECT_TIMEOUT)
|
|
443
|
-
this.
|
|
433
|
+
this._device(peripheral.id).timer = timer
|
|
444
434
|
try {
|
|
445
435
|
this._stopScan()
|
|
446
436
|
this.central.connect(peripheral)
|
|
@@ -450,7 +440,7 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
450
440
|
}
|
|
451
441
|
|
|
452
442
|
_onConnect(peripheral) {
|
|
453
|
-
this.
|
|
443
|
+
this._device(peripheral.id).peripheral = peripheral
|
|
454
444
|
peripheral.on('error', () => this._abortDial(peripheral, 'peripheral-error'))
|
|
455
445
|
peripheral.once('servicesDiscover', (services) => {
|
|
456
446
|
const svc = findByUUID(services, this.serviceUUID)
|
|
@@ -469,10 +459,9 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
469
459
|
}
|
|
470
460
|
this._startCentralSession(peripheral, char)
|
|
471
461
|
})
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
// Redundant links are tolerated; _track keeps the first and drops the dup.
|
|
462
|
+
// both sides open a session: on iOS the peer id isn't known until after
|
|
463
|
+
// connect, so a tie-break yields only post-handshake and deadlocks if the
|
|
464
|
+
// other side never dials back. _track keeps the first, drops the dup.
|
|
476
465
|
peripheral.discoverServices([this.serviceUUID])
|
|
477
466
|
}
|
|
478
467
|
|
|
@@ -488,7 +477,8 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
488
477
|
send: (payload) => this._centralSend(peripheral, char, frame(TYPE_DATA, sid, payload)),
|
|
489
478
|
onclose: () => {
|
|
490
479
|
this._centralSend(peripheral, char, frame(TYPE_CLOSE, sid)).catch(safetyCatch)
|
|
491
|
-
this.
|
|
480
|
+
const d = this._devices.get(peripheral.id)
|
|
481
|
+
if (d) d.peripheral = null
|
|
492
482
|
try {
|
|
493
483
|
this.central.disconnect(peripheral)
|
|
494
484
|
} catch (err) {
|
|
@@ -499,7 +489,6 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
499
489
|
peripheral._stream = stream
|
|
500
490
|
// open frame first: it registers the session on the server before any data
|
|
501
491
|
this._centralSend(peripheral, char, frame(TYPE_OPEN, sid)).catch(safetyCatch)
|
|
502
|
-
// greet the peer with our app-user name so it can label this link
|
|
503
492
|
this._centralSend(peripheral, char, frame(TYPE_HELLO, sid, this._helloPayload())).catch(
|
|
504
493
|
safetyCatch
|
|
505
494
|
)
|
|
@@ -511,14 +500,12 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
511
500
|
const sess = peripheral._session
|
|
512
501
|
if (!sess || !peripheral._stream) return
|
|
513
502
|
const f = parseFrame(data)
|
|
514
|
-
if (!f)
|
|
515
|
-
|
|
516
|
-
}
|
|
517
|
-
if (f.sidHex !== sess.sidHex) return // not our session (broadcast to others)
|
|
503
|
+
if (!f) return
|
|
504
|
+
if (f.sidHex !== sess.sidHex) return // not our session
|
|
518
505
|
if (f.type === TYPE_DATA) peripheral._stream.receive(b4a.from(f.payload))
|
|
519
506
|
else if (f.type === TYPE_HELLO) {
|
|
520
507
|
const name = this._parseHello(f.payload)
|
|
521
|
-
if (name === null) return
|
|
508
|
+
if (name === null) return
|
|
522
509
|
peripheral._peerName = name
|
|
523
510
|
if (peripheral._conn) peripheral._conn._peerName = name
|
|
524
511
|
this.emit('update')
|
|
@@ -564,17 +551,15 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
564
551
|
|
|
565
552
|
_abortDial(peripheral, _reason) {
|
|
566
553
|
const id = peripheral?.id
|
|
567
|
-
// eager when unlinked, patient once linked; per-peer exponential backoff so a
|
|
568
|
-
// peer that keeps failing is retried ever less often (bitchat connect hygiene)
|
|
569
554
|
if (id != null) {
|
|
570
|
-
const
|
|
571
|
-
|
|
555
|
+
const d = this._device(id)
|
|
556
|
+
d.failures += 1
|
|
572
557
|
const base = this.linkCount === 0 ? DIAL_COOLDOWN_BASE_LONELY : DIAL_COOLDOWN_BASE
|
|
573
|
-
|
|
574
|
-
|
|
558
|
+
d.coolUntil =
|
|
559
|
+
Date.now() + Math.min(DIAL_COOLDOWN_MAX, base * 2 ** Math.min(4, d.failures - 1))
|
|
560
|
+
d.peripheral = null
|
|
575
561
|
}
|
|
576
562
|
this._clearDial(id)
|
|
577
|
-
this._connectedPeripherals.delete(peripheral)
|
|
578
563
|
try {
|
|
579
564
|
this.central.disconnect(peripheral)
|
|
580
565
|
} catch (err) {
|
|
@@ -585,9 +570,16 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
585
570
|
|
|
586
571
|
_clearDial(id) {
|
|
587
572
|
if (id == null) return
|
|
588
|
-
const
|
|
589
|
-
if (
|
|
590
|
-
|
|
573
|
+
const d = this._devices.get(id)
|
|
574
|
+
if (!d) return
|
|
575
|
+
if (d.timer) clearTimeout(d.timer)
|
|
576
|
+
d.timer = null
|
|
577
|
+
this._prune(id)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
_isDialing() {
|
|
581
|
+
for (const d of this._devices.values()) if (d.timer) return true
|
|
582
|
+
return false
|
|
591
583
|
}
|
|
592
584
|
|
|
593
585
|
// iOS & Android report errored connects as 'error' with a code, not
|
|
@@ -596,51 +588,54 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
596
588
|
safetyCatch(err)
|
|
597
589
|
const code = err && err.code
|
|
598
590
|
if (code === 'CONNECTION_FAILED' || code === 'DISCONNECT') {
|
|
599
|
-
for (const id of
|
|
591
|
+
for (const [id, d] of this._devices) if (d.timer) this._clearDial(id)
|
|
600
592
|
}
|
|
601
593
|
}
|
|
602
594
|
|
|
603
|
-
_onChannel(
|
|
595
|
+
_onChannel(stream, isInitiator, peripheralId) {
|
|
604
596
|
if (this.closing || this.closed || this._suspended) {
|
|
605
|
-
|
|
597
|
+
stream.destroy()
|
|
606
598
|
return
|
|
607
599
|
}
|
|
608
|
-
const conn = this.network.inject(
|
|
609
|
-
//
|
|
610
|
-
//
|
|
600
|
+
const conn = this.network.inject(stream, { isInitiator })
|
|
601
|
+
// iOS never signals a disconnect for a vanished peer: keepalive pings and the
|
|
602
|
+
// timeout are the liveness detector (keepalive refreshes the timeout).
|
|
611
603
|
conn.setKeepAlive(5000)
|
|
612
604
|
conn.setTimeout(15000)
|
|
613
|
-
|
|
605
|
+
stream.on('error', safetyCatch)
|
|
614
606
|
// marked at channel-open (not handshake-open): rediscovery must not dial a
|
|
615
607
|
// peripheral whose channel is still handshaking
|
|
616
608
|
if (peripheralId != null) {
|
|
617
|
-
this.
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
609
|
+
const d = this._device(peripheralId)
|
|
610
|
+
d.linked = true
|
|
611
|
+
d.coolUntil = 0
|
|
612
|
+
d.failures = 0 // reached a live session — reset backoff
|
|
613
|
+
conn.once('close', () => {
|
|
614
|
+
const rec = this._devices.get(peripheralId)
|
|
615
|
+
if (rec) {
|
|
616
|
+
rec.linked = false
|
|
617
|
+
this._prune(peripheralId)
|
|
618
|
+
}
|
|
619
|
+
})
|
|
621
620
|
}
|
|
622
621
|
conn.on('open', () => this._track(conn, peripheralId, isInitiator))
|
|
623
|
-
conn.on('close', () => this._untrack(conn
|
|
622
|
+
conn.on('close', () => this._untrack(conn))
|
|
624
623
|
this._startScan()
|
|
625
624
|
return conn
|
|
626
625
|
}
|
|
627
626
|
|
|
628
627
|
_track(conn, peripheralId, isInitiator) {
|
|
629
628
|
if (peripheralId != null && conn.remotePublicKey) {
|
|
630
|
-
this.
|
|
629
|
+
this._device(peripheralId).peerKey = b4a.toString(conn.remotePublicKey, 'hex')
|
|
631
630
|
}
|
|
632
631
|
if (this.closing || this.closed) return
|
|
633
632
|
const key = b4a.toString(conn.remotePublicKey, 'hex')
|
|
634
633
|
const existing = this.peers.get(key)
|
|
635
634
|
if (existing && existing !== conn) {
|
|
636
|
-
// Both sides dial
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
// link. Deterministic winner: keep the channel whose initiator has the
|
|
641
|
-
// smaller static key. Both peers compute it identically (initiator end:
|
|
642
|
-
// our key smaller; responder end: remote key smaller), so the loser
|
|
643
|
-
// channel is dropped on both ends and never cascades onto the survivor.
|
|
635
|
+
// Both sides dial, so a pair links twice. Dropping a channel closes it for
|
|
636
|
+
// BOTH devices, so both must retire the SAME channel. Deterministic winner:
|
|
637
|
+
// keep the channel whose initiator has the smaller static key — computed
|
|
638
|
+
// identically on both ends, so the loser drops on both and never cascades.
|
|
644
639
|
const initiatorIsUsSmaller = b4a.compare(conn.publicKey, conn.remotePublicKey) < 0
|
|
645
640
|
const preferred = isInitiator === initiatorIsUsSmaller
|
|
646
641
|
if (!preferred) {
|
|
@@ -665,21 +660,17 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
665
660
|
}
|
|
666
661
|
}
|
|
667
662
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
* a normal stream close uses. Waits up to DRAIN_MS for the frames to flush,
|
|
672
|
-
* then resolves regardless: suspend must never hang on a wedged radio.
|
|
673
|
-
*
|
|
674
|
-
* @returns {Promise<void>}
|
|
675
|
-
*/
|
|
663
|
+
// Best-effort TYPE_CLOSE to every live session — server sessions over notify,
|
|
664
|
+
// central sessions over write — then wait up to DRAIN_MS for the frames to
|
|
665
|
+
// flush. Resolves regardless: suspend must never hang on a wedged radio.
|
|
676
666
|
async _sayGoodbye() {
|
|
677
667
|
const sent = []
|
|
678
668
|
for (const { sid } of this._sessions.values()) {
|
|
679
669
|
sent.push(this._enqueueNotify(frame(TYPE_CLOSE, sid)).catch(safetyCatch))
|
|
680
670
|
}
|
|
681
|
-
for (const
|
|
682
|
-
const
|
|
671
|
+
for (const d of this._devices.values()) {
|
|
672
|
+
const peripheral = d.peripheral
|
|
673
|
+
const sess = peripheral && peripheral._session
|
|
683
674
|
if (!sess || !peripheral._char) continue
|
|
684
675
|
const f = frame(TYPE_CLOSE, sess.sid)
|
|
685
676
|
sent.push(this._centralSend(peripheral, peripheral._char, f).catch(safetyCatch))
|
|
@@ -691,29 +682,27 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
691
682
|
/**
|
|
692
683
|
* Pause radio activity but KEEP the Server/Central instances and the
|
|
693
684
|
* registered GATT service alive — the toggle-friendly counterpart to _close.
|
|
694
|
-
*
|
|
695
|
-
*
|
|
696
|
-
* keeps a duplicate GATT service registered; remote centrals then subscribe to
|
|
697
|
-
* the dead service and hear silence. Reuse one instance instead. Idempotent.
|
|
685
|
+
* CoreBluetooth managers can't be destroy()ed (native double-free), so one
|
|
686
|
+
* transport is reused across toggles rather than recreated. Idempotent.
|
|
698
687
|
*/
|
|
699
688
|
async suspend() {
|
|
700
689
|
this._suspended = true
|
|
701
690
|
if (this._scanTimer) clearTimeout(this._scanTimer)
|
|
702
691
|
this._scanTimer = null
|
|
703
|
-
for (const
|
|
704
|
-
|
|
705
|
-
//
|
|
706
|
-
//
|
|
707
|
-
// links (an ACL disconnect is an instant OS-level signal on both roles).
|
|
692
|
+
for (const d of this._devices.values()) if (d.timer) clearTimeout(d.timer)
|
|
693
|
+
// say goodbye BEFORE teardown so the remote reacts in <1s instead of waiting
|
|
694
|
+
// out the 15s keepalive: close frames, a short drain, then an ACL disconnect
|
|
695
|
+
// (an instant OS-level signal on both roles).
|
|
708
696
|
await this._sayGoodbye()
|
|
709
|
-
for (const
|
|
697
|
+
for (const d of this._devices.values()) {
|
|
698
|
+
if (!d.peripheral) continue
|
|
710
699
|
try {
|
|
711
|
-
this.central.disconnect(peripheral)
|
|
700
|
+
this.central.disconnect(d.peripheral)
|
|
712
701
|
} catch (err) {
|
|
713
702
|
safetyCatch(err)
|
|
714
703
|
}
|
|
715
704
|
}
|
|
716
|
-
this.
|
|
705
|
+
this._devices.clear()
|
|
717
706
|
try {
|
|
718
707
|
this._stopScan()
|
|
719
708
|
} catch (err) {
|
|
@@ -743,9 +732,6 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
743
732
|
this._sessions.clear()
|
|
744
733
|
for (const item of this._notifyQueue) item.reject(new Error('suspended'))
|
|
745
734
|
this._notifyQueue = []
|
|
746
|
-
this._linked.clear()
|
|
747
|
-
this._coolUntil.clear()
|
|
748
|
-
this._failures.clear()
|
|
749
735
|
this.state = 'off'
|
|
750
736
|
this.emit('update')
|
|
751
737
|
}
|
|
@@ -769,10 +755,10 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
769
755
|
async _close() {
|
|
770
756
|
if (this._scanTimer) clearTimeout(this._scanTimer)
|
|
771
757
|
this._scanTimer = null
|
|
772
|
-
//
|
|
773
|
-
//
|
|
774
|
-
for (const
|
|
775
|
-
this.
|
|
758
|
+
// never call central/server.destroy() — it double-frees in native teardown;
|
|
759
|
+
// stop advertising/scanning and let the runtime reclaim.
|
|
760
|
+
for (const d of this._devices.values()) if (d.timer) clearTimeout(d.timer)
|
|
761
|
+
this._devices.clear()
|
|
776
762
|
try {
|
|
777
763
|
this.central?.stopScan()
|
|
778
764
|
} catch (err) {
|
|
@@ -784,7 +770,6 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
784
770
|
safetyCatch(err)
|
|
785
771
|
}
|
|
786
772
|
if (!this.keepLinks) {
|
|
787
|
-
// channel mesh: toggling nearby off means stop syncing nearby
|
|
788
773
|
for (const conn of this.peers.values()) {
|
|
789
774
|
try {
|
|
790
775
|
conn.destroy()
|