@cero-base/core 1.1.0 → 1.2.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/README.md +14 -12
- package/package.json +13 -4
- package/src/blobs/index.js +1 -1
- package/src/database/bootstrap.js +2 -2
- package/src/database/dispatch.js +41 -6
- package/src/database/index.js +177 -17
- package/src/identity/index.js +15 -1
- package/src/lib/errors.js +8 -0
- package/src/lib/utils.js +33 -12
- package/src/network/bluetooth.js +324 -0
- package/src/network/index.js +113 -8
- package/src/pairing/index.js +6 -6
- package/src/rpc/index.js +2 -2
- package/src/storage/index.js +30 -8
- package/types/database/dispatch.d.ts +2 -1
- package/types/database/index.d.ts +34 -8
- package/types/identity/index.d.ts +9 -0
- package/types/lib/errors.d.ts +6 -0
- package/types/lib/utils.d.ts +4 -6
- package/types/network/bluetooth.d.ts +93 -0
- package/types/network/index.d.ts +36 -1
- package/types/storage/index.d.ts +7 -1
- package/src/database/CLAUDE.md +0 -3
- package/src/identity/CLAUDE.md +0 -3
- package/src/lib/CLAUDE.md +0 -3
package/src/lib/utils.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import crypto from 'crypto'
|
|
2
|
+
import b4a from 'b4a'
|
|
2
3
|
import { Readable } from 'streamx'
|
|
3
4
|
import z32 from 'z32'
|
|
4
|
-
import { encode, decode
|
|
5
|
+
import { encode, decode } from 'hypercore-id-encoding'
|
|
5
6
|
|
|
6
7
|
import { ROLE_PERMS, RANK } from './constants.js'
|
|
7
8
|
|
|
@@ -28,12 +29,18 @@ export const toId = encode
|
|
|
28
29
|
*/
|
|
29
30
|
export const toKey = decode
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
// Domain-separated payloads for writer-admission signatures. Binding the db
|
|
33
|
+
// key makes an admission unreplayable across rooms; the tag pins the format.
|
|
34
|
+
// v2 — pre-1.2 signatures (writer‖appender / bare writer) no longer verify.
|
|
35
|
+
const ADD_WRITER_TAG = b4a.from('cero/add-writer/v2')
|
|
36
|
+
const CLAIM_WRITER_TAG = b4a.from('cero/claim-writer/v2')
|
|
37
|
+
|
|
38
|
+
/** @type {(dbKey: Uint8Array, writer: Uint8Array, appender: Uint8Array) => Uint8Array} */
|
|
39
|
+
export const addWriterPayload = (dbKey, writer, appender) =>
|
|
40
|
+
b4a.concat([ADD_WRITER_TAG, dbKey, writer, appender])
|
|
41
|
+
|
|
42
|
+
/** @type {(dbKey: Uint8Array, writer: Uint8Array) => Uint8Array} */
|
|
43
|
+
export const claimWriterPayload = (dbKey, writer) => b4a.concat([CLAIM_WRITER_TAG, dbKey, writer])
|
|
37
44
|
|
|
38
45
|
/**
|
|
39
46
|
* Whether `role` is granted `perm` under the default policy.
|
|
@@ -63,7 +70,6 @@ export const outranks = (a, b) => RANK[a] != null && RANK[b] != null && RANK[a]
|
|
|
63
70
|
*/
|
|
64
71
|
export function subscribe({ get, watch }) {
|
|
65
72
|
let stop = null
|
|
66
|
-
let version = 0
|
|
67
73
|
let last
|
|
68
74
|
const stream = new Readable({
|
|
69
75
|
destroy(cb) {
|
|
@@ -78,15 +84,30 @@ export function subscribe({ get, watch }) {
|
|
|
78
84
|
stream.push(data)
|
|
79
85
|
}
|
|
80
86
|
|
|
87
|
+
// Coalesce: one get() in flight at a time. Ticks that land mid-read mark
|
|
88
|
+
// dirty and fold into a single trailing re-read — a write burst costs one
|
|
89
|
+
// extra snapshot instead of one concurrent scan per tick, and only the
|
|
90
|
+
// latest state is emitted.
|
|
91
|
+
let running = false
|
|
92
|
+
let dirty = false
|
|
81
93
|
const push = async () => {
|
|
82
94
|
if (stream.destroyed) return
|
|
83
|
-
|
|
95
|
+
if (running) {
|
|
96
|
+
dirty = true
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
running = true
|
|
84
100
|
try {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
101
|
+
do {
|
|
102
|
+
dirty = false
|
|
103
|
+
const data = await get()
|
|
104
|
+
if (stream.destroyed) return
|
|
105
|
+
emit(data)
|
|
106
|
+
} while (dirty)
|
|
88
107
|
} catch (e) {
|
|
89
108
|
stream.destroy(e)
|
|
109
|
+
} finally {
|
|
110
|
+
running = false
|
|
90
111
|
}
|
|
91
112
|
}
|
|
92
113
|
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import ReadyResource from 'ready-resource'
|
|
2
|
+
import b4a from 'b4a'
|
|
3
|
+
import { hash } from 'hypercore-crypto'
|
|
4
|
+
import safetyCatch from 'safety-catch'
|
|
5
|
+
|
|
6
|
+
// Readable characteristic carrying "<psm>:<nodeId-hex>" — the L2CAP PSM to
|
|
7
|
+
// connect on plus the advertiser's stable id for the initiate tie-break. Read
|
|
8
|
+
// over GATT after connecting (bare-mobile-doctor reads the PSM the same way);
|
|
9
|
+
// advertisement service-data is not a portable channel for this.
|
|
10
|
+
const PSM_UUID = 'ce1a0003-0000-1000-8000-00805f9b34fb'
|
|
11
|
+
const DEFAULT_CAP = 4
|
|
12
|
+
const CONNECT_TIMEOUT = 15000
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Derive a stable 128-bit BLE service UUID from a topic. Only devices that
|
|
16
|
+
* compute the same UUID (same channel / same invite) ever discover each other.
|
|
17
|
+
*
|
|
18
|
+
* @param {Uint8Array} topic
|
|
19
|
+
* @param {string} [tag] Namespace so channel and invite meshes never collide.
|
|
20
|
+
* @returns {string}
|
|
21
|
+
*/
|
|
22
|
+
export function toServiceUUID(topic, tag = 'cero-ble') {
|
|
23
|
+
const h = hash([b4a.from(tag), topic])
|
|
24
|
+
const hex = b4a.toString(h.subarray(0, 16), 'hex')
|
|
25
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Backend adapter state → the app-facing state the facade exposes.
|
|
29
|
+
const STATE = {
|
|
30
|
+
poweredOn: 'on',
|
|
31
|
+
poweredOff: 'waiting',
|
|
32
|
+
unauthorized: 'unauthorized',
|
|
33
|
+
unsupported: 'unsupported',
|
|
34
|
+
resetting: 'waiting',
|
|
35
|
+
unknown: 'waiting'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const uuidEq = (a, b) =>
|
|
39
|
+
String(a || '')
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
.replace(/-/g, '') ===
|
|
42
|
+
String(b || '')
|
|
43
|
+
.toLowerCase()
|
|
44
|
+
.replace(/-/g, '')
|
|
45
|
+
|
|
46
|
+
const findByUUID = (items, uuid) => (items || []).find((i) => uuidEq(i.uuid, uuid)) || null
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Dual-role BLE transport: advertises + scans one service UUID, opens an L2CAP
|
|
50
|
+
* channel to each discovered peer, and feeds it into `network.inject`. From
|
|
51
|
+
* there replication and pairing are transport-agnostic (see Network.inject).
|
|
52
|
+
*
|
|
53
|
+
* Choreography mirrors the proven bare-mobile-doctor worklet: server adds a
|
|
54
|
+
* readable PSM characteristic at startup and answers reads dynamically; the
|
|
55
|
+
* central connects, reads "<psm>:<nodeId>", tie-breaks, then opens the channel.
|
|
56
|
+
* `backend` is bare-bluetooth in production and a mock in tests.
|
|
57
|
+
*
|
|
58
|
+
* ponytail: capability-handshake DoS link-scoring is deferred — it needs a
|
|
59
|
+
* replication-progress signal (design §4b). v1 caps links + times out dials.
|
|
60
|
+
*
|
|
61
|
+
* @extends ReadyResource
|
|
62
|
+
*/
|
|
63
|
+
export class BluetoothTransport extends ReadyResource {
|
|
64
|
+
/**
|
|
65
|
+
* @param {object} opts
|
|
66
|
+
* @param {any} opts.backend bare-bluetooth-shaped module (Central, Server, Service, Characteristic).
|
|
67
|
+
* @param {import('./index.js').Network} opts.network
|
|
68
|
+
* @param {Uint8Array} opts.uuid The 32-byte topic the service UUID derives from.
|
|
69
|
+
* @param {Uint8Array} opts.nodeId Stable local id (identity/device key) for the initiate tie-break.
|
|
70
|
+
* @param {string} [opts.tag] UUID namespace (channel mesh vs invite mesh).
|
|
71
|
+
* @param {number} [opts.cap] Max concurrent links; gossip covers the rest.
|
|
72
|
+
* @param {{ scanMode?: any }} [opts.scanOptions] Platform scan options (e.g. Android low-power).
|
|
73
|
+
* @param {boolean} [opts.keepLinks] On close, stop the radio but leave established links alive (invite rendezvous: the link outlives the QR and carries the initial replication).
|
|
74
|
+
*/
|
|
75
|
+
constructor({
|
|
76
|
+
backend,
|
|
77
|
+
network,
|
|
78
|
+
uuid,
|
|
79
|
+
nodeId,
|
|
80
|
+
tag = 'cero-ble',
|
|
81
|
+
cap = DEFAULT_CAP,
|
|
82
|
+
scanOptions,
|
|
83
|
+
keepLinks = false
|
|
84
|
+
}) {
|
|
85
|
+
super()
|
|
86
|
+
this.backend = backend
|
|
87
|
+
this.network = network
|
|
88
|
+
this.nodeId = nodeId
|
|
89
|
+
this.nodeHex = b4a.toString(nodeId, 'hex')
|
|
90
|
+
this.serviceUUID = toServiceUUID(uuid, tag)
|
|
91
|
+
this.cap = cap
|
|
92
|
+
this.scanOptions = scanOptions
|
|
93
|
+
this.keepLinks = keepLinks
|
|
94
|
+
|
|
95
|
+
this.state = 'off'
|
|
96
|
+
this.central = null
|
|
97
|
+
this.server = null
|
|
98
|
+
this.psm = null
|
|
99
|
+
this._scanning = false
|
|
100
|
+
this._advertising = false
|
|
101
|
+
this._serviceAdded = false
|
|
102
|
+
/** peripheral id being dialed → its connect-timeout timer */
|
|
103
|
+
this._dialing = new Map()
|
|
104
|
+
/** live injected links keyed by remote node id hex */
|
|
105
|
+
this.peers = new Map()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Whether we should be the one to open the connection to `peerNodeId`. The
|
|
110
|
+
* lexicographically smaller id initiates; the larger waits — so a pair
|
|
111
|
+
* connects once, not twice. Equal (our own reflection) → false.
|
|
112
|
+
*
|
|
113
|
+
* @param {Uint8Array} peerNodeId
|
|
114
|
+
* @returns {boolean}
|
|
115
|
+
*/
|
|
116
|
+
shouldInitiate(peerNodeId) {
|
|
117
|
+
return b4a.compare(this.nodeId, peerNodeId) < 0
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
get linkCount() {
|
|
121
|
+
return this.peers.size
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async _open() {
|
|
125
|
+
const { Central, Server, Service, Characteristic } = this.backend
|
|
126
|
+
|
|
127
|
+
this.server = new Server()
|
|
128
|
+
this.server.on('stateChange', (s) => {
|
|
129
|
+
this._onState(s)
|
|
130
|
+
if (s === 'poweredOn') this._startServer(Service, Characteristic)
|
|
131
|
+
})
|
|
132
|
+
this.server.on('serviceAdd', () => {
|
|
133
|
+
this._serviceAdded = true
|
|
134
|
+
this._maybeAdvertise()
|
|
135
|
+
})
|
|
136
|
+
this.server.on('channelPublish', (psm) => {
|
|
137
|
+
this.psm = psm
|
|
138
|
+
this._maybeAdvertise()
|
|
139
|
+
})
|
|
140
|
+
// A central reads this to learn the PSM (+ our id) before opening the
|
|
141
|
+
// channel. Answer dynamically: the PSM isn't known when the service is added.
|
|
142
|
+
this.server.on('readRequest', (req) => {
|
|
143
|
+
const ok = this.server.constructor.ATT_SUCCESS ?? 0
|
|
144
|
+
const val = this.psm == null ? b4a.alloc(0) : b4a.from(`${this.psm}:${this.nodeHex}`)
|
|
145
|
+
this.server.respondToRequest(req, ok, val)
|
|
146
|
+
})
|
|
147
|
+
this.server.on('channelOpen', (l2cap) => this._onChannel(l2cap, false, null))
|
|
148
|
+
this.server.on('error', safetyCatch)
|
|
149
|
+
|
|
150
|
+
this.central = new Central()
|
|
151
|
+
this.central.on('stateChange', (s) => {
|
|
152
|
+
this._onState(s)
|
|
153
|
+
if (s === 'poweredOn') this._startScan()
|
|
154
|
+
})
|
|
155
|
+
this.central.on('discover', (peripheral) => this._onDiscover(peripheral))
|
|
156
|
+
this.central.on('connect', (peripheral) => this._onConnect(peripheral))
|
|
157
|
+
this.central.on('disconnect', () => {})
|
|
158
|
+
this.central.on('error', (err) => this._onCentralError(err))
|
|
159
|
+
|
|
160
|
+
this._startServer(Service, Characteristic)
|
|
161
|
+
this._startScan()
|
|
162
|
+
this.state = 'starting'
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
_startServer(Service, Characteristic) {
|
|
166
|
+
if (this.server.state !== 'poweredOn') return // wait for the adapter (stateChange)
|
|
167
|
+
if (!this._serviceAdded) {
|
|
168
|
+
const char = new Characteristic(PSM_UUID, { read: true })
|
|
169
|
+
this.server.addService(new Service(this.serviceUUID, [char]))
|
|
170
|
+
}
|
|
171
|
+
if (this.psm == null) this.server.publishChannel({})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
_maybeAdvertise() {
|
|
175
|
+
if (this._advertising || !this._serviceAdded || this.psm == null) return
|
|
176
|
+
this._advertising = true
|
|
177
|
+
this.server.startAdvertising({ serviceUUIDs: [this.serviceUUID] })
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
_startScan() {
|
|
181
|
+
if (this._scanning || this.central.state !== 'poweredOn') return
|
|
182
|
+
this._scanning = true
|
|
183
|
+
this.central.startScan([this.serviceUUID], this.scanOptions)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
_onState(raw) {
|
|
187
|
+
const next = STATE[raw] ?? 'waiting'
|
|
188
|
+
if (next === this.state) return
|
|
189
|
+
this.state = next
|
|
190
|
+
this.emit('update')
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
_onDiscover(peripheral) {
|
|
194
|
+
if (this.closing || this.closed) return
|
|
195
|
+
if (this._dialing.has(peripheral.id)) return // already connecting to this one
|
|
196
|
+
if (this.linkCount >= this.cap) return // gossip covers the rest
|
|
197
|
+
// tie-break is decided post-read (we can't trust advertisement payloads);
|
|
198
|
+
// dial now, learn the peer id from the PSM characteristic, decide there.
|
|
199
|
+
const timer = setTimeout(() => this._abortDial(peripheral, 'timeout'), CONNECT_TIMEOUT)
|
|
200
|
+
this._dialing.set(peripheral.id, timer)
|
|
201
|
+
try {
|
|
202
|
+
this.central.connect(peripheral)
|
|
203
|
+
} catch (err) {
|
|
204
|
+
this._abortDial(peripheral, err)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
_onConnect(peripheral) {
|
|
209
|
+
peripheral.on('error', () => this._abortDial(peripheral, 'peripheral-error'))
|
|
210
|
+
peripheral.once('servicesDiscover', (services) => {
|
|
211
|
+
const svc = findByUUID(services, this.serviceUUID)
|
|
212
|
+
if (svc) peripheral.discoverCharacteristics(svc, [PSM_UUID])
|
|
213
|
+
else this._abortDial(peripheral, 'no-service')
|
|
214
|
+
})
|
|
215
|
+
peripheral.once('characteristicsDiscover', (_svc, chars) => {
|
|
216
|
+
const psmChar = findByUUID(chars, PSM_UUID)
|
|
217
|
+
if (psmChar) peripheral.read(psmChar)
|
|
218
|
+
else this._abortDial(peripheral, 'no-psm-char')
|
|
219
|
+
})
|
|
220
|
+
peripheral.once('read', (_char, data) => {
|
|
221
|
+
const [psmStr, peerHex] = b4a.toString(data).split(':')
|
|
222
|
+
const psm = Number(psmStr)
|
|
223
|
+
const peerId = peerHex ? b4a.from(peerHex, 'hex') : null
|
|
224
|
+
// the smaller id opens the channel; the larger disconnects and waits for
|
|
225
|
+
// the peer to dial back — a pair links once, not twice
|
|
226
|
+
if (!psm || !peerId || !this.shouldInitiate(peerId)) {
|
|
227
|
+
this._abortDial(peripheral, 'yield')
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
peripheral.once('channelOpen', (l2cap) => {
|
|
231
|
+
this._clearDial(peripheral.id)
|
|
232
|
+
this._onChannel(l2cap, true, peripheral.id)
|
|
233
|
+
})
|
|
234
|
+
peripheral.openL2CAPChannel(psm)
|
|
235
|
+
})
|
|
236
|
+
peripheral.discoverServices([this.serviceUUID])
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
_abortDial(peripheral, _reason) {
|
|
240
|
+
const id = peripheral?.id
|
|
241
|
+
this._clearDial(id)
|
|
242
|
+
try {
|
|
243
|
+
this.central.disconnect(peripheral)
|
|
244
|
+
} catch (err) {
|
|
245
|
+
safetyCatch(err)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
_clearDial(id) {
|
|
250
|
+
if (id == null) return
|
|
251
|
+
const timer = this._dialing.get(id)
|
|
252
|
+
if (timer) clearTimeout(timer)
|
|
253
|
+
this._dialing.delete(id) // free the slot — rediscovery can re-dial
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// iOS & Android report errored connects as 'error' with a code, not
|
|
257
|
+
// 'disconnect' — without this a failed dial pins the peripheral forever.
|
|
258
|
+
_onCentralError(err) {
|
|
259
|
+
safetyCatch(err)
|
|
260
|
+
const code = err && err.code
|
|
261
|
+
if (code === 'CONNECTION_FAILED' || code === 'DISCONNECT') {
|
|
262
|
+
for (const id of [...this._dialing.keys()]) this._clearDial(id)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
_onChannel(l2cap, isInitiator, peripheralId) {
|
|
267
|
+
if (this.closing || this.closed) {
|
|
268
|
+
l2cap.destroy()
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
const conn = this.network.inject(l2cap, { isInitiator })
|
|
272
|
+
conn.on('open', () => this._track(conn, peripheralId))
|
|
273
|
+
conn.on('close', () => this._untrack(conn, peripheralId))
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
_track(conn, peripheralId) {
|
|
277
|
+
if (this.closing || this.closed) return
|
|
278
|
+
const key = b4a.toString(conn.remotePublicKey, 'hex')
|
|
279
|
+
const existing = this.peers.get(key)
|
|
280
|
+
if (existing && existing !== conn) {
|
|
281
|
+
conn.destroy() // duplicate link to the same peer — keep the first
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
conn._peripheralId = peripheralId
|
|
285
|
+
conn._peerKey = key
|
|
286
|
+
this.peers.set(key, conn)
|
|
287
|
+
this.emit('update')
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
_untrack(conn) {
|
|
291
|
+
const key = conn._peerKey
|
|
292
|
+
if (key && this.peers.get(key) === conn) this.peers.delete(key)
|
|
293
|
+
if (!this.closing && !this.closed) this.emit('update')
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async _close() {
|
|
297
|
+
// doctor-app rule: never call central/server.destroy() — it double-frees in
|
|
298
|
+
// the native teardown; stop advertising/scanning and let the runtime reclaim.
|
|
299
|
+
for (const timer of this._dialing.values()) clearTimeout(timer)
|
|
300
|
+
this._dialing.clear()
|
|
301
|
+
try {
|
|
302
|
+
this.central?.stopScan()
|
|
303
|
+
} catch (err) {
|
|
304
|
+
safetyCatch(err)
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
this.server?.stopAdvertising()
|
|
308
|
+
} catch (err) {
|
|
309
|
+
safetyCatch(err)
|
|
310
|
+
}
|
|
311
|
+
if (!this.keepLinks) {
|
|
312
|
+
// channel mesh: toggling nearby off means stop syncing nearby
|
|
313
|
+
for (const conn of this.peers.values()) {
|
|
314
|
+
try {
|
|
315
|
+
conn.destroy()
|
|
316
|
+
} catch (err) {
|
|
317
|
+
safetyCatch(err)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
this.peers.clear()
|
|
322
|
+
this.state = 'off'
|
|
323
|
+
}
|
|
324
|
+
}
|
package/src/network/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import Hyperswarm from 'hyperswarm'
|
|
2
|
+
import NoiseSecretStream from '@hyperswarm/secret-stream'
|
|
3
|
+
import BlindPairing from 'blind-pairing'
|
|
2
4
|
import ProtomuxWakeup from 'protomux-wakeup'
|
|
3
5
|
import ReadyResource from 'ready-resource'
|
|
4
6
|
import safetyCatch from 'safety-catch'
|
|
@@ -39,6 +41,81 @@ export class Network extends ReadyResource {
|
|
|
39
41
|
|
|
40
42
|
this._replicateables = new Set()
|
|
41
43
|
this._discoveries = new Set()
|
|
44
|
+
this._injected = new Set()
|
|
45
|
+
this._blind = null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Feed an externally-established connection — a Bluetooth L2CAP channel, a
|
|
50
|
+
* serial link, an in-process pair, any duplex — into the network. A raw
|
|
51
|
+
* duplex is wrapped in NoiseSecretStream (pass `isInitiator`); a stream
|
|
52
|
+
* that already IS one is used as-is. From here it gets the exact same
|
|
53
|
+
* treatment as a swarm connection: wakeup, replication of every attached
|
|
54
|
+
* core, pairing, and the 'connection' event.
|
|
55
|
+
*
|
|
56
|
+
* @param {any} stream Duplex transport, or a ready NoiseSecretStream.
|
|
57
|
+
* @param {{ isInitiator?: boolean }} [opts] Which side initiates the noise handshake (raw duplexes only).
|
|
58
|
+
* @returns {any} The encrypted connection stream.
|
|
59
|
+
*/
|
|
60
|
+
inject(stream, { isInitiator } = {}) {
|
|
61
|
+
if (this.closing || this.closed) throw CeroError.CLOSED('Network')
|
|
62
|
+
if (!stream) throw CeroError.REQUIRED('stream')
|
|
63
|
+
const conn =
|
|
64
|
+
stream.noiseStream === stream ? stream : new NoiseSecretStream(isInitiator === true, stream)
|
|
65
|
+
|
|
66
|
+
// blind-pairing picks the lowest-`rtt` unvisited channel to send on; that
|
|
67
|
+
// field only exists on real udx sockets. A raw injected duplex has none,
|
|
68
|
+
// so `rtt < Infinity` is always false and pairing never sends a request.
|
|
69
|
+
// Zero is also the semantically correct RTT for a direct injected link.
|
|
70
|
+
if (conn.rawStream && conn.rawStream.rtt === undefined) conn.rawStream.rtt = 0
|
|
71
|
+
|
|
72
|
+
this._injected.add(conn)
|
|
73
|
+
conn.on('close', () => this._injected.delete(conn))
|
|
74
|
+
conn.on('error', safetyCatch) // a dropped radio link must not crash the host
|
|
75
|
+
|
|
76
|
+
this.wakeup.addStream(conn)
|
|
77
|
+
for (const r of this._replicateables) replicateInto(r, conn)
|
|
78
|
+
this.emit('connection', conn, { injected: true })
|
|
79
|
+
return conn
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Lazily create the network-shared BlindPairing. One instance serves every
|
|
84
|
+
* handle's pairing member — per-handle instances each added their own swarm
|
|
85
|
+
* and DHT listeners plus a protomux channel per connection.
|
|
86
|
+
*
|
|
87
|
+
* @returns {Promise<any>}
|
|
88
|
+
*/
|
|
89
|
+
async blind() {
|
|
90
|
+
if (!this._blind) {
|
|
91
|
+
const blind = new BlindPairing(this.swarm)
|
|
92
|
+
this._blind = blind.ready().then(() => {
|
|
93
|
+
// blind-pairing only watches the swarm — injected connections must
|
|
94
|
+
// reach it too, or offline (e.g. Bluetooth) pairing never completes.
|
|
95
|
+
// _onconnection is upstream-private; the offline-pairing test pins it.
|
|
96
|
+
this.on('connection', (conn, info) => {
|
|
97
|
+
if (info?.injected) blind._onconnection(conn)
|
|
98
|
+
})
|
|
99
|
+
for (const conn of this._injected) blind._onconnection(conn)
|
|
100
|
+
return blind
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
return this._blind
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Re-attach pairing channels on injected connections. blind-pairing only
|
|
108
|
+
* auto-attaches refs that existed when a connection arrived — swarm peers
|
|
109
|
+
* meet again over topic joins, injected links (Bluetooth, pipes) don't, so
|
|
110
|
+
* a member/candidate added later must re-run the attach. Idempotent:
|
|
111
|
+
* protomux refuses duplicate channels.
|
|
112
|
+
*
|
|
113
|
+
* @returns {Promise<void>}
|
|
114
|
+
*/
|
|
115
|
+
async refreshInjected() {
|
|
116
|
+
if (!this._blind || !this._injected.size) return
|
|
117
|
+
const blind = await this._blind
|
|
118
|
+
for (const conn of this._injected) blind._onconnection(conn)
|
|
42
119
|
}
|
|
43
120
|
|
|
44
121
|
/** @returns {Map<string, any>} Known peers keyed by public-key string. */
|
|
@@ -46,9 +123,10 @@ export class Network extends ReadyResource {
|
|
|
46
123
|
return this.swarm ? this.swarm.peers : new Map()
|
|
47
124
|
}
|
|
48
125
|
|
|
49
|
-
/** @returns {Set<any>} Live connection streams. */
|
|
126
|
+
/** @returns {Set<any>} Live connection streams — swarm and injected. */
|
|
50
127
|
get connections() {
|
|
51
|
-
return this.swarm ? this.swarm.connections : new Set()
|
|
128
|
+
if (!this._injected.size) return this.swarm ? this.swarm.connections : new Set()
|
|
129
|
+
return new Set([...(this.swarm ? this.swarm.connections : []), ...this._injected])
|
|
52
130
|
}
|
|
53
131
|
|
|
54
132
|
/** @returns {boolean} */
|
|
@@ -129,6 +207,15 @@ export class Network extends ReadyResource {
|
|
|
129
207
|
}
|
|
130
208
|
|
|
131
209
|
async _close() {
|
|
210
|
+
for (const conn of [...this._injected]) {
|
|
211
|
+
try {
|
|
212
|
+
conn.destroy()
|
|
213
|
+
} catch (err) {
|
|
214
|
+
safetyCatch(err)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
this._injected.clear()
|
|
218
|
+
|
|
132
219
|
for (const d of [...this._discoveries]) {
|
|
133
220
|
try {
|
|
134
221
|
await d.destroy()
|
|
@@ -137,6 +224,15 @@ export class Network extends ReadyResource {
|
|
|
137
224
|
}
|
|
138
225
|
}
|
|
139
226
|
|
|
227
|
+
if (this._blind) {
|
|
228
|
+
try {
|
|
229
|
+
await (await this._blind).close()
|
|
230
|
+
} catch (err) {
|
|
231
|
+
safetyCatch(err)
|
|
232
|
+
}
|
|
233
|
+
this._blind = null
|
|
234
|
+
}
|
|
235
|
+
|
|
140
236
|
if (this.swarm) {
|
|
141
237
|
try {
|
|
142
238
|
await this.flush()
|
|
@@ -195,9 +291,7 @@ export class Network extends ReadyResource {
|
|
|
195
291
|
attach(core) {
|
|
196
292
|
if (!core) throw CeroError.REQUIRED('core')
|
|
197
293
|
this._replicateables.add(core)
|
|
198
|
-
|
|
199
|
-
for (const stream of this.swarm.connections) replicateInto(core, stream)
|
|
200
|
-
}
|
|
294
|
+
for (const stream of this.connections) replicateInto(core, stream)
|
|
201
295
|
}
|
|
202
296
|
|
|
203
297
|
/**
|
|
@@ -223,13 +317,24 @@ export class Network extends ReadyResource {
|
|
|
223
317
|
if (!target || typeof target.replicate !== 'function') {
|
|
224
318
|
throw CeroError.INVALID('target must be an object with a replicate(stream) method')
|
|
225
319
|
}
|
|
226
|
-
|
|
227
|
-
for (const stream of this.swarm.connections) replicateInto(target, stream)
|
|
228
|
-
}
|
|
320
|
+
for (const stream of this.connections) replicateInto(target, stream)
|
|
229
321
|
}
|
|
230
322
|
}
|
|
231
323
|
|
|
324
|
+
// Corestore replication is store-wide: N attached bees on one store would
|
|
325
|
+
// re-attach every core AND add N duplicate StreamTracker records per
|
|
326
|
+
// connection. Replicate each root store once per stream; wakeup streams are
|
|
327
|
+
// added at the network level, so skipped bees lose nothing.
|
|
328
|
+
const replicatedRoots = new WeakMap()
|
|
329
|
+
|
|
232
330
|
function replicateInto(core, stream) {
|
|
331
|
+
const root = core.store ? core.store.root || core.store : null
|
|
332
|
+
if (root) {
|
|
333
|
+
let seen = replicatedRoots.get(stream)
|
|
334
|
+
if (!seen) replicatedRoots.set(stream, (seen = new WeakSet()))
|
|
335
|
+
if (seen.has(root)) return
|
|
336
|
+
seen.add(root)
|
|
337
|
+
}
|
|
233
338
|
try {
|
|
234
339
|
core.replicate(stream)
|
|
235
340
|
} catch (err) {
|
package/src/pairing/index.js
CHANGED
|
@@ -88,13 +88,14 @@ export class Pairing extends ReadyResource {
|
|
|
88
88
|
|
|
89
89
|
async _open() {
|
|
90
90
|
await this.network.ready()
|
|
91
|
-
|
|
92
|
-
await this.
|
|
91
|
+
// shared instance — one set of swarm/DHT listeners for every handle
|
|
92
|
+
this._blind = await this.network.blind()
|
|
93
93
|
|
|
94
94
|
this._member = this._blind.addMember({
|
|
95
95
|
discoveryKey: discoveryKey(this.topic),
|
|
96
96
|
onadd: (req) => this._onCandidate(req).catch(this._onerror)
|
|
97
97
|
})
|
|
98
|
+
await this.network.refreshInjected()
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
async _close() {
|
|
@@ -105,10 +106,8 @@ export class Pairing extends ReadyResource {
|
|
|
105
106
|
await this._member.close()
|
|
106
107
|
this._member = null
|
|
107
108
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
this._blind = null
|
|
111
|
-
}
|
|
109
|
+
// the BlindPairing is network-owned and shared — never close it here
|
|
110
|
+
this._blind = null
|
|
112
111
|
this._invites.clear()
|
|
113
112
|
}
|
|
114
113
|
|
|
@@ -415,6 +414,7 @@ class Candidate {
|
|
|
415
414
|
onadd: (result) => this._done(result)
|
|
416
415
|
})
|
|
417
416
|
this._candidate.request.on('rejected', (err) => this._fail(fromBlindError(err)))
|
|
417
|
+
this.pairing.network.refreshInjected().catch(this._onerror ?? (() => {}))
|
|
418
418
|
} catch (err) {
|
|
419
419
|
this._fail(err instanceof CeroError ? err : CeroError.NETWORK_ERROR(err.message))
|
|
420
420
|
}
|
package/src/rpc/index.js
CHANGED
|
@@ -108,13 +108,13 @@ export function bindCodec(spec) {
|
|
|
108
108
|
},
|
|
109
109
|
encodeAction(handle, op, data) {
|
|
110
110
|
const ref = handle?.[op]
|
|
111
|
-
if (!ref || !ref.schema) throw
|
|
111
|
+
if (!ref || !ref.schema) throw CeroError.UNKNOWN('action', op)
|
|
112
112
|
if (data == null) return EMPTY
|
|
113
113
|
return schema.encode(ref.schema, data)
|
|
114
114
|
},
|
|
115
115
|
decodeAction(handle, op, buf) {
|
|
116
116
|
const ref = handle?.[op]
|
|
117
|
-
if (!ref || !ref.schema) throw
|
|
117
|
+
if (!ref || !ref.schema) throw CeroError.UNKNOWN('action', op)
|
|
118
118
|
if (!buf || buf.length === 0) return undefined
|
|
119
119
|
return schema.decode(ref.schema, buf)
|
|
120
120
|
}
|