@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cero-base/core",
3
- "version": "1.3.0",
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/bluetooth": {
45
- "types": "./types/network/bluetooth.d.ts",
46
- "default": "./src/network/bluetooth.js"
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",
@@ -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.swarm = null
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
- const opts = {}
147
- if (this.identity)
148
- opts.keyPair = { publicKey: this.identity.publicKey, secretKey: this.identity.secretKey }
149
- if (this.bootstrap) opts.bootstrap = this.bootstrap
150
- if (this.firewall) opts.firewall = this.firewall
151
- if (this.relayThrough) opts.relayThrough = this.relayThrough
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
- this.swarm.on('connection', (stream, info) => {
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
- this.swarm.on('peer-add', (peer) => this.emit('peer-add', peer))
173
- this.swarm.on('peer-remove', (peer) => this.emit('peer-remove', peer))
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({ timeout = 500 } = {}) {
183
- if (!this.swarm) return
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 (!this.swarm || this.closing || this.closed) return
194
- if (this.swarm.suspended) return
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 (!this.swarm || this.closing || this.closed) return
209
- if (!this.swarm.suspended) return
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.swarm) {
233
+ if (this._dht) {
245
234
  try {
246
- await this.flush()
235
+ await this._dht.destroy()
247
236
  } catch (err) {
248
237
  safetyCatch(err)
249
238
  }
250
- try {
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-stream.js'
6
+ import { GattStream } from './gatt.js'
7
7
 
8
- // One data characteristic (write + notify) carries a framed byte stream both
9
- // ways. iOS never answers an L2CAP channel opened by a Mac central and gives no
10
- // L2CAP disconnect signal; GATT with app-level framing is the proven transport
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 on the characteristic (both directions): [type:1][sessionId:8][payload].
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 bases (bitchat model): eager while unlinked, patient once
25
- // we have a link. The actual cooldown grows exponentially per consecutive failure.
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
- // no two dials globally closer than this — one radio can't usefully dial faster
28
+ // one radio can't usefully dial faster than this
30
29
  const DIAL_MIN_INTERVAL = 500
31
- // Lonely: scan continuously, cycling every SCAN_RESTART_LONELY so a re-advertised
32
- // peer is re-reported (iOS suppresses duplicate discoveries within a scan session).
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
- // Linked: continuous scanning is the dominant BLE battery cost, so duty-cycle it
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: how long to let goodbye frames flush before hanging up.
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 (the caller logs + drops).
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
- * Choreography: the server adds one data characteristic (write + notify) and
116
- * advertises. The central connects, discovers the characteristic, subscribes,
117
- * then framed bytes flow both ways — central→server as GATT writes, server→
118
- * central as notifications — each tagged with an 8-byte session id (bitchat
119
- * model). `backend` is bare-bluetooth in production and a mock in tests.
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 BluetoothTransport extends ReadyResource {
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('./index.js').Network} opts.network
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 being dialed its connect-timeout timer */
173
- this._dialing = new Map()
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
- // WriteRequests carry no central identifier (see peripheral-manager
226
- // _onwriterequests), so sessions can't be mapped to the unsubscribing
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 // wait for the adapter (stateChange)
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 // off means off: acknowledge but drop
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 // duplicate open
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 // already torn down (peer sent close)
299
+ if (!this._sessions.has(sidHex)) return
308
300
  this._sessions.delete(sidHex)
309
- this._enqueueNotify(frame(TYPE_CLOSE, sid)).catch(safetyCatch) // best effort
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 // malformed hello — ignore
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 retry
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 // queue full — wait for 'readyToUpdate', retry same frame
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
- // Scan scheduler (bitchat model). Lonely: cycle the scan every
378
- // SCAN_RESTART_LONELY so a re-advertised peer is re-reported (iOS suppresses
379
- // duplicate discoveries within a scan session). Linked: duty-cycle to save the
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._dialing.size > 0) {
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() // enter the dark half of the duty cycle
378
+ this._stopScan()
397
379
  this._armScanRestart()
398
380
  } else {
399
- this._startScan() // wake up and re-arm for the next dark window
381
+ this._startScan()
400
382
  }
401
383
  } else {
402
- this._stopScan() // lonely: cycle so re-advertised peers re-report
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
- if (this._suspended) return
429
- if (this._linked.has(peripheral.id)) return // already linked
430
- const knownPeer = this._peerByPeripheral.get(peripheral.id)
431
- if (knownPeer && this.peers.has(knownPeer)) return // device already linked via another channel
432
- const cool = this._coolUntil.get(peripheral.id)
433
- if (cool && cool > Date.now()) return // failed recently back off
434
- if (this._dialing.has(peripheral.id)) return // already connecting to this one
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
- // no tie-break: dial every discovery and open a session (bitchat model);
440
- // a redundant link to the same peer is dropped by _track's dedup
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._dialing.set(peripheral.id, timer)
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._connectedPeripherals.add(peripheral)
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
- // No tie-break: both sides open a session (bitchat model). On iOS the peer
473
- // id isn't known until after connect, so a tie-break can only yield
474
- // post-handshake which deadlocks when the other side never dials back.
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._connectedPeripherals.delete(peripheral)
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
- return
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 // malformed hello — ignore
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 count = (this._failures.get(id) || 0) + 1
571
- this._failures.set(id, count)
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
- const cooldown = Math.min(DIAL_COOLDOWN_MAX, base * 2 ** Math.min(4, count - 1))
574
- this._coolUntil.set(id, Date.now() + cooldown)
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 timer = this._dialing.get(id)
589
- if (timer) clearTimeout(timer)
590
- this._dialing.delete(id) // free the slot — rediscovery can re-dial
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 [...this._dialing.keys()]) this._clearDial(id)
581
+ for (const [id, d] of this._devices) if (d.timer) this._clearDial(id)
600
582
  }
601
583
  }
602
584
 
603
- _onChannel(l2cap, isInitiator, peripheralId) {
585
+ _onChannel(stream, isInitiator, peripheralId) {
604
586
  if (this.closing || this.closed || this._suspended) {
605
- l2cap.destroy()
587
+ stream.destroy()
606
588
  return
607
589
  }
608
- const conn = this.network.inject(l2cap, { isInitiator })
609
- // Liveness: iOS never signals an L2CAP disconnect, so ping the peer and
610
- // drop the link if we stop hearing back (keepalive refreshes the timeout).
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
- l2cap.on('error', safetyCatch)
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._linked.add(peripheralId)
618
- this._coolUntil.delete(peripheralId)
619
- this._failures.delete(peripheralId) // reached a live session — reset backoff
620
- conn.once('close', () => this._linked.delete(peripheralId))
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, peripheralId))
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._peerByPeripheral.set(peripheralId, b4a.toString(conn.remotePublicKey, 'hex'))
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 (bitchat model) so a pair links twice two separate
637
- // channels, each with an initiator end on one device and a responder end
638
- // on the other. Dropping a channel closes it for BOTH devices, so the two
639
- // devices MUST retire the same channel or one tears down the peer's kept
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
- * Best-effort TYPE_CLOSE to every live session server sessions over the
670
- * notify path, central sessions over the write path reusing the same helpers
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 peripheral of this._connectedPeripherals) {
682
- const sess = peripheral._session
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
- * iOS CoreBluetooth managers can't be destroy()ed (native double-free), so a
695
- * fresh transport per toggle leaks a manager whose stale peripheral-manager
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 timer of this._dialing.values()) clearTimeout(timer)
704
- this._dialing.clear()
705
- // Say goodbye BEFORE teardown so the remote reacts in <1s instead of waiting
706
- // out the 15s keepalive: close frames, a short drain, then drop the physical
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 peripheral of this._connectedPeripherals) {
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._connectedPeripherals.clear()
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
- // doctor-app rule: never call central/server.destroy() — it double-frees in
773
- // the native teardown; stop advertising/scanning and let the runtime reclaim.
774
- for (const timer of this._dialing.values()) clearTimeout(timer)
775
- this._dialing.clear()
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
- // iOS caps a single GATT write (and a notify payload) at ATT_MTU − 3 ≈ 182
4
- // bytes. 150 stays safely under that without negotiating an MTU. Raise later
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 BluetoothTransport; this only fragments outbound writes to fit a GATT
11
- * write and pushes inbound payload bytes. NoiseSecretStream wraps it as a raw
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
  */
@@ -1,4 +1,4 @@
1
- export function channelTopic(topic: any, channel: any): any;
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
- swarm: any;
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({ timeout }?: {
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
- * Choreography: the server adds one data characteristic (write + notify) and
16
- * advertises. The central connects, discovers the characteristic, subscribes,
17
- * then framed bytes flow both ways — central→server as GATT writes, server→
18
- * central as notifications — each tagged with an 8-byte session id (bitchat
19
- * model). `backend` is bare-bluetooth in production and a mock in tests.
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 BluetoothTransport extends ReadyResource {
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('./index.js').Network} opts.network
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("./index.js").Network;
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("./index.js").Network;
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 being dialed its connect-timeout timer */
75
- _dialing: Map<any, any>;
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(l2cap: any, isInitiator: any, peripheralId: any): any;
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
- * iOS CoreBluetooth managers can't be destroy()ed (native double-free), so a
150
- * fresh transport per toggle leaks a manager whose stale peripheral-manager
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 BluetoothTransport; this only fragments outbound writes to fit a GATT
4
- * write and pushes inbound payload bytes. NoiseSecretStream wraps it as a raw
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
  */