@cero-base/cero 2.1.8 → 2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cero-base/cero",
3
- "version": "2.1.8",
3
+ "version": "2.2.0",
4
4
  "description": "The ideal p2p API — everything is a handle, handles contain refs, refs contain rows.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -99,8 +99,8 @@
99
99
  "test:node": "find test -name '*.test.js' | sort | xargs -P4 -n1 brittle-node"
100
100
  },
101
101
  "dependencies": {
102
- "@cero-base/core": "^2.1.8",
103
- "b4a": "^1.8.1",
102
+ "@cero-base/core": "^2.2.0",
103
+ "b4a": "^1.9.0",
104
104
  "bare-abort-controller": "^1.1.2",
105
105
  "bare-crypto": "^1.15.3",
106
106
  "bare-fs": "^4.8.1",
@@ -112,10 +112,10 @@
112
112
  "hypercore": "^11.36.1",
113
113
  "hypercore-crypto": "^3.7.0",
114
114
  "hypercore-id-encoding": "^1.3.0",
115
- "hypercore-storage": "^3.2.1",
115
+ "hypercore-storage": "^3.3.1",
116
116
  "hyperdb": "^6.9.0",
117
117
  "hyperdispatch": "^1.6.0",
118
- "hyperschema": "^1.26.1",
118
+ "hyperschema": "^1.26.2",
119
119
  "ready-resource": "^1.2.0",
120
120
  "safety-catch": "^1.0.3",
121
121
  "streamx": "^2.28.1",
@@ -125,7 +125,7 @@
125
125
  "devDependencies": {
126
126
  "@hyperswarm/testnet": "^3.1.4",
127
127
  "bare-events": "^2.9.2",
128
- "bare-fetch": "^3.3.0",
128
+ "bare-fetch": "^3.4.0",
129
129
  "bare-process": "^4.5.1",
130
130
  "bare-url": "^2.5.4",
131
131
  "brittle": "^4.2.0",
@@ -111,7 +111,8 @@ const COMMANDS = [
111
111
  ['rotate', 'req-handle', 'res-epoch'],
112
112
  ['set-active', 'req-set-active', 'res-ok'],
113
113
  ['suspend', 'req-empty', 'res-ok'],
114
- ['resume', 'req-empty', 'res-ok']
114
+ ['resume', 'req-empty', 'res-ok'],
115
+ ['errors', 'req-empty', 'res-error', true]
115
116
  ]
116
117
 
117
118
  export function commands(ns) {
@@ -203,5 +203,10 @@ export const rpc = {
203
203
  },
204
204
  'res-epoch': {
205
205
  epoch: required(uint)
206
+ },
207
+ 'res-error': {
208
+ message: required(string),
209
+ code: string,
210
+ stack: string
206
211
  }
207
212
  }
@@ -116,7 +116,7 @@ export class Handle extends ReadyResource {
116
116
  this._opts = opts.opts || {}
117
117
  this.extensions = parent?.extensions || extensionsOf(spec, this._opts.extensions)
118
118
  this.operators = parent?.operators || operatorsOf(spec, this._opts.operators)
119
- this._onerror = this._opts.onerror || safetyCatch
119
+ this._onerror = this._opts.onerror || ((err) => console.error(err))
120
120
  this.children = parent ? null : new Set()
121
121
  this._typeHooks = parent ? null : new Set()
122
122
  this._loading = parent ? null : new Map()
@@ -260,36 +260,40 @@ export class Handle extends ReadyResource {
260
260
  for (const hex of this._blobKeys || []) this.root._coreKeys.delete(hex)
261
261
  for (const r of [...this._owned]) r.destroy?.()
262
262
  this._owned.clear()
263
- if (this._blobs) await this._blobs.close()
264
- for (const b of this._epochBlobs?.values() || []) await b.close().catch(safetyCatch)
265
- this._epochBlobs = null
266
- if (this.children) {
267
- for (const c of [...this.children]) await c.close()
268
- this.children.clear()
269
- }
270
263
  if (this._invitesSync) {
271
264
  this.store.off('update', this._invitesSync)
272
265
  this._invitesSync = null
273
266
  }
274
- if (this.pair) await this.pair.close()
267
+ if (this._discovery) this._discovery.destroy().catch(safetyCatch)
268
+
269
+ const steps = [
270
+ () => this._blobs?.close(),
271
+ ...[...(this._epochBlobs?.values() || [])].map((b) => () => b.close()),
272
+ ...[...(this.children || [])].map((c) => () => c.close()),
273
+ () => this.pair?.close()
274
+ ]
275
+ this._epochBlobs = null
276
+ this.children?.clear()
275
277
 
276
278
  if (this.parent) {
277
279
  this.parent.children?.delete(this)
278
- await this.store.close()
279
- return
280
- }
281
-
282
- if (this.local) await this.local.close()
283
- if (this._fileServer) {
284
- await this._fileServer.close()
285
- this._fileServer = null
280
+ steps.push(() => this.store.close())
281
+ } else {
282
+ const store = this.store.store
283
+ steps.push(
284
+ () => this.bluetooth?.close(),
285
+ () => this.local?.close(),
286
+ async () => {
287
+ await this._fileServer?.close()
288
+ this._fileServer = null
289
+ },
290
+ () => this.store.close(),
291
+ () => this.network.close(),
292
+ () => store.close(),
293
+ () => this._storage?.close()
294
+ )
286
295
  }
287
- const store = this.store.store
288
- await this.store.close()
289
- if (this._discovery) this._discovery.destroy().catch(safetyCatch)
290
- await this.network.close()
291
- await store.close()
292
- if (this._storage) await this._storage.close()
296
+ await settle(steps)
293
297
  }
294
298
 
295
299
  /**
@@ -862,26 +866,18 @@ export class Handle extends ReadyResource {
862
866
 
863
867
  async _suspend() {
864
868
  if (this.closing || this.closed) return
865
- await Promise.all([...this.children].map((c) => c.pair?.suspend()))
866
- await this.bluetooth?.suspend()
867
- await this.network.suspend()
868
- try {
869
- await this.store.store.suspend()
870
- } catch (err) {
871
- this._onerror(err)
872
- }
869
+ await Promise.all([...this.children].map((c) => c.pair?.suspend().catch(this._onerror)))
870
+ await this.bluetooth?.suspend().catch(this._onerror)
871
+ await this.network.suspend().catch(this._onerror)
872
+ await this.store.store.suspend().catch(this._onerror)
873
873
  }
874
874
 
875
875
  async _resume() {
876
876
  if (this.closing || this.closed) return
877
- try {
878
- await this.store.store.resume()
879
- } catch (err) {
880
- this._onerror(err)
881
- }
882
- await this.network.resume()
883
- await this.bluetooth?.resume()
884
- await Promise.all([...this.children].map((child) => child.pair?.resume()))
877
+ await this.store.store.resume().catch(this._onerror)
878
+ await this.network.resume().catch(this._onerror)
879
+ await this.bluetooth?.resume().catch(this._onerror)
880
+ await Promise.all([...this.children].map((c) => c.pair?.resume().catch(this._onerror)))
885
881
  }
886
882
 
887
883
  // a child is a child once it has its operators, the hooks declared for its type, and a slot
@@ -1019,3 +1015,16 @@ function pickHandle(spec, type) {
1019
1015
  function randomNs() {
1020
1016
  return z32.encode(Identity.randomBytes(8))
1021
1017
  }
1018
+
1019
+ // run every teardown step, then surface the first failure: a bug must not leak the storage lock
1020
+ async function settle(steps) {
1021
+ let failed = null
1022
+ for (const step of steps) {
1023
+ try {
1024
+ await step()
1025
+ } catch (err) {
1026
+ failed ??= err
1027
+ }
1028
+ }
1029
+ if (failed) throw failed
1030
+ }
package/src/index.js CHANGED
@@ -67,7 +67,7 @@ export { t, schema } from './lib/spec.js'
67
67
  * @property {Uint8Array} [key] Existing database key to recover into, skipping the pointer lookup.
68
68
  * @property {Uint8Array} [encryptionKey] Pre-existing encryption key.
69
69
  * @property {Record<string, Function>} [routes] Custom RPC routes for the database dispatcher.
70
- * @property {(err: any) => void} [onerror] Background-task error handler.
70
+ * @property {(err: any) => void} [onerror] Background-task error handler; without one, errors emit 'error' on the root handle.
71
71
  * @property {number} [recoveryTimeout] Max wait to find another device and be admitted, in ms. Defaults to 30000.
72
72
  * @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.
73
73
  * @property {import('./extensions/index.js').Extension[]} [extensions] The extensions this instance runs, instead of the ones the spec carries. Build with the same list.
@@ -93,12 +93,23 @@ export async function cero(dir, spec, opts = {}) {
93
93
  let network = null
94
94
  let discovery = null
95
95
  let me = null
96
+ // background failures reach the app through opts.onerror or the root's 'error' event, never silence
97
+ const onerror =
98
+ opts.onerror ||
99
+ ((err) => (me?.listenerCount('error') ? me.emit('error', err) : console.error(err)))
100
+ opts = { ...opts, onerror }
96
101
  // any failure during open must close what opened, or the storage lock leaks
97
102
  try {
98
103
  await storage.ready()
99
104
  await fs.promises.chmod(`${dir}/main`, 0o700)
100
105
  store = new Corestore(storage, { manifestVersion: 2 })
101
106
  await store.ready()
107
+ store.watch((core) => {
108
+ const session = new Hypercore({ core, weak: true })
109
+ session.on('verification-error', onerror)
110
+ session.on('invalid-request', onerror)
111
+ session.on('conflict', () => onerror(CeroError.CONFLICT(`fork on core ${session.id}`)))
112
+ })
102
113
 
103
114
  if (spec.local && spec.meta?.local) {
104
115
  local = new Local(null, spec, { store, storageKey: opts.storageKey })
@@ -126,7 +137,8 @@ export async function cero(dir, spec, opts = {}) {
126
137
  channel: opts.channel,
127
138
  store,
128
139
  mirrors: opts.mirrors,
129
- presence: opts.presence
140
+ presence: opts.presence,
141
+ onerror
130
142
  })
131
143
  await network.ready()
132
144
  discovery = network.join(identity.topic)
@@ -200,7 +212,6 @@ export async function cero(dir, spec, opts = {}) {
200
212
  maxInbound: bt.maxInbound,
201
213
  pipe: bt.pipe
202
214
  })
203
- me.once('close', () => me.bluetooth.close().catch(safetyCatch))
204
215
  await me.bluetooth.ready()
205
216
  }
206
217
 
package/src/rpc/client.js CHANGED
@@ -457,6 +457,20 @@ export class Client extends RPCClient {
457
457
  this.local = null
458
458
  }
459
459
 
460
+ // the worker's background errors, delivered the way a local root delivers them
461
+ _pumpErrors() {
462
+ const report = (err) =>
463
+ this.listenerCount('error') ? this.emit('error', err) : console.error(err)
464
+ const pump = async () => {
465
+ for await (const { message, code, stack } of this.rpc.errors({})) {
466
+ report(Object.assign(new Error(message), code && { code }, stack && { stack }))
467
+ }
468
+ }
469
+ pump().catch((err) => {
470
+ if (err.code !== 'PREMATURE_CLOSE' && err.code !== 'CHANNEL_CLOSED') report(err)
471
+ })
472
+ }
473
+
460
474
  async _open() {
461
475
  await super._open()
462
476
  const { id, deviceId, fileBase, fileToken } = await this.rpc.init({})
@@ -468,6 +482,7 @@ export class Client extends RPCClient {
468
482
  Ref.attach(this, /** @type {Spec} */ (this.spec).meta.refs)
469
483
  bind(this, null, this.operators)
470
484
  if (/** @type {Spec} */ (this.spec).meta.local?.refs) this.local = new LocalRefs(this)
485
+ this._pumpErrors()
471
486
  }
472
487
 
473
488
  /** Pause networking and storage on the server. Idempotent. */
package/src/rpc/server.js CHANGED
@@ -43,7 +43,10 @@ export class Server extends RPCServer {
43
43
  super(ipc, spec)
44
44
  if (spec.local?.schema && !spec.local.codec) bindCodec(spec.local)
45
45
  this.storage = storage
46
- this.opts = opts
46
+ this._report = opts.onerror || ((err) => console.error(err))
47
+ this.opts = { ...opts, onerror: (err) => this._onerror(err) }
48
+ /** @type {Set<object>} open error streams, one per connected client */
49
+ this._errors = new Set()
47
50
  this.me = null
48
51
  this.handles = new Map()
49
52
  /** @type {Map<string, Set<object>>} handle id → its open watch streams */
@@ -87,7 +90,22 @@ export class Server extends RPCServer {
87
90
  }
88
91
 
89
92
  /** Wire the `init` handler that lazily constructs the root cero handle. */
93
+ _onerror(err) {
94
+ if (!this._errors.size) return this._report(err)
95
+ const frame = {
96
+ message: err?.message || String(err),
97
+ code: err?.code || '',
98
+ stack: err?.stack || ''
99
+ }
100
+ for (const stream of this._errors) stream.write(frame)
101
+ }
102
+
90
103
  _wireInit() {
104
+ this.rpc.onErrors((stream) => {
105
+ this._errors.add(stream)
106
+ stream.on('error', safetyCatch)
107
+ stream.on('close', () => this._errors.delete(stream))
108
+ })
91
109
  this.rpc.onInit(async () => {
92
110
  // a reloaded UI is a new client on the same worker: it re-attaches, its old streams end
93
111
  if (this.me) {
@@ -196,4 +196,9 @@ export declare const rpc: {
196
196
  'res-epoch': {
197
197
  epoch: import("@cero-base/core").Prim;
198
198
  };
199
+ 'res-error': {
200
+ message: import("@cero-base/core").Prim;
201
+ code: import("@cero-base/core").Prim;
202
+ stack: import("@cero-base/core").Prim;
203
+ };
199
204
  };
package/types/index.d.ts CHANGED
@@ -74,7 +74,7 @@ export type CeroOpts = {
74
74
  */
75
75
  routes?: Record<string, Function>;
76
76
  /**
77
- * Background-task error handler.
77
+ * Background-task error handler; without one, errors emit 'error' on the root handle.
78
78
  */
79
79
  onerror?: (err: any) => void;
80
80
  /**
@@ -123,7 +123,7 @@ export type CeroOpts = {
123
123
  * @property {Uint8Array} [key] Existing database key to recover into, skipping the pointer lookup.
124
124
  * @property {Uint8Array} [encryptionKey] Pre-existing encryption key.
125
125
  * @property {Record<string, Function>} [routes] Custom RPC routes for the database dispatcher.
126
- * @property {(err: any) => void} [onerror] Background-task error handler.
126
+ * @property {(err: any) => void} [onerror] Background-task error handler; without one, errors emit 'error' on the root handle.
127
127
  * @property {number} [recoveryTimeout] Max wait to find another device and be admitted, in ms. Defaults to 30000.
128
128
  * @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.
129
129
  * @property {import('./extensions/index.js').Extension[]} [extensions] The extensions this instance runs, instead of the ones the spec carries. Build with the same list.
@@ -89,6 +89,7 @@ export declare class Client extends RPCClient {
89
89
  constructor(ipc: any, spec: Spec, opts?: {
90
90
  operators?: Record<string, any>;
91
91
  });
92
+ _pumpErrors(): void;
92
93
  _open(): Promise<void>;
93
94
  /** Pause networking and storage on the server. Idempotent. */
94
95
  suspend(): Promise<void>;
@@ -63,6 +63,7 @@ export type GetResult = {
63
63
  */
64
64
  export declare class Server extends RPCServer {
65
65
  storage: string;
66
+ _report: (err: Error) => void;
66
67
  opts: {
67
68
  name?: string;
68
69
  bootstrap?: Array<{
@@ -70,8 +71,10 @@ export declare class Server extends RPCServer {
70
71
  port: number;
71
72
  }>;
72
73
  isMobile?: boolean;
73
- onerror?: (err: Error) => void;
74
+ onerror: (err: any) => void;
74
75
  };
76
+ /** @type {Set<object>} open error streams, one per connected client */
77
+ _errors: Set<object>;
75
78
  me: import("../handle/index.js").CeroHandle;
76
79
  handles: Map<any, any>;
77
80
  /** @type {Map<string, Set<object>>} handle id → its open watch streams */
@@ -98,6 +101,7 @@ export declare class Server extends RPCServer {
98
101
  /** End every watch stream bound to a handle (e.g. when it closes or leaves). */
99
102
  _endWatches(handle: any): void;
100
103
  /** Wire the `init` handler that lazily constructs the root cero handle. */
104
+ _onerror(err: any): void;
101
105
  _wireInit(): void;
102
106
  /** Wire the `restore` handler that rebuilds the local store from a phrase. */
103
107
  _wireRestore(): void;