@cero-base/cero 1.1.1 → 1.3.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 +53 -24
- package/package.json +18 -7
- package/src/bluetooth.js +190 -0
- package/src/build/index.js +17 -4
- package/src/build/schemas.js +3 -1
- package/src/extensions/handle-sync.js +14 -3
- package/src/extensions/profile-sync.js +10 -2
- package/src/handle/index.js +39 -11
- package/src/index.js +57 -11
- package/src/lib/internal.js +7 -1
- package/src/lib/operators.js +38 -18
- package/src/local/index.js +4 -2
- package/src/rpc/client.js +18 -5
- package/src/rpc/server.js +30 -7
- package/types/bluetooth.d.ts +66 -0
- package/types/build/index.d.ts +2 -1
- package/types/build/schemas.d.ts +2 -0
- package/types/extensions/handle-sync.d.ts +2 -0
- package/types/extensions/profile-sync.d.ts +2 -0
- package/types/handle/index.d.ts +14 -0
- package/types/index.d.ts +19 -1
- package/types/lib/internal.d.ts +22 -1
- package/types/lib/operators.d.ts +1 -7
- package/types/local/index.d.ts +6 -1
- package/src/CLAUDE.md +0 -3
- package/src/build/CLAUDE.md +0 -3
- package/src/extensions/CLAUDE.md +0 -3
- package/src/handle/CLAUDE.md +0 -3
- package/src/lib/CLAUDE.md +0 -3
- package/src/rpc/CLAUDE.md +0 -3
package/src/handle/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import z32 from 'z32'
|
|
|
9
9
|
import { Identity } from '@cero-base/core/identity'
|
|
10
10
|
import { Database } from '@cero-base/core/database'
|
|
11
11
|
import { Pairing } from '@cero-base/core/pairing'
|
|
12
|
-
import { toId, grants } from '@cero-base/core/utils'
|
|
12
|
+
import { toId, grants, addWriterPayload } from '@cero-base/core/utils'
|
|
13
13
|
import { CeroError } from '@cero-base/core/errors'
|
|
14
14
|
import { Blobs } from '@cero-base/core/blobs'
|
|
15
15
|
import { FileServer } from '@cero-base/core/blobs/server'
|
|
@@ -42,6 +42,7 @@ export { Ref } from '../lib/utils.js'
|
|
|
42
42
|
* @property {Uint8Array} [encryptionKey] Existing encryption key.
|
|
43
43
|
* @property {string} [namespace] Corestore namespace.
|
|
44
44
|
* @property {KeyPair} [keyPair] Writer keypair.
|
|
45
|
+
* @property {boolean} [passive] Join discovery server-only; flip later with `setActive`.
|
|
45
46
|
* @property {boolean} [pair] When `false`, skips creating a `Pairing` session.
|
|
46
47
|
*
|
|
47
48
|
* @typedef {object} CreateChildOpts
|
|
@@ -132,6 +133,7 @@ export class Handle extends ReadyResource {
|
|
|
132
133
|
encryptionKey: opts.encryptionKey,
|
|
133
134
|
namespace: opts.namespace,
|
|
134
135
|
keyPair: opts.keyPair,
|
|
136
|
+
passive: opts.passive,
|
|
135
137
|
onerror: this._onerror
|
|
136
138
|
})
|
|
137
139
|
this.pair = null
|
|
@@ -156,6 +158,8 @@ export class Handle extends ReadyResource {
|
|
|
156
158
|
|
|
157
159
|
async _close() {
|
|
158
160
|
this.root._coreKeys.delete(b4a.toString(this.store.key, 'hex'))
|
|
161
|
+
if (this._blobs?.key) this.root._coreKeys.delete(b4a.toString(this._blobs.key, 'hex'))
|
|
162
|
+
for (const hex of this._blobKeys || []) this.root._coreKeys.delete(hex)
|
|
159
163
|
for (const r of [...this._owned]) r.destroy?.()
|
|
160
164
|
this._owned.clear()
|
|
161
165
|
if (this._blobs) await this._blobs.close()
|
|
@@ -302,7 +306,7 @@ export class Handle extends ReadyResource {
|
|
|
302
306
|
this.root._coreKeys.set(b4a.toString(this._blobs.key, 'hex'), this.store.encryptionKey)
|
|
303
307
|
}
|
|
304
308
|
})
|
|
305
|
-
.catch(
|
|
309
|
+
.catch(this._onerror)
|
|
306
310
|
}
|
|
307
311
|
return this._blobs
|
|
308
312
|
}
|
|
@@ -359,6 +363,18 @@ export class Handle extends ReadyResource {
|
|
|
359
363
|
await this.store.bee.update()
|
|
360
364
|
}
|
|
361
365
|
|
|
366
|
+
/**
|
|
367
|
+
* Flip this handle's swarm announce mode — `setActive(false)` demotes an
|
|
368
|
+
* idle/background room to server-only (still reachable, stops searching);
|
|
369
|
+
* `setActive(true)` promotes it back on focus. Cheap, safe to call often.
|
|
370
|
+
*
|
|
371
|
+
* @param {boolean} active
|
|
372
|
+
* @returns {Promise<void>}
|
|
373
|
+
*/
|
|
374
|
+
setActive(active) {
|
|
375
|
+
return this.store.setActive(active)
|
|
376
|
+
}
|
|
377
|
+
|
|
362
378
|
/**
|
|
363
379
|
* Mint a pairing invite for this handle.
|
|
364
380
|
*
|
|
@@ -405,6 +421,11 @@ export class Handle extends ReadyResource {
|
|
|
405
421
|
throw CeroError.INVALID(`role '${role}' exceeds the invite role '${candidate.invite.role}'`)
|
|
406
422
|
}
|
|
407
423
|
|
|
424
|
+
// confirm must answer within the incoming pairing request's lifetime —
|
|
425
|
+
// any await before it (even ~100ms) and the response is dropped, the
|
|
426
|
+
// joiner times out. So the key is revealed before the membership writes
|
|
427
|
+
// land; if they fail the joiner holds the key un-admitted, which is
|
|
428
|
+
// recoverable (re-pair) and surfaced via onerror in _wireAccept.
|
|
408
429
|
await candidate.confirm({ key: this.store.key, encryptionKey: this.store.encryptionKey })
|
|
409
430
|
|
|
410
431
|
const ts = Date.now()
|
|
@@ -423,7 +444,9 @@ export class Handle extends ReadyResource {
|
|
|
423
444
|
return
|
|
424
445
|
}
|
|
425
446
|
|
|
426
|
-
const sig = this.identity.sign(
|
|
447
|
+
const sig = this.identity.sign(
|
|
448
|
+
addWriterPayload(this.store.key, writerKey, this.store.writerKey)
|
|
449
|
+
)
|
|
427
450
|
await this.store.tx(async () => {
|
|
428
451
|
await this.store.call('add-writer', {
|
|
429
452
|
sig,
|
|
@@ -480,7 +503,7 @@ export class Handle extends ReadyResource {
|
|
|
480
503
|
await child.store.call('add-writer', {
|
|
481
504
|
master: this.identity.publicKey,
|
|
482
505
|
writer: writerKey,
|
|
483
|
-
sig: this.identity.sign(
|
|
506
|
+
sig: this.identity.sign(addWriterPayload(child.store.key, writerKey, child.store.writerKey))
|
|
484
507
|
})
|
|
485
508
|
await child.store.call('add-member', {
|
|
486
509
|
id: this.identity.id,
|
|
@@ -535,6 +558,10 @@ export class Handle extends ReadyResource {
|
|
|
535
558
|
if (existing) return this._load(type, existing.id)
|
|
536
559
|
}
|
|
537
560
|
|
|
561
|
+
// offline join: with nearby sync on, also rendezvous on the invite-derived
|
|
562
|
+
// BLE UUID for the duration of the join — pairing rides the injected link
|
|
563
|
+
const stopNearby = this.root.bluetooth ? this.root.bluetooth.announce(invite) : null
|
|
564
|
+
|
|
538
565
|
const child = /** @type {Child} */ (
|
|
539
566
|
await Handle.join(invite, {
|
|
540
567
|
parent: this,
|
|
@@ -542,7 +569,7 @@ export class Handle extends ReadyResource {
|
|
|
542
569
|
namespace: `${NS}/handle/${type}/${randomNs()}`,
|
|
543
570
|
routes,
|
|
544
571
|
timeout
|
|
545
|
-
})
|
|
572
|
+
}).finally(() => stopNearby?.())
|
|
546
573
|
)
|
|
547
574
|
// whenWritable timing out (host offline) is a normal failure — close the
|
|
548
575
|
// fully-opened child rather than leak its Database/pairing/swarm session
|
|
@@ -607,7 +634,8 @@ export class Handle extends ReadyResource {
|
|
|
607
634
|
async _reopen(type, id) {
|
|
608
635
|
const { data } = await this.store.get('handles', id)
|
|
609
636
|
if (!data) throw CeroError.UNKNOWN('handle', id)
|
|
610
|
-
if (data.type !== type)
|
|
637
|
+
if (data.type !== type)
|
|
638
|
+
throw CeroError.INVALID(`handle ${id} is type ${data.type}, not ${type}`)
|
|
611
639
|
let writer = await this._loadKeyPair(id)
|
|
612
640
|
const firstTime = !writer
|
|
613
641
|
if (firstTime) {
|
|
@@ -643,7 +671,7 @@ export class Handle extends ReadyResource {
|
|
|
643
671
|
async suspend() {
|
|
644
672
|
if (this.parent || this.closing || this.closed || this._suspended) return
|
|
645
673
|
this._suspended = true
|
|
646
|
-
|
|
674
|
+
await Promise.all([...this.children].map((c) => c.pair?.suspend()))
|
|
647
675
|
await this.network.suspend()
|
|
648
676
|
try {
|
|
649
677
|
await this.store.store.suspend()
|
|
@@ -683,10 +711,10 @@ export class Handle extends ReadyResource {
|
|
|
683
711
|
) {
|
|
684
712
|
const net = network || parent?.network
|
|
685
713
|
const id = identity || parent?.identity
|
|
686
|
-
if (!net) throw
|
|
687
|
-
if (!id) throw
|
|
688
|
-
if (!store && !parent) throw
|
|
689
|
-
if (!spec) throw
|
|
714
|
+
if (!net) throw CeroError.REQUIRED('network')
|
|
715
|
+
if (!id) throw CeroError.REQUIRED('identity')
|
|
716
|
+
if (!store && !parent) throw CeroError.REQUIRED('store')
|
|
717
|
+
if (!spec) throw CeroError.REQUIRED('spec')
|
|
690
718
|
|
|
691
719
|
const writer = Identity.randomKeyPair()
|
|
692
720
|
const pair = new Pairing({ network: net, identity: id })
|
package/src/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { CeroError } from '@cero-base/core/errors'
|
|
|
9
9
|
|
|
10
10
|
import { Handle, Ref } from './handle/index.js'
|
|
11
11
|
import { Local } from './local/index.js'
|
|
12
|
+
import { Bluetooth } from './bluetooth.js'
|
|
12
13
|
import {
|
|
13
14
|
put,
|
|
14
15
|
set,
|
|
@@ -66,6 +67,9 @@ export { t, schema } from './lib/spec.js'
|
|
|
66
67
|
* @property {(err: any) => void} [onerror] Background-task error handler.
|
|
67
68
|
* @property {boolean} [recovery] Recovery flow — wipe local state and re-claim a writer slot.
|
|
68
69
|
* @property {number} [recoveryTimeout] Max wait for peer data + writer capability during recovery.
|
|
70
|
+
* @property {Uint8Array} [storageKey] 32-byte key encrypting local key material (master seed, device keypairs) at rest. Source it from the OS keychain — cero never stores it.
|
|
71
|
+
* @property {boolean} [extensions] `false` disables the bundled extensions (profileSync, handleSync) for this instance. Build with `{ extensions: false }` too so the spec matches.
|
|
72
|
+
* @property {boolean | { autoStart?: boolean, backend?: any }} [bluetooth] `true` enables nearby (Bluetooth) sync via `me.bluetooth` (auto-started). `{ autoStart: false }` creates the facade without starting the radio — the app calls `me.bluetooth.start()`/`stop()` (user toggle). `backend` injects a bare-bluetooth-shaped backend (tests); absent backend on an unsupported host → `me.bluetooth.state === 'unsupported'`.
|
|
69
73
|
*/
|
|
70
74
|
|
|
71
75
|
/**
|
|
@@ -93,24 +97,27 @@ export async function cero(dir, spec, opts = {}) {
|
|
|
93
97
|
// lock leaks and a retry in the same process hits locked storage.
|
|
94
98
|
try {
|
|
95
99
|
await storage.ready()
|
|
100
|
+
await fs.promises.chmod(`${dir}/main`, 0o700)
|
|
96
101
|
store = new Corestore(storage, { manifestVersion: 2 })
|
|
97
102
|
await store.ready()
|
|
98
103
|
|
|
99
104
|
if (spec.local && spec.meta?.local) {
|
|
100
|
-
local = new Local(null, spec, { store })
|
|
105
|
+
local = new Local(null, spec, { store, storageKey: opts.storageKey })
|
|
101
106
|
await local.ready()
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
const identity = await resolveIdentity(opts, local)
|
|
105
110
|
const writer = local ? (await local.store.get('keypair')).data : null
|
|
106
111
|
|
|
107
|
-
// Channel stamp: a storage remembers its channel; reopening it under a different channel
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
if (local
|
|
111
|
-
const stored = (await local.store.get('environment')).data?.channel
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
// Channel stamp: a storage remembers its channel; reopening it under a different channel —
|
|
113
|
+
// including no channel at all, which would silently rejoin the global network — is a
|
|
114
|
+
// misconfiguration that could leak data across networks, so reject it.
|
|
115
|
+
if (local) {
|
|
116
|
+
const stored = (await local.store.get('environment')).data?.channel ?? null
|
|
117
|
+
const wanted = opts.channel ?? null
|
|
118
|
+
if (stored == null && wanted != null)
|
|
119
|
+
await local.store.set('environment', { channel: wanted })
|
|
120
|
+
else if (stored != null && stored !== wanted) throw CeroError.CHANNEL_MISMATCH()
|
|
114
121
|
}
|
|
115
122
|
|
|
116
123
|
network = new Network({ bootstrap: opts.bootstrap, channel: opts.channel })
|
|
@@ -164,10 +171,28 @@ export async function cero(dir, spec, opts = {}) {
|
|
|
164
171
|
if (opts.recovery) await me.recover({ timeout: opts.recoveryTimeout })
|
|
165
172
|
|
|
166
173
|
for (const ext of internal.extensions) {
|
|
174
|
+
if (ext.bundled && opts.extensions === false) continue
|
|
167
175
|
const off = await ext.setup?.(me)
|
|
168
176
|
if (typeof off === 'function') me.once('close', off)
|
|
169
177
|
}
|
|
170
178
|
|
|
179
|
+
if (opts.bluetooth) {
|
|
180
|
+
// `true` → defaults; `{ autoStart, backend }` → options. A bare backend
|
|
181
|
+
// object (pre-1.3 shape, has Central/Server) is still accepted.
|
|
182
|
+
const bt =
|
|
183
|
+
opts.bluetooth === true
|
|
184
|
+
? {}
|
|
185
|
+
: opts.bluetooth.Central
|
|
186
|
+
? { backend: opts.bluetooth }
|
|
187
|
+
: opts.bluetooth
|
|
188
|
+
me.bluetooth = new Bluetooth(me, {
|
|
189
|
+
backend: bt.backend || null,
|
|
190
|
+
autoStart: bt.autoStart !== false
|
|
191
|
+
})
|
|
192
|
+
me.once('close', () => me.bluetooth.close().catch(safetyCatch))
|
|
193
|
+
await me.bluetooth.ready()
|
|
194
|
+
}
|
|
195
|
+
|
|
171
196
|
bind(me, null)
|
|
172
197
|
return me
|
|
173
198
|
} catch (err) {
|
|
@@ -205,7 +230,17 @@ export async function restore(me, phrase) {
|
|
|
205
230
|
const { _dir: dir, spec, _opts: opts } = me
|
|
206
231
|
// channel must carry over — without it the recovered instance rejoins the
|
|
207
232
|
// global identity topic and never meets its channeled peers (recovery timeout).
|
|
208
|
-
const {
|
|
233
|
+
const {
|
|
234
|
+
name,
|
|
235
|
+
bootstrap,
|
|
236
|
+
isMobile,
|
|
237
|
+
onerror,
|
|
238
|
+
routes,
|
|
239
|
+
recoveryTimeout,
|
|
240
|
+
channel,
|
|
241
|
+
storageKey,
|
|
242
|
+
extensions
|
|
243
|
+
} = opts
|
|
209
244
|
|
|
210
245
|
await me.close()
|
|
211
246
|
await fs.promises.rm(`${dir}/main`, { recursive: true, force: true })
|
|
@@ -218,6 +253,8 @@ export async function restore(me, phrase) {
|
|
|
218
253
|
routes,
|
|
219
254
|
recoveryTimeout,
|
|
220
255
|
channel,
|
|
256
|
+
storageKey,
|
|
257
|
+
extensions,
|
|
221
258
|
phrase,
|
|
222
259
|
recovery: true
|
|
223
260
|
})
|
|
@@ -242,11 +279,20 @@ cero.restore = restore
|
|
|
242
279
|
cero.schema = schema
|
|
243
280
|
cero.bind = bind
|
|
244
281
|
cero.define = define
|
|
282
|
+
// test-only escape hatch (cross-package tests reset/seed the registry)
|
|
245
283
|
cero._internal = internal
|
|
246
284
|
// A bare function is shorthand for a behavior-only extension: `{ setup: fn }`.
|
|
247
285
|
// Accepts both `use(a, b)` and `use([a, b])` (and a mix) for easier composition.
|
|
248
|
-
|
|
249
|
-
|
|
286
|
+
// A named extension replaces any registered one with the same name — so
|
|
287
|
+
// `use(profileSync({ fields }))` reconfigures the bundled default instead of
|
|
288
|
+
// doubling it.
|
|
289
|
+
cero.use = (...exts) => {
|
|
290
|
+
for (const e of exts.flat().map((e) => (typeof e === 'function' ? { setup: e } : e))) {
|
|
291
|
+
const i = e.name ? internal.extensions.findIndex((x) => x.name === e.name) : -1
|
|
292
|
+
if (i >= 0) internal.extensions[i] = e
|
|
293
|
+
else internal.extensions.push(e)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
250
296
|
|
|
251
297
|
async function resolveIdentity(opts, local) {
|
|
252
298
|
if (opts.identity) return opts.identity
|
package/src/lib/internal.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
// Process-wide registry for extensions registered via `cero.use()`.
|
|
2
2
|
// `build()` folds in each extension's schema; `cero()` runs each setup.
|
|
3
|
-
|
|
3
|
+
// The bundled extensions are on by default — `cero.use(profileSync({...}))`
|
|
4
|
+
// replaces a default by name, `{ extensions: false }` (on build and cero)
|
|
5
|
+
// leaves them out entirely.
|
|
6
|
+
import { profileSync } from '../extensions/profile-sync.js'
|
|
7
|
+
import { handleSync } from '../extensions/handle-sync.js'
|
|
8
|
+
|
|
9
|
+
export const internal = { extensions: [profileSync(), handleSync()] }
|
package/src/lib/operators.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Readable } from 'streamx'
|
|
2
|
+
import b4a from 'b4a'
|
|
2
3
|
|
|
3
4
|
import { encodeId, decodeId } from '@cero-base/core/blobs/codec'
|
|
4
5
|
import { onAbort } from './utils.js'
|
|
@@ -30,11 +31,11 @@ export function resolveFile(handle, id, name) {
|
|
|
30
31
|
/**
|
|
31
32
|
* Insert (or overwrite by id) a row on `ref`. The `files` builtin is special:
|
|
32
33
|
* `put(handle.files, { data, type, name? })` uploads the bytes to this handle's
|
|
33
|
-
* blob store, records `{ id, name }`, and
|
|
34
|
+
* blob store, records `{ id, name }`, and resolves the file.
|
|
34
35
|
*
|
|
35
36
|
* @param {Ref} ref
|
|
36
37
|
* @param {Record<string, any>} row
|
|
37
|
-
* @returns {Promise<SingleResult
|
|
38
|
+
* @returns {Promise<SingleResult>}
|
|
38
39
|
*/
|
|
39
40
|
export const put = (ref, row) =>
|
|
40
41
|
ref.name === 'files' ? putFile(ref, row) : ref.handle.store.put(ref.name, row)
|
|
@@ -47,7 +48,7 @@ async function putFile(ref, row) {
|
|
|
47
48
|
const blobId = await handle.blobs.put(data)
|
|
48
49
|
const id = encodeId(handle.blobs.key, blobId, type)
|
|
49
50
|
await handle.store.call('add-file', { id, name })
|
|
50
|
-
return resolveFile(handle, id, name)
|
|
51
|
+
return { data: resolveFile(handle, id, name) }
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
/**
|
|
@@ -186,10 +187,13 @@ function registerBlobCore(handle, id) {
|
|
|
186
187
|
if (!id || !handle.root?._coreKeys) return
|
|
187
188
|
try {
|
|
188
189
|
const { coreKey } = decodeId(id)
|
|
189
|
-
const hex =
|
|
190
|
+
const hex = b4a.toString(coreKey, 'hex')
|
|
190
191
|
if (!handle.root._coreKeys.has(hex)) {
|
|
191
192
|
handle.root._coreKeys.set(hex, handle.root.store.encryptionKey)
|
|
192
193
|
}
|
|
194
|
+
// remember which handle read it, so close prunes the entry (re-registered
|
|
195
|
+
// on the next read if another handle still serves the same core)
|
|
196
|
+
if (handle !== handle.root) (handle._blobKeys ??= new Set()).add(hex)
|
|
193
197
|
} catch {
|
|
194
198
|
// ignore invalid ids
|
|
195
199
|
}
|
|
@@ -239,28 +243,44 @@ export const watch = (ref, q, opts) => {
|
|
|
239
243
|
const owner = ref.handle
|
|
240
244
|
if (ref.kind !== 'handle') {
|
|
241
245
|
const src = owner.store.watch(ref.name, q)
|
|
242
|
-
const out =
|
|
243
|
-
destroy(cb) {
|
|
244
|
-
src.destroy()
|
|
245
|
-
cb(null)
|
|
246
|
-
}
|
|
247
|
-
})
|
|
248
|
-
src.on('data', (res) => out.push(resolveResult(ref, res)))
|
|
249
|
-
src.on('end', () => out.push(null))
|
|
250
|
-
src.on('error', (err) => out.destroy(err))
|
|
246
|
+
const out = snapshotStream(src, (res) => resolveResult(ref, res))
|
|
251
247
|
return bindStream(owner, out, opts)
|
|
252
248
|
}
|
|
253
249
|
const source = parentStore(ref).watch('handles', q)
|
|
250
|
+
const out = snapshotStream(source, (snap) => normalize(snap?.data, ref.name))
|
|
251
|
+
return bindStream(owner, out, opts)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Snapshots are idempotent — under a slow consumer hold only the NEWEST one
|
|
255
|
+
// instead of queueing every intermediate (a busy room + un-drained reader
|
|
256
|
+
// used to buffer full result sets without bound).
|
|
257
|
+
function snapshotStream(src, map) {
|
|
258
|
+
let pending
|
|
259
|
+
let wanted = false
|
|
254
260
|
const out = new Readable({
|
|
261
|
+
read(cb) {
|
|
262
|
+
wanted = true
|
|
263
|
+
flush()
|
|
264
|
+
cb(null)
|
|
265
|
+
},
|
|
255
266
|
destroy(cb) {
|
|
256
|
-
|
|
267
|
+
src.destroy()
|
|
257
268
|
cb(null)
|
|
258
269
|
}
|
|
259
270
|
})
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
271
|
+
const flush = () => {
|
|
272
|
+
if (!wanted || pending === undefined || out.destroyed) return
|
|
273
|
+
const snap = pending
|
|
274
|
+
pending = undefined
|
|
275
|
+
wanted = out.push(snap) !== false
|
|
276
|
+
}
|
|
277
|
+
src.on('data', (res) => {
|
|
278
|
+
pending = map(res)
|
|
279
|
+
flush()
|
|
280
|
+
})
|
|
281
|
+
src.on('end', () => out.push(null))
|
|
282
|
+
src.on('error', (err) => out.destroy(err))
|
|
283
|
+
return out
|
|
264
284
|
}
|
|
265
285
|
|
|
266
286
|
// Universal signal instantiator. Dispatch on the second arg:
|
package/src/local/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { attachRefs } from '../lib/utils.js'
|
|
|
9
9
|
* @typedef {object} LocalOpts
|
|
10
10
|
* @property {any} [root] Pre-existing HypercoreStorage to reuse.
|
|
11
11
|
* @property {any} [store] Pre-existing Corestore to reuse.
|
|
12
|
+
* @property {Uint8Array} [storageKey] 32-byte key encrypting the local store at rest.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -22,7 +23,7 @@ export class Local extends ReadyResource {
|
|
|
22
23
|
* @param {any} spec Built cero spec — must include `spec.local.database` and `spec.meta.local`.
|
|
23
24
|
* @param {LocalOpts} [opts]
|
|
24
25
|
*/
|
|
25
|
-
constructor(dir, spec, { root, store } = {}) {
|
|
26
|
+
constructor(dir, spec, { root, store, storageKey } = {}) {
|
|
26
27
|
super()
|
|
27
28
|
if (!root && !store && (typeof dir !== 'string' || !dir))
|
|
28
29
|
throw CeroError.REQUIRED('dir, root, or store')
|
|
@@ -34,7 +35,8 @@ export class Local extends ReadyResource {
|
|
|
34
35
|
this.store = Storage.bee(dir, {
|
|
35
36
|
spec: { database: spec.local.database, meta: spec.meta.local },
|
|
36
37
|
root,
|
|
37
|
-
store
|
|
38
|
+
store,
|
|
39
|
+
storageKey
|
|
38
40
|
})
|
|
39
41
|
}
|
|
40
42
|
|
package/src/rpc/client.js
CHANGED
|
@@ -152,7 +152,7 @@ const operators = {
|
|
|
152
152
|
async put(name, row) {
|
|
153
153
|
const codec = this._codec()
|
|
154
154
|
const schema = this.schemaOf(name)
|
|
155
|
-
if (
|
|
155
|
+
if (name === 'files') {
|
|
156
156
|
const res = await this.rpc.addFile({
|
|
157
157
|
handle: this.id,
|
|
158
158
|
data: row.data,
|
|
@@ -225,7 +225,11 @@ const operators = {
|
|
|
225
225
|
info?.kind === 'single'
|
|
226
226
|
? codec.decodeRow(schema, res.data)
|
|
227
227
|
: codec.decodeRows(schema, res.data)
|
|
228
|
-
return {
|
|
228
|
+
return {
|
|
229
|
+
data: this._resolveFiles(name, raw),
|
|
230
|
+
total: res.total === -1 ? null : res.total,
|
|
231
|
+
size: res.size
|
|
232
|
+
}
|
|
229
233
|
},
|
|
230
234
|
|
|
231
235
|
/**
|
|
@@ -285,7 +289,11 @@ const operators = {
|
|
|
285
289
|
refInfo.kind === 'single'
|
|
286
290
|
? (codec.decodeRow(schema, snap.data) ?? null)
|
|
287
291
|
: codec.decodeRows(schema, snap.data)
|
|
288
|
-
out.push({
|
|
292
|
+
out.push({
|
|
293
|
+
data: this._resolveFiles(name, raw),
|
|
294
|
+
total: snap.total === -1 ? null : snap.total,
|
|
295
|
+
size: snap.size
|
|
296
|
+
})
|
|
289
297
|
})
|
|
290
298
|
// end exactly once whether the wire ends or the server destroys it (handle close)
|
|
291
299
|
let ended = false
|
|
@@ -320,8 +328,13 @@ const operators = {
|
|
|
320
328
|
* @param {{ role?: string }} [opts]
|
|
321
329
|
* @returns {Promise<string>}
|
|
322
330
|
*/
|
|
323
|
-
async invite({ role } = {}) {
|
|
324
|
-
const { invite } = await this.rpc.invite({
|
|
331
|
+
async invite({ role, expiresIn, multiUse } = {}) {
|
|
332
|
+
const { invite } = await this.rpc.invite({
|
|
333
|
+
handle: this.id,
|
|
334
|
+
role: role || '',
|
|
335
|
+
expiresIn: expiresIn || 0,
|
|
336
|
+
multiUse: multiUse === true
|
|
337
|
+
})
|
|
325
338
|
return invite
|
|
326
339
|
},
|
|
327
340
|
|
package/src/rpc/server.js
CHANGED
|
@@ -88,7 +88,7 @@ export class Server extends RPCServer {
|
|
|
88
88
|
/** Wire the `init` handler that lazily constructs the root cero handle. */
|
|
89
89
|
_wireInit() {
|
|
90
90
|
this.rpc.onInit(async () => {
|
|
91
|
-
if (this.me) throw
|
|
91
|
+
if (this.me) throw CeroError.CONFLICT('already initialized')
|
|
92
92
|
this.me = await cero(this.storage, this.spec, this.opts)
|
|
93
93
|
this.handles.set(this.me.id, this.me)
|
|
94
94
|
await this.me.fileServer.listen()
|
|
@@ -104,7 +104,7 @@ export class Server extends RPCServer {
|
|
|
104
104
|
/** Wire the `restore` handler that rebuilds the local store from a phrase. */
|
|
105
105
|
_wireRestore() {
|
|
106
106
|
this.rpc.onRestore(async ({ phrase }) => {
|
|
107
|
-
if (!this.me) throw
|
|
107
|
+
if (!this.me) throw CeroError.NOT_READY('Server', 'server')
|
|
108
108
|
this.me = await restore(this.me, phrase)
|
|
109
109
|
this.handles = new Map([[this.me.id, this.me]])
|
|
110
110
|
return this._identity()
|
|
@@ -144,7 +144,8 @@ export class Server extends RPCServer {
|
|
|
144
144
|
r.kind === 'single'
|
|
145
145
|
? codec.encodeRow(r.schema, result.data)
|
|
146
146
|
: codec.encodeRows(r.schema, result.data)
|
|
147
|
-
|
|
147
|
+
// total is int on the wire: -1 encodes "skipped" (null), see T2.1 lazy total
|
|
148
|
+
return { data, total: result.total ?? -1, size: result.size ?? 0 }
|
|
148
149
|
})
|
|
149
150
|
|
|
150
151
|
this.rpc.onGetOne(async ({ handle, ref, id, local }) => {
|
|
@@ -179,12 +180,30 @@ export class Server extends RPCServer {
|
|
|
179
180
|
stream.destroy()
|
|
180
181
|
return
|
|
181
182
|
}
|
|
183
|
+
// keep-latest under IPC backpressure: snapshots are idempotent, so an
|
|
184
|
+
// un-drained wire holds one pending snapshot (newest wins) instead of
|
|
185
|
+
// queueing every intermediate result set — encode only what ships
|
|
186
|
+
let pending = null
|
|
187
|
+
let blocked = false
|
|
182
188
|
const onData = (snap) => {
|
|
189
|
+
if (blocked) {
|
|
190
|
+
pending = snap
|
|
191
|
+
return
|
|
192
|
+
}
|
|
183
193
|
const data =
|
|
184
194
|
r.kind === 'single'
|
|
185
195
|
? codec.encodeRow(r.schema, snap.data)
|
|
186
196
|
: codec.encodeRows(r.schema, snap.data)
|
|
187
|
-
stream.write({ data, total: snap.total ??
|
|
197
|
+
blocked = stream.write({ data, total: snap.total ?? -1, size: snap.size ?? 0 }) === false
|
|
198
|
+
if (blocked) {
|
|
199
|
+
stream.once('drain', () => {
|
|
200
|
+
blocked = false
|
|
201
|
+
if (pending === null) return
|
|
202
|
+
const next = pending
|
|
203
|
+
pending = null
|
|
204
|
+
onData(next)
|
|
205
|
+
})
|
|
206
|
+
}
|
|
188
207
|
}
|
|
189
208
|
let set = this._watchStreams.get(handle)
|
|
190
209
|
if (!set) this._watchStreams.set(handle, (set = new Set()))
|
|
@@ -208,9 +227,13 @@ export class Server extends RPCServer {
|
|
|
208
227
|
|
|
209
228
|
/** Register invite/revoke/join RPC handlers. */
|
|
210
229
|
_wirePairing() {
|
|
211
|
-
this.rpc.onInvite(async ({ handle, role }) => {
|
|
230
|
+
this.rpc.onInvite(async ({ handle, role, expiresIn, multiUse }) => {
|
|
212
231
|
const h = this._resolve(handle)
|
|
213
|
-
const invite = await h.invite({
|
|
232
|
+
const invite = await h.invite({
|
|
233
|
+
role: role || undefined,
|
|
234
|
+
expiresIn: expiresIn || undefined,
|
|
235
|
+
multiUse: multiUse === true
|
|
236
|
+
})
|
|
214
237
|
return { invite }
|
|
215
238
|
})
|
|
216
239
|
|
|
@@ -328,7 +351,7 @@ export class Server extends RPCServer {
|
|
|
328
351
|
/** Wire the on-demand `seed` handler — surfaces the recovery phrase only when asked. */
|
|
329
352
|
_wireSeed() {
|
|
330
353
|
this.rpc.onSeed(async () => {
|
|
331
|
-
if (!this.me) throw
|
|
354
|
+
if (!this.me) throw CeroError.NOT_READY('Server', 'server')
|
|
332
355
|
return { phrase: this.me.identity.toPhrase() }
|
|
333
356
|
})
|
|
334
357
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `me.bluetooth` — the whole app-facing surface for nearby (Bluetooth) sync.
|
|
3
|
+
* Bluetooth is a cero feature, not a parallel API: it only changes how peers
|
|
4
|
+
* meet and carry bytes; capability-gated replication still decides what syncs.
|
|
5
|
+
*
|
|
6
|
+
* ```js
|
|
7
|
+
* const me = await cero(dir, spec, { channel, bluetooth: true })
|
|
8
|
+
* me.bluetooth.state // 'unsupported' | 'unauthorized' | 'off' | 'waiting' | 'on'
|
|
9
|
+
* await me.bluetooth.start()
|
|
10
|
+
* me.bluetooth.peers // Map of live BLE links
|
|
11
|
+
* me.bluetooth.on('update', () => {})
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* @extends ReadyResource
|
|
15
|
+
*/
|
|
16
|
+
export class Bluetooth extends ReadyResource {
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} handle Root cero Handle (network + identity + channel).
|
|
19
|
+
* @param {object} [opts]
|
|
20
|
+
* @param {any} [opts.backend] Injected bare-bluetooth-shaped backend (tests); lazy-loaded when absent.
|
|
21
|
+
* @param {boolean} [opts.autoStart] Start on handle open (from `cero({ bluetooth: true })`).
|
|
22
|
+
*/
|
|
23
|
+
constructor(handle: object, { backend, autoStart }?: {
|
|
24
|
+
backend?: any;
|
|
25
|
+
autoStart?: boolean;
|
|
26
|
+
});
|
|
27
|
+
_handle: any;
|
|
28
|
+
_backend: any;
|
|
29
|
+
_autoStart: boolean;
|
|
30
|
+
_transport: BluetoothTransport;
|
|
31
|
+
_name: any;
|
|
32
|
+
_announces: Set<any>;
|
|
33
|
+
/** @type {'unsupported'|'unauthorized'|'off'|'waiting'|'starting'|'on'} */
|
|
34
|
+
state: "unsupported" | "unauthorized" | "off" | "waiting" | "starting" | "on";
|
|
35
|
+
/**
|
|
36
|
+
* Offline join rendezvous. Both sides derive the same BLE service UUID from
|
|
37
|
+
* the invite, so they find each other with zero DHT: the host calls this
|
|
38
|
+
* while the invite QR is on screen; the joiner's `open(me.room, invite)`
|
|
39
|
+
* calls it automatically for the duration of the join. Returns a stop
|
|
40
|
+
* function — closing the QR must stop the advertisement so a photographed
|
|
41
|
+
* invite doesn't stay an ambient admission ticket. Auto-stops at the
|
|
42
|
+
* invite's expiry, on `bluetooth.stop()`, and on close.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} invite Z32 invite string.
|
|
45
|
+
* @returns {() => void}
|
|
46
|
+
*/
|
|
47
|
+
announce(invite: string): () => void;
|
|
48
|
+
/** @returns {Map<string, any>} Live BLE links, keyed by peer node id. */
|
|
49
|
+
get peers(): Map<string, any>;
|
|
50
|
+
/**
|
|
51
|
+
* Begin advertising + scanning on the channel-derived UUID. Idempotent.
|
|
52
|
+
* No-op (stays `unsupported`) when no backend is present.
|
|
53
|
+
*
|
|
54
|
+
* @returns {Promise<void>}
|
|
55
|
+
*/
|
|
56
|
+
start({ name }?: {}): Promise<void>;
|
|
57
|
+
/**
|
|
58
|
+
* Stop advertising/scanning and drop links. Idempotent. Sync stops; local
|
|
59
|
+
* data and the rest of the network (DHT) are untouched.
|
|
60
|
+
*
|
|
61
|
+
* @returns {Promise<void>}
|
|
62
|
+
*/
|
|
63
|
+
stop(): Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
import ReadyResource from 'ready-resource';
|
|
66
|
+
import { BluetoothTransport } from '@cero-base/core/network/bluetooth';
|
package/types/build/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
export function build(specDir: any, schema: any, { ns }?: {
|
|
1
|
+
export function build(specDir: any, schema: any, { ns, extensions }?: {
|
|
2
2
|
ns?: string;
|
|
3
|
+
extensions?: boolean;
|
|
3
4
|
}): Promise<void>;
|
|
4
5
|
export { getHyperdbType } from "./builtins.js";
|
|
5
6
|
export type Schema = import("@cero-base/core/schema").Schema;
|
package/types/build/schemas.d.ts
CHANGED
|
@@ -118,6 +118,8 @@ export const rpc: {
|
|
|
118
118
|
'req-invite': {
|
|
119
119
|
handle: import("@cero-base/core").Prim;
|
|
120
120
|
role: import("@cero-base/core").Prim;
|
|
121
|
+
expiresIn: import("@cero-base/core").Prim;
|
|
122
|
+
multiUse: import("@cero-base/core").Prim;
|
|
121
123
|
};
|
|
122
124
|
'req-revoke': {
|
|
123
125
|
handle: import("@cero-base/core").Prim;
|