@cero-base/core 1.3.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 +31 -53
- package/src/network/{bluetooth.js → transports/ble.js} +137 -162
- package/src/network/transports/dht.js +110 -0
- package/src/network/{gatt-stream.js → transports/gatt.js} +4 -6
- package/types/network/index.d.ts +7 -3
- package/types/network/{bluetooth.d.ts → transports/ble.d.ts} +18 -47
- package/types/network/transports/dht.d.ts +75 -0
- package/types/network/{gatt-stream.d.ts → transports/gatt.d.ts} +2 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cero-base/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "cero p2p primitives — identity, storage, network, database, blobs, rpc, pairing.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -41,9 +41,13 @@
|
|
|
41
41
|
"types": "./types/network/index.d.ts",
|
|
42
42
|
"default": "./src/network/index.js"
|
|
43
43
|
},
|
|
44
|
-
"./network/
|
|
45
|
-
"types": "./types/network/
|
|
46
|
-
"default": "./src/network/
|
|
44
|
+
"./network/transports/ble": {
|
|
45
|
+
"types": "./types/network/transports/ble.d.ts",
|
|
46
|
+
"default": "./src/network/transports/ble.js"
|
|
47
|
+
},
|
|
48
|
+
"./network/transports/dht": {
|
|
49
|
+
"types": "./types/network/transports/dht.d.ts",
|
|
50
|
+
"default": "./src/network/transports/dht.js"
|
|
47
51
|
},
|
|
48
52
|
"./database": {
|
|
49
53
|
"types": "./types/database/index.d.ts",
|
package/src/network/index.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import Hyperswarm from 'hyperswarm'
|
|
2
1
|
import NoiseSecretStream from '@hyperswarm/secret-stream'
|
|
3
2
|
import BlindPairing from 'blind-pairing'
|
|
4
3
|
import ProtomuxWakeup from 'protomux-wakeup'
|
|
5
4
|
import ReadyResource from 'ready-resource'
|
|
6
5
|
import safetyCatch from 'safety-catch'
|
|
7
6
|
import b4a from 'b4a'
|
|
8
|
-
import { hash } from 'hypercore-crypto'
|
|
9
7
|
|
|
10
8
|
import { ACTIVE, PASSIVE } from '../lib/constants.js'
|
|
11
9
|
import { CeroError } from '../lib/errors.js'
|
|
12
10
|
import { Discovery } from './discovery.js'
|
|
11
|
+
import { DHTTransport, channelTopic } from './transports/dht.js'
|
|
12
|
+
|
|
13
|
+
export { channelTopic }
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* @typedef {object} NetworkOpts
|
|
@@ -36,7 +37,7 @@ export class Network extends ReadyResource {
|
|
|
36
37
|
this.relayThrough = relayThrough || null
|
|
37
38
|
this.channel = channel || null
|
|
38
39
|
|
|
39
|
-
this.
|
|
40
|
+
this._dht = null
|
|
40
41
|
this.wakeup = new ProtomuxWakeup()
|
|
41
42
|
|
|
42
43
|
this._replicateables = new Set()
|
|
@@ -45,6 +46,11 @@ export class Network extends ReadyResource {
|
|
|
45
46
|
this._blind = null
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
/** @returns {any} The underlying hyperswarm, or null before ready / after close. */
|
|
50
|
+
get swarm() {
|
|
51
|
+
return this._dht ? this._dht.swarm : null
|
|
52
|
+
}
|
|
53
|
+
|
|
48
54
|
/**
|
|
49
55
|
* Feed an externally-established connection — a Bluetooth L2CAP channel, a
|
|
50
56
|
* serial link, an in-process pair, any duplex — into the network. A raw
|
|
@@ -143,24 +149,18 @@ export class Network extends ReadyResource {
|
|
|
143
149
|
}
|
|
144
150
|
|
|
145
151
|
async _open() {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
this.swarm = new Hyperswarm(opts)
|
|
154
|
-
|
|
155
|
-
if (this.channel) {
|
|
156
|
-
const channel = this.channel
|
|
157
|
-
const join = this.swarm.join.bind(this.swarm)
|
|
158
|
-
const leave = this.swarm.leave.bind(this.swarm)
|
|
159
|
-
this.swarm.join = (topic, opts) => join(channelTopic(topic, channel), opts)
|
|
160
|
-
this.swarm.leave = (topic) => leave(channelTopic(topic, channel))
|
|
161
|
-
}
|
|
152
|
+
this._dht = new DHTTransport({
|
|
153
|
+
identity: this.identity,
|
|
154
|
+
bootstrap: this.bootstrap,
|
|
155
|
+
firewall: this.firewall,
|
|
156
|
+
relayThrough: this.relayThrough,
|
|
157
|
+
channel: this.channel
|
|
158
|
+
})
|
|
162
159
|
|
|
163
|
-
|
|
160
|
+
// DHTTransport owns the swarm's lifecycle; Network subscribes to its events
|
|
161
|
+
// because the handlers touch Network state (wakeup, replicateables, emit).
|
|
162
|
+
const swarm = this._dht.swarm
|
|
163
|
+
swarm.on('connection', (stream, info) => {
|
|
164
164
|
if (this.closing || this.closed) {
|
|
165
165
|
stream.destroy()
|
|
166
166
|
return
|
|
@@ -169,8 +169,8 @@ export class Network extends ReadyResource {
|
|
|
169
169
|
for (const r of this._replicateables) replicateInto(r, stream)
|
|
170
170
|
this.emit('connection', stream, info)
|
|
171
171
|
})
|
|
172
|
-
|
|
173
|
-
|
|
172
|
+
swarm.on('peer-add', (peer) => this.emit('peer-add', peer))
|
|
173
|
+
swarm.on('peer-remove', (peer) => this.emit('peer-remove', peer))
|
|
174
174
|
}
|
|
175
175
|
|
|
176
176
|
/**
|
|
@@ -179,9 +179,8 @@ export class Network extends ReadyResource {
|
|
|
179
179
|
* @param {{ timeout?: number }} [opts]
|
|
180
180
|
* @returns {Promise<void>}
|
|
181
181
|
*/
|
|
182
|
-
async flush(
|
|
183
|
-
|
|
184
|
-
await Promise.race([this.swarm.flush(), new Promise((r) => setTimeout(r, timeout))])
|
|
182
|
+
async flush(opts) {
|
|
183
|
+
await this._dht?.flush(opts)
|
|
185
184
|
}
|
|
186
185
|
|
|
187
186
|
/**
|
|
@@ -190,13 +189,8 @@ export class Network extends ReadyResource {
|
|
|
190
189
|
* @returns {Promise<void>}
|
|
191
190
|
*/
|
|
192
191
|
async suspend() {
|
|
193
|
-
if (
|
|
194
|
-
|
|
195
|
-
try {
|
|
196
|
-
await this.swarm.suspend()
|
|
197
|
-
} catch (err) {
|
|
198
|
-
safetyCatch(err)
|
|
199
|
-
}
|
|
192
|
+
if (this.closing || this.closed) return
|
|
193
|
+
await this._dht?.suspend()
|
|
200
194
|
}
|
|
201
195
|
|
|
202
196
|
/**
|
|
@@ -205,13 +199,8 @@ export class Network extends ReadyResource {
|
|
|
205
199
|
* @returns {Promise<void>}
|
|
206
200
|
*/
|
|
207
201
|
async resume() {
|
|
208
|
-
if (
|
|
209
|
-
|
|
210
|
-
try {
|
|
211
|
-
await this.swarm.resume()
|
|
212
|
-
} catch (err) {
|
|
213
|
-
safetyCatch(err)
|
|
214
|
-
}
|
|
202
|
+
if (this.closing || this.closed) return
|
|
203
|
+
await this._dht?.resume()
|
|
215
204
|
}
|
|
216
205
|
|
|
217
206
|
async _close() {
|
|
@@ -241,18 +230,13 @@ export class Network extends ReadyResource {
|
|
|
241
230
|
this._blind = null
|
|
242
231
|
}
|
|
243
232
|
|
|
244
|
-
if (this.
|
|
233
|
+
if (this._dht) {
|
|
245
234
|
try {
|
|
246
|
-
await this.
|
|
235
|
+
await this._dht.destroy()
|
|
247
236
|
} catch (err) {
|
|
248
237
|
safetyCatch(err)
|
|
249
238
|
}
|
|
250
|
-
|
|
251
|
-
await this.swarm.destroy()
|
|
252
|
-
} catch (err) {
|
|
253
|
-
safetyCatch(err)
|
|
254
|
-
}
|
|
255
|
-
this.swarm = null
|
|
239
|
+
this._dht = null
|
|
256
240
|
}
|
|
257
241
|
|
|
258
242
|
if (this.wakeup) {
|
|
@@ -350,12 +334,6 @@ function replicateInto(core, stream) {
|
|
|
350
334
|
}
|
|
351
335
|
}
|
|
352
336
|
|
|
353
|
-
// A channel re-namespaces every swarm topic so only same-channel peers meet.
|
|
354
|
-
// No channel → identity (unchanged, back-compat).
|
|
355
|
-
export function channelTopic(topic, channel) {
|
|
356
|
-
return channel ? hash([topic, b4a.from(channel)]) : topic
|
|
357
|
-
}
|
|
358
|
-
|
|
359
337
|
function isTopic(x) {
|
|
360
338
|
return b4a.isBuffer(x) && x.length === 32
|
|
361
339
|
}
|
|
@@ -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
|
|
@@ -21,21 +20,20 @@ const HEADER = 1 + SID_LEN
|
|
|
21
20
|
|
|
22
21
|
const DEFAULT_CAP = 4
|
|
23
22
|
const CONNECT_TIMEOUT = 15000
|
|
24
|
-
// per-peer dial backoff
|
|
25
|
-
//
|
|
23
|
+
// per-peer dial backoff: eager while unlinked, patient once linked; the cooldown
|
|
24
|
+
// grows exponentially per consecutive failure.
|
|
26
25
|
const DIAL_COOLDOWN_BASE = 8000
|
|
27
26
|
const DIAL_COOLDOWN_BASE_LONELY = 2000
|
|
28
27
|
const DIAL_COOLDOWN_MAX = 30000
|
|
29
|
-
//
|
|
28
|
+
// one radio can't usefully dial faster than this
|
|
30
29
|
const DIAL_MIN_INTERVAL = 500
|
|
31
|
-
//
|
|
32
|
-
//
|
|
30
|
+
// iOS reports a peripheral once per scan session; restart to re-report a
|
|
31
|
+
// re-advertised peer.
|
|
33
32
|
const SCAN_RESTART_LONELY = 5000
|
|
34
|
-
//
|
|
35
|
-
// (bitchat model) — scan for SCAN_DUTY_ON, then stay dark for SCAN_DUTY_OFF.
|
|
33
|
+
// linked: continuous scanning is the dominant battery cost, so duty-cycle it.
|
|
36
34
|
const SCAN_DUTY_ON = 5000
|
|
37
35
|
const SCAN_DUTY_OFF = 25000
|
|
38
|
-
// suspend() drain window:
|
|
36
|
+
// suspend() drain window: let goodbye frames flush before hanging up.
|
|
39
37
|
const DRAIN_MS = 300
|
|
40
38
|
|
|
41
39
|
const EMPTY = b4a.alloc(0)
|
|
@@ -57,7 +55,7 @@ function frame(type, sid, payload = EMPTY) {
|
|
|
57
55
|
}
|
|
58
56
|
|
|
59
57
|
/**
|
|
60
|
-
* Parse a wire frame. Short/empty buffers → null (
|
|
58
|
+
* Parse a wire frame. Short/empty buffers → null (caller drops).
|
|
61
59
|
*
|
|
62
60
|
* @param {Uint8Array} buf
|
|
63
61
|
* @returns {{ type: number, sid: Uint8Array, sidHex: string, payload: Uint8Array } | null}
|
|
@@ -112,22 +110,19 @@ const findByUUID = (items, uuid) => (items || []).find((i) => uuidEq(i.uuid, uui
|
|
|
112
110
|
* byte-stream to each discovered peer, and feeds it into `network.inject`. From
|
|
113
111
|
* there replication and pairing are transport-agnostic (see Network.inject).
|
|
114
112
|
*
|
|
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.
|
|
113
|
+
* The server adds one data characteristic (write + notify) and advertises. The
|
|
114
|
+
* central connects, discovers the characteristic, subscribes, then framed bytes
|
|
115
|
+
* flow both ways — central→server as GATT writes, server→central as
|
|
116
|
+
* notifications — each tagged with an 8-byte session id. `backend` is
|
|
117
|
+
* bare-bluetooth in production and a mock in tests.
|
|
123
118
|
*
|
|
124
119
|
* @extends ReadyResource
|
|
125
120
|
*/
|
|
126
|
-
export class
|
|
121
|
+
export class BLETransport extends ReadyResource {
|
|
127
122
|
/**
|
|
128
123
|
* @param {object} opts
|
|
129
124
|
* @param {any} opts.backend bare-bluetooth-shaped module (Central, Server, Service, Characteristic).
|
|
130
|
-
* @param {import('
|
|
125
|
+
* @param {import('../index.js').Network} opts.network
|
|
131
126
|
* @param {Uint8Array} opts.uuid The 32-byte topic the service UUID derives from.
|
|
132
127
|
* @param {Uint8Array} opts.nodeId Stable local id (identity/device key) for the initiate tie-break.
|
|
133
128
|
* @param {string} [opts.tag] UUID namespace (channel mesh vs invite mesh).
|
|
@@ -169,19 +164,8 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
169
164
|
this._scanning = false
|
|
170
165
|
this._advertising = false
|
|
171
166
|
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()
|
|
167
|
+
/** peripheral id → per-peer dial state { timer, linked, coolUntil, failures, peerKey, peripheral } */
|
|
168
|
+
this._devices = new Map()
|
|
185
169
|
/** last central.connect timestamp — global inter-dial rate limit */
|
|
186
170
|
this._lastDial = 0
|
|
187
171
|
this._scanTimer = null
|
|
@@ -206,6 +190,22 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
206
190
|
return this.peers.size
|
|
207
191
|
}
|
|
208
192
|
|
|
193
|
+
_device(id) {
|
|
194
|
+
let d = this._devices.get(id)
|
|
195
|
+
if (!d) {
|
|
196
|
+
d = { timer: null, linked: false, coolUntil: 0, failures: 0, peerKey: null, peripheral: null }
|
|
197
|
+
this._devices.set(id, d)
|
|
198
|
+
}
|
|
199
|
+
return d
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
_prune(id) {
|
|
203
|
+
const d = this._devices.get(id)
|
|
204
|
+
if (d && !d.timer && !d.linked && !d.coolUntil && !d.failures && !d.peerKey && !d.peripheral) {
|
|
205
|
+
this._devices.delete(id)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
209
|
async _open() {
|
|
210
210
|
const { Central, Server, Service, Characteristic } = this.backend
|
|
211
211
|
|
|
@@ -219,13 +219,9 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
219
219
|
this._maybeAdvertise()
|
|
220
220
|
})
|
|
221
221
|
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
222
|
this.server.on('readyToUpdate', () => this._drainNotify())
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
// central; teardown is left to the per-link keepalive/timeout liveness in
|
|
228
|
-
// _onChannel (15s).
|
|
223
|
+
// writeRequests carry no central identifier, so an unsubscribe can't be
|
|
224
|
+
// mapped to a session; teardown is left to _onChannel's keepalive/timeout.
|
|
229
225
|
this.server.on('unsubscribe', () => {})
|
|
230
226
|
this.server.on('error', safetyCatch)
|
|
231
227
|
|
|
@@ -245,7 +241,7 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
245
241
|
}
|
|
246
242
|
|
|
247
243
|
_startServer(Service, Characteristic) {
|
|
248
|
-
if (this.server.state !== 'poweredOn') return
|
|
244
|
+
if (this.server.state !== 'poweredOn') return
|
|
249
245
|
if (!this._serviceAdded) {
|
|
250
246
|
this._dataChar = new Characteristic(DATA_UUID, { write: true, notify: true })
|
|
251
247
|
this.server.addService(new Service(this.serviceUUID, [this._dataChar]))
|
|
@@ -263,20 +259,18 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
263
259
|
_onWriteRequests(requests) {
|
|
264
260
|
const ok = this.server.constructor.ATT_SUCCESS ?? 0
|
|
265
261
|
for (const req of requests) {
|
|
266
|
-
// respond within ms or the central times out — before any parsing
|
|
262
|
+
// must respond within ms or the central times out — before any parsing
|
|
267
263
|
if (req.responseNeeded !== false) this.server.respondToRequest(req, ok)
|
|
268
|
-
if (this._suspended) continue
|
|
264
|
+
if (this._suspended) continue
|
|
269
265
|
this._onServerFrame(req.data)
|
|
270
266
|
}
|
|
271
267
|
}
|
|
272
268
|
|
|
273
269
|
_onServerFrame(data) {
|
|
274
270
|
const f = parseFrame(data)
|
|
275
|
-
if (!f)
|
|
276
|
-
return
|
|
277
|
-
}
|
|
271
|
+
if (!f) return
|
|
278
272
|
if (f.type === TYPE_OPEN) {
|
|
279
|
-
if (this._sessions.has(f.sidHex)) return
|
|
273
|
+
if (this._sessions.has(f.sidHex)) return
|
|
280
274
|
const sid = b4a.from(f.sid) // copy: f.sid views the transient request buffer
|
|
281
275
|
const stream = new GattStream({
|
|
282
276
|
send: (payload) => this._enqueueNotify(frame(TYPE_DATA, sid, payload)),
|
|
@@ -285,7 +279,6 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
285
279
|
const session = { stream, sid, conn: null, name: null }
|
|
286
280
|
this._sessions.set(f.sidHex, session)
|
|
287
281
|
session.conn = this._onChannel(stream, false, null)
|
|
288
|
-
// greet the peer with our app-user name so it can label this link
|
|
289
282
|
this._enqueueNotify(frame(TYPE_HELLO, sid, this._helloPayload())).catch(safetyCatch)
|
|
290
283
|
} else if (f.type === TYPE_DATA) {
|
|
291
284
|
const s = this._sessions.get(f.sidHex)
|
|
@@ -299,29 +292,21 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
299
292
|
this._sessions.delete(f.sidHex)
|
|
300
293
|
s.stream.remoteEnd()
|
|
301
294
|
}
|
|
302
|
-
} else {
|
|
303
295
|
}
|
|
304
296
|
}
|
|
305
297
|
|
|
306
298
|
_closeServerSession(sidHex, sid) {
|
|
307
|
-
if (!this._sessions.has(sidHex)) return
|
|
299
|
+
if (!this._sessions.has(sidHex)) return
|
|
308
300
|
this._sessions.delete(sidHex)
|
|
309
|
-
this._enqueueNotify(frame(TYPE_CLOSE, sid)).catch(safetyCatch)
|
|
301
|
+
this._enqueueNotify(frame(TYPE_CLOSE, sid)).catch(safetyCatch)
|
|
310
302
|
}
|
|
311
303
|
|
|
312
304
|
// ─── peer display name (hello frame) ──────────────────────────────────────
|
|
313
305
|
|
|
314
|
-
/** Our hello payload: the local app-user name the peer labels this link with. */
|
|
315
306
|
_helloPayload() {
|
|
316
307
|
return b4a.from(JSON.stringify({ n: this.name || '' }))
|
|
317
308
|
}
|
|
318
309
|
|
|
319
|
-
/**
|
|
320
|
-
* Parse a hello payload. Malformed → null (the caller ignores it).
|
|
321
|
-
*
|
|
322
|
-
* @param {Uint8Array} payload
|
|
323
|
-
* @returns {string | null}
|
|
324
|
-
*/
|
|
325
310
|
_parseHello(payload) {
|
|
326
311
|
try {
|
|
327
312
|
const { n } = JSON.parse(b4a.toString(payload))
|
|
@@ -331,18 +316,17 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
331
316
|
}
|
|
332
317
|
}
|
|
333
318
|
|
|
334
|
-
/** Stash a peer's name onto a server session + its conn, then refresh mirrors. */
|
|
335
319
|
_applyPeerName(session, payload) {
|
|
336
320
|
const name = this._parseHello(payload)
|
|
337
|
-
if (name === null) return
|
|
321
|
+
if (name === null) return
|
|
338
322
|
session.name = name
|
|
339
323
|
if (session.conn) session.conn._peerName = name
|
|
340
324
|
this.emit('update')
|
|
341
325
|
}
|
|
342
326
|
|
|
343
327
|
// 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.
|
|
328
|
+
// returns false when the peripheral's queue is full — hold the head frame and
|
|
329
|
+
// retry on the next 'readyToUpdate', preserving order.
|
|
346
330
|
_enqueueNotify(f) {
|
|
347
331
|
return new Promise((resolve, reject) => {
|
|
348
332
|
this._notifyQueue.push({ frame: f, resolve, reject })
|
|
@@ -361,7 +345,7 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
361
345
|
item.reject(err)
|
|
362
346
|
continue
|
|
363
347
|
}
|
|
364
|
-
if (!ok) return
|
|
348
|
+
if (!ok) return
|
|
365
349
|
this._notifyQueue.shift()
|
|
366
350
|
item.resolve()
|
|
367
351
|
}
|
|
@@ -374,11 +358,9 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
374
358
|
this._armScanRestart()
|
|
375
359
|
}
|
|
376
360
|
|
|
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.
|
|
361
|
+
// Lonely: cycle the scan every SCAN_RESTART_LONELY so a re-advertised peer is
|
|
362
|
+
// re-reported. Linked: duty-cycle SCAN_DUTY_ON on / SCAN_DUTY_OFF dark to save
|
|
363
|
+
// battery. One timer drives the whole cycle; the next delay derives from phase.
|
|
382
364
|
_armScanRestart() {
|
|
383
365
|
if (this._scanTimer) clearTimeout(this._scanTimer)
|
|
384
366
|
const delay =
|
|
@@ -387,19 +369,19 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
387
369
|
this._scanTimer = null
|
|
388
370
|
if (this.closing || this.closed || this._suspended) return
|
|
389
371
|
// never toggle the scan mid-dial (would kill the connect) — defer a phase
|
|
390
|
-
if (this.
|
|
372
|
+
if (this._isDialing()) {
|
|
391
373
|
this._armScanRestart()
|
|
392
374
|
return
|
|
393
375
|
}
|
|
394
376
|
if (this.linkCount > 0) {
|
|
395
377
|
if (this._scanning) {
|
|
396
|
-
this._stopScan()
|
|
378
|
+
this._stopScan()
|
|
397
379
|
this._armScanRestart()
|
|
398
380
|
} else {
|
|
399
|
-
this._startScan()
|
|
381
|
+
this._startScan()
|
|
400
382
|
}
|
|
401
383
|
} else {
|
|
402
|
-
this._stopScan()
|
|
384
|
+
this._stopScan()
|
|
403
385
|
this._startScan()
|
|
404
386
|
}
|
|
405
387
|
}, delay)
|
|
@@ -424,23 +406,21 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
424
406
|
}
|
|
425
407
|
|
|
426
408
|
_onDiscover(peripheral) {
|
|
427
|
-
if (this.closing || this.closed) return
|
|
428
|
-
|
|
429
|
-
if (
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
409
|
+
if (this.closing || this.closed || this._suspended) return
|
|
410
|
+
const d = this._devices.get(peripheral.id)
|
|
411
|
+
if (d) {
|
|
412
|
+
if (d.linked) return
|
|
413
|
+
if (d.peerKey && this.peers.has(d.peerKey)) return // linked via another channel
|
|
414
|
+
if (d.coolUntil > Date.now()) return // failed recently — back off
|
|
415
|
+
if (d.timer) return // already connecting to this one
|
|
416
|
+
}
|
|
435
417
|
if (this.linkCount >= this.cap) return // gossip covers the rest
|
|
436
|
-
// global rate limit: one radio can't usefully dial faster than this, and
|
|
437
|
-
// back-to-back connects thrash CoreBluetooth (bitchat connect hygiene)
|
|
438
418
|
if (Date.now() - this._lastDial < DIAL_MIN_INTERVAL) return
|
|
439
|
-
//
|
|
440
|
-
//
|
|
419
|
+
// dial every discovery and open a session; a redundant link is dropped by
|
|
420
|
+
// _track's dedup
|
|
441
421
|
this._lastDial = Date.now()
|
|
442
422
|
const timer = setTimeout(() => this._abortDial(peripheral, 'timeout'), CONNECT_TIMEOUT)
|
|
443
|
-
this.
|
|
423
|
+
this._device(peripheral.id).timer = timer
|
|
444
424
|
try {
|
|
445
425
|
this._stopScan()
|
|
446
426
|
this.central.connect(peripheral)
|
|
@@ -450,7 +430,7 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
450
430
|
}
|
|
451
431
|
|
|
452
432
|
_onConnect(peripheral) {
|
|
453
|
-
this.
|
|
433
|
+
this._device(peripheral.id).peripheral = peripheral
|
|
454
434
|
peripheral.on('error', () => this._abortDial(peripheral, 'peripheral-error'))
|
|
455
435
|
peripheral.once('servicesDiscover', (services) => {
|
|
456
436
|
const svc = findByUUID(services, this.serviceUUID)
|
|
@@ -469,10 +449,9 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
469
449
|
}
|
|
470
450
|
this._startCentralSession(peripheral, char)
|
|
471
451
|
})
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
// Redundant links are tolerated; _track keeps the first and drops the dup.
|
|
452
|
+
// both sides open a session: on iOS the peer id isn't known until after
|
|
453
|
+
// connect, so a tie-break yields only post-handshake and deadlocks if the
|
|
454
|
+
// other side never dials back. _track keeps the first, drops the dup.
|
|
476
455
|
peripheral.discoverServices([this.serviceUUID])
|
|
477
456
|
}
|
|
478
457
|
|
|
@@ -488,7 +467,8 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
488
467
|
send: (payload) => this._centralSend(peripheral, char, frame(TYPE_DATA, sid, payload)),
|
|
489
468
|
onclose: () => {
|
|
490
469
|
this._centralSend(peripheral, char, frame(TYPE_CLOSE, sid)).catch(safetyCatch)
|
|
491
|
-
this.
|
|
470
|
+
const d = this._devices.get(peripheral.id)
|
|
471
|
+
if (d) d.peripheral = null
|
|
492
472
|
try {
|
|
493
473
|
this.central.disconnect(peripheral)
|
|
494
474
|
} catch (err) {
|
|
@@ -499,7 +479,6 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
499
479
|
peripheral._stream = stream
|
|
500
480
|
// open frame first: it registers the session on the server before any data
|
|
501
481
|
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
482
|
this._centralSend(peripheral, char, frame(TYPE_HELLO, sid, this._helloPayload())).catch(
|
|
504
483
|
safetyCatch
|
|
505
484
|
)
|
|
@@ -511,14 +490,12 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
511
490
|
const sess = peripheral._session
|
|
512
491
|
if (!sess || !peripheral._stream) return
|
|
513
492
|
const f = parseFrame(data)
|
|
514
|
-
if (!f)
|
|
515
|
-
|
|
516
|
-
}
|
|
517
|
-
if (f.sidHex !== sess.sidHex) return // not our session (broadcast to others)
|
|
493
|
+
if (!f) return
|
|
494
|
+
if (f.sidHex !== sess.sidHex) return // not our session
|
|
518
495
|
if (f.type === TYPE_DATA) peripheral._stream.receive(b4a.from(f.payload))
|
|
519
496
|
else if (f.type === TYPE_HELLO) {
|
|
520
497
|
const name = this._parseHello(f.payload)
|
|
521
|
-
if (name === null) return
|
|
498
|
+
if (name === null) return
|
|
522
499
|
peripheral._peerName = name
|
|
523
500
|
if (peripheral._conn) peripheral._conn._peerName = name
|
|
524
501
|
this.emit('update')
|
|
@@ -564,17 +541,15 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
564
541
|
|
|
565
542
|
_abortDial(peripheral, _reason) {
|
|
566
543
|
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
544
|
if (id != null) {
|
|
570
|
-
const
|
|
571
|
-
|
|
545
|
+
const d = this._device(id)
|
|
546
|
+
d.failures += 1
|
|
572
547
|
const base = this.linkCount === 0 ? DIAL_COOLDOWN_BASE_LONELY : DIAL_COOLDOWN_BASE
|
|
573
|
-
|
|
574
|
-
|
|
548
|
+
d.coolUntil =
|
|
549
|
+
Date.now() + Math.min(DIAL_COOLDOWN_MAX, base * 2 ** Math.min(4, d.failures - 1))
|
|
550
|
+
d.peripheral = null
|
|
575
551
|
}
|
|
576
552
|
this._clearDial(id)
|
|
577
|
-
this._connectedPeripherals.delete(peripheral)
|
|
578
553
|
try {
|
|
579
554
|
this.central.disconnect(peripheral)
|
|
580
555
|
} catch (err) {
|
|
@@ -585,9 +560,16 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
585
560
|
|
|
586
561
|
_clearDial(id) {
|
|
587
562
|
if (id == null) return
|
|
588
|
-
const
|
|
589
|
-
if (
|
|
590
|
-
|
|
563
|
+
const d = this._devices.get(id)
|
|
564
|
+
if (!d) return
|
|
565
|
+
if (d.timer) clearTimeout(d.timer)
|
|
566
|
+
d.timer = null
|
|
567
|
+
this._prune(id)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
_isDialing() {
|
|
571
|
+
for (const d of this._devices.values()) if (d.timer) return true
|
|
572
|
+
return false
|
|
591
573
|
}
|
|
592
574
|
|
|
593
575
|
// iOS & Android report errored connects as 'error' with a code, not
|
|
@@ -596,51 +578,54 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
596
578
|
safetyCatch(err)
|
|
597
579
|
const code = err && err.code
|
|
598
580
|
if (code === 'CONNECTION_FAILED' || code === 'DISCONNECT') {
|
|
599
|
-
for (const id of
|
|
581
|
+
for (const [id, d] of this._devices) if (d.timer) this._clearDial(id)
|
|
600
582
|
}
|
|
601
583
|
}
|
|
602
584
|
|
|
603
|
-
_onChannel(
|
|
585
|
+
_onChannel(stream, isInitiator, peripheralId) {
|
|
604
586
|
if (this.closing || this.closed || this._suspended) {
|
|
605
|
-
|
|
587
|
+
stream.destroy()
|
|
606
588
|
return
|
|
607
589
|
}
|
|
608
|
-
const conn = this.network.inject(
|
|
609
|
-
//
|
|
610
|
-
//
|
|
590
|
+
const conn = this.network.inject(stream, { isInitiator })
|
|
591
|
+
// iOS never signals a disconnect for a vanished peer: keepalive pings and the
|
|
592
|
+
// timeout are the liveness detector (keepalive refreshes the timeout).
|
|
611
593
|
conn.setKeepAlive(5000)
|
|
612
594
|
conn.setTimeout(15000)
|
|
613
|
-
|
|
595
|
+
stream.on('error', safetyCatch)
|
|
614
596
|
// marked at channel-open (not handshake-open): rediscovery must not dial a
|
|
615
597
|
// peripheral whose channel is still handshaking
|
|
616
598
|
if (peripheralId != null) {
|
|
617
|
-
this.
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
599
|
+
const d = this._device(peripheralId)
|
|
600
|
+
d.linked = true
|
|
601
|
+
d.coolUntil = 0
|
|
602
|
+
d.failures = 0 // reached a live session — reset backoff
|
|
603
|
+
conn.once('close', () => {
|
|
604
|
+
const rec = this._devices.get(peripheralId)
|
|
605
|
+
if (rec) {
|
|
606
|
+
rec.linked = false
|
|
607
|
+
this._prune(peripheralId)
|
|
608
|
+
}
|
|
609
|
+
})
|
|
621
610
|
}
|
|
622
611
|
conn.on('open', () => this._track(conn, peripheralId, isInitiator))
|
|
623
|
-
conn.on('close', () => this._untrack(conn
|
|
612
|
+
conn.on('close', () => this._untrack(conn))
|
|
624
613
|
this._startScan()
|
|
625
614
|
return conn
|
|
626
615
|
}
|
|
627
616
|
|
|
628
617
|
_track(conn, peripheralId, isInitiator) {
|
|
629
618
|
if (peripheralId != null && conn.remotePublicKey) {
|
|
630
|
-
this.
|
|
619
|
+
this._device(peripheralId).peerKey = b4a.toString(conn.remotePublicKey, 'hex')
|
|
631
620
|
}
|
|
632
621
|
if (this.closing || this.closed) return
|
|
633
622
|
const key = b4a.toString(conn.remotePublicKey, 'hex')
|
|
634
623
|
const existing = this.peers.get(key)
|
|
635
624
|
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.
|
|
625
|
+
// Both sides dial, so a pair links twice. Dropping a channel closes it for
|
|
626
|
+
// BOTH devices, so both must retire the SAME channel. Deterministic winner:
|
|
627
|
+
// keep the channel whose initiator has the smaller static key — computed
|
|
628
|
+
// identically on both ends, so the loser drops on both and never cascades.
|
|
644
629
|
const initiatorIsUsSmaller = b4a.compare(conn.publicKey, conn.remotePublicKey) < 0
|
|
645
630
|
const preferred = isInitiator === initiatorIsUsSmaller
|
|
646
631
|
if (!preferred) {
|
|
@@ -665,21 +650,17 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
665
650
|
}
|
|
666
651
|
}
|
|
667
652
|
|
|
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
|
-
*/
|
|
653
|
+
// Best-effort TYPE_CLOSE to every live session — server sessions over notify,
|
|
654
|
+
// central sessions over write — then wait up to DRAIN_MS for the frames to
|
|
655
|
+
// flush. Resolves regardless: suspend must never hang on a wedged radio.
|
|
676
656
|
async _sayGoodbye() {
|
|
677
657
|
const sent = []
|
|
678
658
|
for (const { sid } of this._sessions.values()) {
|
|
679
659
|
sent.push(this._enqueueNotify(frame(TYPE_CLOSE, sid)).catch(safetyCatch))
|
|
680
660
|
}
|
|
681
|
-
for (const
|
|
682
|
-
const
|
|
661
|
+
for (const d of this._devices.values()) {
|
|
662
|
+
const peripheral = d.peripheral
|
|
663
|
+
const sess = peripheral && peripheral._session
|
|
683
664
|
if (!sess || !peripheral._char) continue
|
|
684
665
|
const f = frame(TYPE_CLOSE, sess.sid)
|
|
685
666
|
sent.push(this._centralSend(peripheral, peripheral._char, f).catch(safetyCatch))
|
|
@@ -691,29 +672,27 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
691
672
|
/**
|
|
692
673
|
* Pause radio activity but KEEP the Server/Central instances and the
|
|
693
674
|
* 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.
|
|
675
|
+
* CoreBluetooth managers can't be destroy()ed (native double-free), so one
|
|
676
|
+
* transport is reused across toggles rather than recreated. Idempotent.
|
|
698
677
|
*/
|
|
699
678
|
async suspend() {
|
|
700
679
|
this._suspended = true
|
|
701
680
|
if (this._scanTimer) clearTimeout(this._scanTimer)
|
|
702
681
|
this._scanTimer = null
|
|
703
|
-
for (const
|
|
704
|
-
|
|
705
|
-
//
|
|
706
|
-
//
|
|
707
|
-
// links (an ACL disconnect is an instant OS-level signal on both roles).
|
|
682
|
+
for (const d of this._devices.values()) if (d.timer) clearTimeout(d.timer)
|
|
683
|
+
// say goodbye BEFORE teardown so the remote reacts in <1s instead of waiting
|
|
684
|
+
// out the 15s keepalive: close frames, a short drain, then an ACL disconnect
|
|
685
|
+
// (an instant OS-level signal on both roles).
|
|
708
686
|
await this._sayGoodbye()
|
|
709
|
-
for (const
|
|
687
|
+
for (const d of this._devices.values()) {
|
|
688
|
+
if (!d.peripheral) continue
|
|
710
689
|
try {
|
|
711
|
-
this.central.disconnect(peripheral)
|
|
690
|
+
this.central.disconnect(d.peripheral)
|
|
712
691
|
} catch (err) {
|
|
713
692
|
safetyCatch(err)
|
|
714
693
|
}
|
|
715
694
|
}
|
|
716
|
-
this.
|
|
695
|
+
this._devices.clear()
|
|
717
696
|
try {
|
|
718
697
|
this._stopScan()
|
|
719
698
|
} catch (err) {
|
|
@@ -743,9 +722,6 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
743
722
|
this._sessions.clear()
|
|
744
723
|
for (const item of this._notifyQueue) item.reject(new Error('suspended'))
|
|
745
724
|
this._notifyQueue = []
|
|
746
|
-
this._linked.clear()
|
|
747
|
-
this._coolUntil.clear()
|
|
748
|
-
this._failures.clear()
|
|
749
725
|
this.state = 'off'
|
|
750
726
|
this.emit('update')
|
|
751
727
|
}
|
|
@@ -769,10 +745,10 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
769
745
|
async _close() {
|
|
770
746
|
if (this._scanTimer) clearTimeout(this._scanTimer)
|
|
771
747
|
this._scanTimer = null
|
|
772
|
-
//
|
|
773
|
-
//
|
|
774
|
-
for (const
|
|
775
|
-
this.
|
|
748
|
+
// never call central/server.destroy() — it double-frees in native teardown;
|
|
749
|
+
// stop advertising/scanning and let the runtime reclaim.
|
|
750
|
+
for (const d of this._devices.values()) if (d.timer) clearTimeout(d.timer)
|
|
751
|
+
this._devices.clear()
|
|
776
752
|
try {
|
|
777
753
|
this.central?.stopScan()
|
|
778
754
|
} catch (err) {
|
|
@@ -784,7 +760,6 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
784
760
|
safetyCatch(err)
|
|
785
761
|
}
|
|
786
762
|
if (!this.keepLinks) {
|
|
787
|
-
// channel mesh: toggling nearby off means stop syncing nearby
|
|
788
763
|
for (const conn of this.peers.values()) {
|
|
789
764
|
try {
|
|
790
765
|
conn.destroy()
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import Hyperswarm from 'hyperswarm'
|
|
2
|
+
import b4a from 'b4a'
|
|
3
|
+
import { hash } from 'hypercore-crypto'
|
|
4
|
+
import safetyCatch from 'safety-catch'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {object} DHTTransportOpts
|
|
8
|
+
* @property {import('../../identity/index.js').Identity} [identity] Long-lived keypair used as the swarm identity.
|
|
9
|
+
* @property {Array<{ host: string, port: number }>} [bootstrap] Custom DHT bootstrap nodes.
|
|
10
|
+
* @property {(remotePublicKey: Uint8Array, payload: any) => boolean} [firewall] Incoming-connection filter.
|
|
11
|
+
* @property {Uint8Array[]} [relayThrough] Relay public keys to tunnel through.
|
|
12
|
+
* @property {string} [channel] Optional network-isolation label; only same-channel peers meet.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The internet transport: a Hyperswarm that finds peers over the DHT. Owns the
|
|
17
|
+
* swarm's whole lifecycle — construction (identity keyPair, bootstrap, firewall,
|
|
18
|
+
* relay), the channel-topic join/leave wrapping, flush, suspend/resume, and
|
|
19
|
+
* teardown. {@link Network} subscribes to `this.swarm`'s connection/peer events
|
|
20
|
+
* and drives topic joins; the swarm-specific wiring lives here.
|
|
21
|
+
*/
|
|
22
|
+
export class DHTTransport {
|
|
23
|
+
/** @param {DHTTransportOpts} [opts] */
|
|
24
|
+
constructor({ identity, bootstrap, firewall, relayThrough, channel } = {}) {
|
|
25
|
+
const opts = {}
|
|
26
|
+
if (identity) opts.keyPair = { publicKey: identity.publicKey, secretKey: identity.secretKey }
|
|
27
|
+
if (bootstrap) opts.bootstrap = bootstrap
|
|
28
|
+
if (firewall) opts.firewall = firewall
|
|
29
|
+
if (relayThrough) opts.relayThrough = relayThrough
|
|
30
|
+
|
|
31
|
+
this.swarm = new Hyperswarm(opts)
|
|
32
|
+
|
|
33
|
+
if (channel) {
|
|
34
|
+
const join = this.swarm.join.bind(this.swarm)
|
|
35
|
+
const leave = this.swarm.leave.bind(this.swarm)
|
|
36
|
+
this.swarm.join = (topic, opts) => join(channelTopic(topic, channel), opts)
|
|
37
|
+
this.swarm.leave = (topic) => leave(channelTopic(topic, channel))
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @returns {boolean} */
|
|
42
|
+
get suspended() {
|
|
43
|
+
return this.swarm?.suspended === true
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Wait for pending DHT announces and lookups to settle, bounded by timeout.
|
|
48
|
+
*
|
|
49
|
+
* @param {{ timeout?: number }} [opts]
|
|
50
|
+
* @returns {Promise<void>}
|
|
51
|
+
*/
|
|
52
|
+
async flush({ timeout = 500 } = {}) {
|
|
53
|
+
if (!this.swarm) return
|
|
54
|
+
await Promise.race([this.swarm.flush(), new Promise((r) => setTimeout(r, timeout))])
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Pause the swarm — keeps state, drops sockets. Idempotent.
|
|
59
|
+
*
|
|
60
|
+
* @returns {Promise<void>}
|
|
61
|
+
*/
|
|
62
|
+
async suspend() {
|
|
63
|
+
if (!this.swarm || this.swarm.suspended) return
|
|
64
|
+
try {
|
|
65
|
+
await this.swarm.suspend()
|
|
66
|
+
} catch (err) {
|
|
67
|
+
safetyCatch(err)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resume a suspended swarm. Idempotent.
|
|
73
|
+
*
|
|
74
|
+
* @returns {Promise<void>}
|
|
75
|
+
*/
|
|
76
|
+
async resume() {
|
|
77
|
+
if (!this.swarm || !this.swarm.suspended) return
|
|
78
|
+
try {
|
|
79
|
+
await this.swarm.resume()
|
|
80
|
+
} catch (err) {
|
|
81
|
+
safetyCatch(err)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Flush pending discovery, then tear down the swarm. Idempotent.
|
|
87
|
+
*
|
|
88
|
+
* @returns {Promise<void>}
|
|
89
|
+
*/
|
|
90
|
+
async destroy() {
|
|
91
|
+
if (!this.swarm) return
|
|
92
|
+
try {
|
|
93
|
+
await this.flush()
|
|
94
|
+
} catch (err) {
|
|
95
|
+
safetyCatch(err)
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
await this.swarm.destroy()
|
|
99
|
+
} catch (err) {
|
|
100
|
+
safetyCatch(err)
|
|
101
|
+
}
|
|
102
|
+
this.swarm = null
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A channel re-namespaces every swarm topic so only same-channel peers meet.
|
|
107
|
+
// No channel → identity (unchanged, back-compat).
|
|
108
|
+
export function channelTopic(topic, channel) {
|
|
109
|
+
return channel ? hash([topic, b4a.from(channel)]) : topic
|
|
110
|
+
}
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
import { Duplex } from 'streamx'
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// via the peripheral's maximumWriteValueLength + write-without-response.
|
|
3
|
+
// A single GATT write/notify caps at ATT_MTU − 3 ≈ 182 bytes; 150 stays under
|
|
4
|
+
// that without negotiating an MTU.
|
|
6
5
|
const PAYLOAD = 150
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* A dumb byte-carrying duplex for the GATT transport. Framing and session logic
|
|
10
|
-
* live in
|
|
11
|
-
* write and pushes inbound payload bytes.
|
|
12
|
-
* duplex, exactly like the old L2CAP channel.
|
|
9
|
+
* live in BLETransport; this only fragments outbound writes to fit a GATT
|
|
10
|
+
* write and pushes inbound payload bytes.
|
|
13
11
|
*
|
|
14
12
|
* @extends Duplex
|
|
15
13
|
*/
|
package/types/network/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export
|
|
1
|
+
export { channelTopic };
|
|
2
2
|
/**
|
|
3
3
|
* @typedef {object} NetworkOpts
|
|
4
4
|
* @property {import('../identity/index.js').Identity} [identity] Long-lived keypair used as the swarm identity.
|
|
@@ -24,12 +24,14 @@ export class Network extends ReadyResource {
|
|
|
24
24
|
firewall: (remotePublicKey: Uint8Array, payload: any) => boolean;
|
|
25
25
|
relayThrough: Uint8Array<ArrayBufferLike>[];
|
|
26
26
|
channel: string;
|
|
27
|
-
|
|
27
|
+
_dht: DHTTransport;
|
|
28
28
|
wakeup: any;
|
|
29
29
|
_replicateables: Set<any>;
|
|
30
30
|
_discoveries: Set<any>;
|
|
31
31
|
_injected: Set<any>;
|
|
32
32
|
_blind: any;
|
|
33
|
+
/** @returns {any} The underlying hyperswarm, or null before ready / after close. */
|
|
34
|
+
get swarm(): any;
|
|
33
35
|
/**
|
|
34
36
|
* Feed an externally-established connection — a Bluetooth L2CAP channel, a
|
|
35
37
|
* serial link, an in-process pair, any duplex — into the network. A raw
|
|
@@ -75,7 +77,7 @@ export class Network extends ReadyResource {
|
|
|
75
77
|
* @param {{ timeout?: number }} [opts]
|
|
76
78
|
* @returns {Promise<void>}
|
|
77
79
|
*/
|
|
78
|
-
flush(
|
|
80
|
+
flush(opts?: {
|
|
79
81
|
timeout?: number;
|
|
80
82
|
}): Promise<void>;
|
|
81
83
|
/**
|
|
@@ -153,5 +155,7 @@ export type NetworkOpts = {
|
|
|
153
155
|
export type Replicable = {
|
|
154
156
|
replicate: (stream: any) => any;
|
|
155
157
|
};
|
|
158
|
+
import { channelTopic } from './transports/dht.js';
|
|
156
159
|
import ReadyResource from 'ready-resource';
|
|
160
|
+
import { DHTTransport } from './transports/dht.js';
|
|
157
161
|
import { Discovery } from './discovery.js';
|
|
@@ -12,22 +12,19 @@ export function toServiceUUID(topic: Uint8Array, tag?: string): string;
|
|
|
12
12
|
* byte-stream to each discovered peer, and feeds it into `network.inject`. From
|
|
13
13
|
* there replication and pairing are transport-agnostic (see Network.inject).
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* ponytail: capability-handshake DoS link-scoring is deferred — it needs a
|
|
22
|
-
* replication-progress signal (design §4b). v1 caps links + times out dials.
|
|
15
|
+
* The server adds one data characteristic (write + notify) and advertises. The
|
|
16
|
+
* central connects, discovers the characteristic, subscribes, then framed bytes
|
|
17
|
+
* flow both ways — central→server as GATT writes, server→central as
|
|
18
|
+
* notifications — each tagged with an 8-byte session id. `backend` is
|
|
19
|
+
* bare-bluetooth in production and a mock in tests.
|
|
23
20
|
*
|
|
24
21
|
* @extends ReadyResource
|
|
25
22
|
*/
|
|
26
|
-
export class
|
|
23
|
+
export class BLETransport extends ReadyResource {
|
|
27
24
|
/**
|
|
28
25
|
* @param {object} opts
|
|
29
26
|
* @param {any} opts.backend bare-bluetooth-shaped module (Central, Server, Service, Characteristic).
|
|
30
|
-
* @param {import('
|
|
27
|
+
* @param {import('../index.js').Network} opts.network
|
|
31
28
|
* @param {Uint8Array} opts.uuid The 32-byte topic the service UUID derives from.
|
|
32
29
|
* @param {Uint8Array} opts.nodeId Stable local id (identity/device key) for the initiate tie-break.
|
|
33
30
|
* @param {string} [opts.tag] UUID namespace (channel mesh vs invite mesh).
|
|
@@ -38,7 +35,7 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
38
35
|
*/
|
|
39
36
|
constructor({ backend, network, uuid, nodeId, tag, cap, scanOptions, keepLinks, name }: {
|
|
40
37
|
backend: any;
|
|
41
|
-
network: import("
|
|
38
|
+
network: import("../index.js").Network;
|
|
42
39
|
uuid: Uint8Array;
|
|
43
40
|
nodeId: Uint8Array;
|
|
44
41
|
tag?: string;
|
|
@@ -50,7 +47,7 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
50
47
|
name?: string;
|
|
51
48
|
});
|
|
52
49
|
backend: any;
|
|
53
|
-
network: import("
|
|
50
|
+
network: import("../index.js").Network;
|
|
54
51
|
name: string;
|
|
55
52
|
nodeId: Uint8Array<ArrayBufferLike>;
|
|
56
53
|
nodeHex: any;
|
|
@@ -71,19 +68,8 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
71
68
|
_scanning: boolean;
|
|
72
69
|
_advertising: boolean;
|
|
73
70
|
_serviceAdded: boolean;
|
|
74
|
-
/** peripheral id
|
|
75
|
-
|
|
76
|
-
/** peripheral ids that carry a live channel — never re-dialed (a second
|
|
77
|
-
* dial's failure would disconnect the peripheral and kill the good link) */
|
|
78
|
-
_linked: Set<any>;
|
|
79
|
-
/** peripheral id → retry-after timestamp; failed dials back off */
|
|
80
|
-
_coolUntil: Map<any, any>;
|
|
81
|
-
/** peripheral id → consecutive failure count; drives exponential backoff */
|
|
82
|
-
_failures: Map<any, any>;
|
|
83
|
-
/** peripheral id → remote peer key, learned at handshake — dial guard */
|
|
84
|
-
_peerByPeripheral: Map<any, any>;
|
|
85
|
-
/** live central-side peripheral wrappers — for goodbye + physical hang-up on suspend */
|
|
86
|
-
_connectedPeripherals: Set<any>;
|
|
71
|
+
/** peripheral id → per-peer dial state { timer, linked, coolUntil, failures, peerKey, peripheral } */
|
|
72
|
+
_devices: Map<any, any>;
|
|
87
73
|
/** last central.connect timestamp — global inter-dial rate limit */
|
|
88
74
|
_lastDial: number;
|
|
89
75
|
_scanTimer: any;
|
|
@@ -100,21 +86,15 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
100
86
|
*/
|
|
101
87
|
shouldInitiate(peerNodeId: Uint8Array): boolean;
|
|
102
88
|
get linkCount(): number;
|
|
89
|
+
_device(id: any): any;
|
|
90
|
+
_prune(id: any): void;
|
|
103
91
|
_startServer(Service: any, Characteristic: any): void;
|
|
104
92
|
_maybeAdvertise(): void;
|
|
105
93
|
_onWriteRequests(requests: any): void;
|
|
106
94
|
_onServerFrame(data: any): void;
|
|
107
95
|
_closeServerSession(sidHex: any, sid: any): void;
|
|
108
|
-
/** Our hello payload: the local app-user name the peer labels this link with. */
|
|
109
96
|
_helloPayload(): any;
|
|
110
|
-
|
|
111
|
-
* Parse a hello payload. Malformed → null (the caller ignores it).
|
|
112
|
-
*
|
|
113
|
-
* @param {Uint8Array} payload
|
|
114
|
-
* @returns {string | null}
|
|
115
|
-
*/
|
|
116
|
-
_parseHello(payload: Uint8Array): string | null;
|
|
117
|
-
/** Stash a peer's name onto a server session + its conn, then refresh mirrors. */
|
|
97
|
+
_parseHello(payload: any): string;
|
|
118
98
|
_applyPeerName(session: any, payload: any): void;
|
|
119
99
|
_enqueueNotify(f: any): Promise<any>;
|
|
120
100
|
_drainNotify(): void;
|
|
@@ -130,26 +110,17 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
130
110
|
_writeOnce(peripheral: any, char: any, f: any): Promise<any>;
|
|
131
111
|
_abortDial(peripheral: any, _reason: any): void;
|
|
132
112
|
_clearDial(id: any): void;
|
|
113
|
+
_isDialing(): boolean;
|
|
133
114
|
_onCentralError(err: any): void;
|
|
134
|
-
_onChannel(
|
|
115
|
+
_onChannel(stream: any, isInitiator: any, peripheralId: any): any;
|
|
135
116
|
_track(conn: any, peripheralId: any, isInitiator: any): void;
|
|
136
117
|
_untrack(conn: any): void;
|
|
137
|
-
/**
|
|
138
|
-
* Best-effort TYPE_CLOSE to every live session — server sessions over the
|
|
139
|
-
* notify path, central sessions over the write path — reusing the same helpers
|
|
140
|
-
* a normal stream close uses. Waits up to DRAIN_MS for the frames to flush,
|
|
141
|
-
* then resolves regardless: suspend must never hang on a wedged radio.
|
|
142
|
-
*
|
|
143
|
-
* @returns {Promise<void>}
|
|
144
|
-
*/
|
|
145
118
|
_sayGoodbye(): Promise<void>;
|
|
146
119
|
/**
|
|
147
120
|
* Pause radio activity but KEEP the Server/Central instances and the
|
|
148
121
|
* registered GATT service alive — the toggle-friendly counterpart to _close.
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
* keeps a duplicate GATT service registered; remote centrals then subscribe to
|
|
152
|
-
* the dead service and hear silence. Reuse one instance instead. Idempotent.
|
|
122
|
+
* CoreBluetooth managers can't be destroy()ed (native double-free), so one
|
|
123
|
+
* transport is reused across toggles rather than recreated. Idempotent.
|
|
153
124
|
*/
|
|
154
125
|
suspend(): Promise<void>;
|
|
155
126
|
/**
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export function channelTopic(topic: any, channel: any): any;
|
|
2
|
+
/**
|
|
3
|
+
* @typedef {object} DHTTransportOpts
|
|
4
|
+
* @property {import('../../identity/index.js').Identity} [identity] Long-lived keypair used as the swarm identity.
|
|
5
|
+
* @property {Array<{ host: string, port: number }>} [bootstrap] Custom DHT bootstrap nodes.
|
|
6
|
+
* @property {(remotePublicKey: Uint8Array, payload: any) => boolean} [firewall] Incoming-connection filter.
|
|
7
|
+
* @property {Uint8Array[]} [relayThrough] Relay public keys to tunnel through.
|
|
8
|
+
* @property {string} [channel] Optional network-isolation label; only same-channel peers meet.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The internet transport: a Hyperswarm that finds peers over the DHT. Owns the
|
|
12
|
+
* swarm's whole lifecycle — construction (identity keyPair, bootstrap, firewall,
|
|
13
|
+
* relay), the channel-topic join/leave wrapping, flush, suspend/resume, and
|
|
14
|
+
* teardown. {@link Network} subscribes to `this.swarm`'s connection/peer events
|
|
15
|
+
* and drives topic joins; the swarm-specific wiring lives here.
|
|
16
|
+
*/
|
|
17
|
+
export class DHTTransport {
|
|
18
|
+
/** @param {DHTTransportOpts} [opts] */
|
|
19
|
+
constructor({ identity, bootstrap, firewall, relayThrough, channel }?: DHTTransportOpts);
|
|
20
|
+
swarm: any;
|
|
21
|
+
/** @returns {boolean} */
|
|
22
|
+
get suspended(): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Wait for pending DHT announces and lookups to settle, bounded by timeout.
|
|
25
|
+
*
|
|
26
|
+
* @param {{ timeout?: number }} [opts]
|
|
27
|
+
* @returns {Promise<void>}
|
|
28
|
+
*/
|
|
29
|
+
flush({ timeout }?: {
|
|
30
|
+
timeout?: number;
|
|
31
|
+
}): Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* Pause the swarm — keeps state, drops sockets. Idempotent.
|
|
34
|
+
*
|
|
35
|
+
* @returns {Promise<void>}
|
|
36
|
+
*/
|
|
37
|
+
suspend(): Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* Resume a suspended swarm. Idempotent.
|
|
40
|
+
*
|
|
41
|
+
* @returns {Promise<void>}
|
|
42
|
+
*/
|
|
43
|
+
resume(): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Flush pending discovery, then tear down the swarm. Idempotent.
|
|
46
|
+
*
|
|
47
|
+
* @returns {Promise<void>}
|
|
48
|
+
*/
|
|
49
|
+
destroy(): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
export type DHTTransportOpts = {
|
|
52
|
+
/**
|
|
53
|
+
* Long-lived keypair used as the swarm identity.
|
|
54
|
+
*/
|
|
55
|
+
identity?: import("../../identity/index.js").Identity;
|
|
56
|
+
/**
|
|
57
|
+
* Custom DHT bootstrap nodes.
|
|
58
|
+
*/
|
|
59
|
+
bootstrap?: Array<{
|
|
60
|
+
host: string;
|
|
61
|
+
port: number;
|
|
62
|
+
}>;
|
|
63
|
+
/**
|
|
64
|
+
* Incoming-connection filter.
|
|
65
|
+
*/
|
|
66
|
+
firewall?: (remotePublicKey: Uint8Array, payload: any) => boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Relay public keys to tunnel through.
|
|
69
|
+
*/
|
|
70
|
+
relayThrough?: Uint8Array[];
|
|
71
|
+
/**
|
|
72
|
+
* Optional network-isolation label; only same-channel peers meet.
|
|
73
|
+
*/
|
|
74
|
+
channel?: string;
|
|
75
|
+
};
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A dumb byte-carrying duplex for the GATT transport. Framing and session logic
|
|
3
|
-
* live in
|
|
4
|
-
* write and pushes inbound payload bytes.
|
|
5
|
-
* duplex, exactly like the old L2CAP channel.
|
|
3
|
+
* live in BLETransport; this only fragments outbound writes to fit a GATT
|
|
4
|
+
* write and pushes inbound payload bytes.
|
|
6
5
|
*
|
|
7
6
|
* @extends Duplex
|
|
8
7
|
*/
|