@cero-base/core 0.8.9 → 1.0.1

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,8 +1,17 @@
1
1
  {
2
2
  "name": "@cero-base/core",
3
- "version": "0.8.9",
3
+ "version": "1.0.1",
4
4
  "description": "cero p2p primitives — identity, storage, network, database, blobs, rpc, pairing.",
5
5
  "type": "module",
6
+ "sideEffects": false,
7
+ "engines": {
8
+ "node": ">=20"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/lekinox/cero-base.git",
13
+ "directory": "packages/core"
14
+ },
6
15
  "main": "./src/index.js",
7
16
  "types": "./types/index.d.ts",
8
17
  "files": [
@@ -39,6 +48,12 @@
39
48
  "types": "./types/blobs/index.d.ts",
40
49
  "default": "./src/blobs/index.js"
41
50
  },
51
+ "./blobs/codec": {
52
+ "default": "./src/blobs/codec.js"
53
+ },
54
+ "./blobs/server": {
55
+ "default": "./src/blobs/server.js"
56
+ },
42
57
  "./rpc": {
43
58
  "types": "./types/rpc/index.d.ts",
44
59
  "default": "./src/rpc/index.js"
@@ -106,22 +121,23 @@
106
121
  "test": "brittle-node test/*.test.js"
107
122
  },
108
123
  "dependencies": {
109
- "autobee": "^1.0.7",
124
+ "autobee": "^1.0.9",
110
125
  "b4a": "^1.8.1",
111
- "bare-crypto": "^1.14.1",
126
+ "bare-crypto": "^1.15.3",
112
127
  "bare-fs": "^4.7.2",
113
128
  "bare-path": "^3.0.1",
114
129
  "bip39-mnemonic": "^2.5.0",
115
130
  "blind-pairing": "^2.3.1",
116
- "compact-encoding": "^3.1.0",
117
- "corestore": "^7.10.0",
131
+ "compact-encoding": "^3.2.0",
132
+ "corestore": "^7.10.1",
118
133
  "framed-stream": "^1.0.1",
119
134
  "hrpc": "^4.3.0",
120
- "hyperblobs": "^2.12.0",
135
+ "hyperblobs": "^2.12.1",
136
+ "hypercore-blob-server": "^1.12.0",
121
137
  "hypercore": "^11.33.1",
122
138
  "hypercore-crypto": "^3.7.0",
123
139
  "hypercore-id-encoding": "^1.3.0",
124
- "hypercore-storage": "^3.0.2",
140
+ "hypercore-storage": "^3.1.1",
125
141
  "hyperdb": "^6.7.0",
126
142
  "hyperdispatch": "^1.6.0",
127
143
  "hyperschema": "^1.21.0",
@@ -131,12 +147,12 @@
131
147
  "ready-resource": "^1.2.0",
132
148
  "safety-catch": "^1.0.3",
133
149
  "sodium-universal": "^5.0.1",
134
- "streamx": "^2.26.0",
150
+ "streamx": "^2.28.0",
135
151
  "z32": "^1.1.0"
136
152
  },
137
153
  "devDependencies": {
138
154
  "@hyperswarm/testnet": "^3.1.4",
139
- "brittle": "^4.0.0",
155
+ "brittle": "^4.0.2",
140
156
  "typescript": "^5.9.3"
141
157
  },
142
158
  "license": "Apache-2.0"
@@ -0,0 +1,52 @@
1
+ import c from 'compact-encoding'
2
+ import z32 from 'z32'
3
+
4
+ import { getEncoding } from '../lib/spec/index.js'
5
+ import { CeroError } from '../lib/errors.js'
6
+
7
+ /**
8
+ * @typedef {object} RawBlobId
9
+ * @property {number} blockOffset
10
+ * @property {number} blockLength
11
+ * @property {number} byteOffset
12
+ * @property {number} byteLength
13
+ */
14
+
15
+ const BlobId = getEncoding('@cero/blob-id')
16
+
17
+ /**
18
+ * Encode a coreKey + raw hyperblobs blobId + type into a durable string id.
19
+ *
20
+ * @param {Uint8Array} coreKey
21
+ * @param {RawBlobId} blobId
22
+ * @param {string} type
23
+ * @returns {string}
24
+ */
25
+ export function encodeId(coreKey, blobId, type) {
26
+ return z32.encode(c.encode(BlobId, { coreKey, ...blobId, type }))
27
+ }
28
+
29
+ /**
30
+ * Decode a string id back into its coreKey, raw blobId and type.
31
+ *
32
+ * @param {string} id
33
+ * @returns {{ coreKey: Uint8Array, blobId: RawBlobId, type: string }}
34
+ */
35
+ export function decodeId(id) {
36
+ if (typeof id !== 'string' || id.length === 0)
37
+ throw CeroError.INVALID('id must be a non-empty string')
38
+ let buf
39
+ try {
40
+ buf = z32.decode(id)
41
+ } catch {
42
+ throw CeroError.INVALID('id must be valid z32')
43
+ }
44
+ let v
45
+ try {
46
+ v = c.decode(BlobId, buf)
47
+ } catch {
48
+ throw CeroError.INVALID('id is malformed')
49
+ }
50
+ const { coreKey, blockOffset, blockLength, byteOffset, byteLength, type } = v
51
+ return { coreKey, blobId: { blockOffset, blockLength, byteOffset, byteLength }, type }
52
+ }
@@ -1,41 +1,26 @@
1
1
  import Hyperblobs from 'hyperblobs'
2
2
  import ReadyResource from 'ready-resource'
3
- import sodium from 'sodium-universal'
4
3
  import b4a from 'b4a'
5
- import z32 from 'z32'
6
4
 
7
5
  import { NAMESPACE } from '../lib/constants.js'
8
6
  import { toId } from '../lib/utils.js'
9
7
  import { CeroError } from '../lib/errors.js'
8
+ import { encodeId, decodeId } from './codec.js'
10
9
 
11
10
  const NS = `${NAMESPACE}/blobs`
12
11
 
13
12
  /**
14
13
  * @typedef {object} BlobsOpts
15
- * @property {any} store Corestore (or compatible) used to host the blob core.
14
+ * @property {object} store Corestore (or compatible) used to host the blob core.
16
15
  * @property {import('../identity/index.js').Identity} [identity] Identity supplying the default encryption key.
17
16
  * @property {import('../network/index.js').Network} [network] Optional network used to announce + replicate the core.
18
17
  * @property {Uint8Array} [key] Pre-existing blob core key — joins an existing blob feed.
19
18
  * @property {Uint8Array} [encryptionKey] Explicit encryption key, overrides `identity.encryptionKey`.
20
19
  *
21
- * @typedef {object} BlobEntry
22
- * @property {string} id Opaque z32 id used by `get`/`info`/`delete`.
23
- * @property {string} hash Content hash (z32-encoded blake2b of the bytes).
24
- * @property {number} size Logical byte length recorded at put-time.
25
- * @property {any} meta Caller-provided metadata, stored verbatim.
26
- * @property {number} createdAt Wall-clock timestamp of the put.
27
- *
28
- * @typedef {object} PutOpts
29
- * @property {any} [meta] Arbitrary metadata persisted alongside the blob.
30
- * @property {number} [size] Override the recorded size (defaults to byte length).
20
+ * @typedef {import('./codec.js').RawBlobId} RawBlobId
31
21
  */
32
22
 
33
- /**
34
- * Content-addressable blob store backed by a single Hyperblobs core.
35
- * Writes dedupe locally by content hash, reads/streams are positional
36
- * descriptors, and the underlying core is attached to the supplied
37
- * network for replication.
38
- */
23
+ /** Thin wrapper over a single Hyperblobs core; deals only in raw blobIds. */
39
24
  export class Blobs extends ReadyResource {
40
25
  /** @param {BlobsOpts} [opts] */
41
26
  constructor({ store, identity, network, key, encryptionKey } = {}) {
@@ -51,12 +36,7 @@ export class Blobs extends ReadyResource {
51
36
  this._coreKey = key || null
52
37
  this.core = null
53
38
  this.hyperblobs = null
54
-
55
39
  this._discovery = null
56
- /** @type {Map<string, BlobEntry>} contentHash → entry */
57
- this._index = new Map()
58
- /** @type {Map<string, BlobEntry>} id → entry (so info/has/delete by id work) */
59
- this._byId = new Map()
60
40
  }
61
41
 
62
42
  /**
@@ -94,7 +74,8 @@ export class Blobs extends ReadyResource {
94
74
  if (this._coreKey) opts.key = this._coreKey
95
75
  else opts.name = 'blobs'
96
76
 
97
- this.core = this.store.namespace(NS).get(opts)
77
+ const ns = this.store.namespace(NS)
78
+ this.core = ns.get(opts)
98
79
  await this.core.ready()
99
80
 
100
81
  this.hyperblobs = new Hyperblobs(this.core)
@@ -114,130 +95,67 @@ export class Blobs extends ReadyResource {
114
95
  }
115
96
 
116
97
  /**
117
- * Store a buffer or readable stream, returning its opaque id. Identical
118
- * content from the same writer is deduplicated locally.
98
+ * Store a buffer or readable stream, returning its raw hyperblobs blobId.
119
99
  *
120
100
  * @param {Uint8Array | import('streamx').Readable} input
121
- * @param {PutOpts} [opts]
122
- * @returns {Promise<string>}
101
+ * @returns {Promise<RawBlobId>}
123
102
  */
124
- async put(input, { meta = null, size = null } = {}) {
103
+ async put(input) {
125
104
  this._guard()
126
105
  if (input == null) throw CeroError.REQUIRED('input')
127
-
128
- let bytes
129
- if (isBufferLike(input)) {
130
- bytes = b4a.from(input)
131
- } else if (isReadable(input)) {
132
- bytes = await readAll(input)
133
- } else {
134
- throw CeroError.INVALID('input must be a buffer or a Readable stream')
135
- }
136
-
137
- const hashBuf = contentHash(bytes)
138
- const hash = toId(hashBuf)
139
-
140
- // Local dedup: same writer putting same bytes returns the existing id.
141
- const existing = this._index.get(hash)
142
- if (existing) return existing.id
143
-
144
- const raw = await this.hyperblobs.put(bytes)
145
- const id = encodeId(raw)
146
- const entry = {
147
- id,
148
- hash,
149
- size: size ?? bytes.byteLength,
150
- meta,
151
- createdAt: Date.now()
152
- }
153
- this._index.set(hash, entry)
154
- this._byId.set(id, entry)
155
- return id
106
+ if (b4a.isBuffer(input)) return this.hyperblobs.put(b4a.from(input))
107
+ if (isReadable(input)) return this._putStream(input)
108
+ throw CeroError.INVALID('input must be a buffer or a Readable stream')
156
109
  }
157
110
 
158
111
  /**
159
- * Delete the blob with the given id and forget its local index entry.
160
- *
161
- * @param {string} id
162
- * @returns {Promise<void>}
112
+ * @param {import('streamx').Readable} input
113
+ * @returns {Promise<RawBlobId>}
163
114
  */
164
- async delete(id) {
165
- this._guard()
166
- const raw = decodeId(id)
167
- await this.hyperblobs.clear(raw)
168
- const entry = this._byId.get(id)
169
- if (entry) {
170
- this._byId.delete(id)
171
- this._index.delete(entry.hash)
172
- }
115
+ async _putStream(input) {
116
+ const ws = this.hyperblobs.createWriteStream()
117
+ await new Promise((resolve, reject) => {
118
+ input.on('error', reject)
119
+ ws.on('error', reject)
120
+ ws.on('close', resolve)
121
+ input.pipe(ws)
122
+ })
123
+ return ws.id
173
124
  }
174
125
 
175
126
  /**
176
- * Resolve the blob bytes for an id. Fetches from peers if not local.
127
+ * Resolve the blob bytes for a raw blobId. Fetches from peers if not local.
177
128
  *
178
- * @param {string} id
179
- * @param {any} [opts]
129
+ * @param {RawBlobId} blobId
130
+ * @param {object} [opts]
180
131
  * @returns {Promise<Uint8Array>}
181
132
  */
182
- async get(id, opts) {
133
+ async get(blobId, opts) {
183
134
  this._guard()
184
- const raw = decodeId(id)
185
- return this.hyperblobs.get(raw, opts)
135
+ return this.hyperblobs.get(blobId, opts)
186
136
  }
187
137
 
188
138
  /**
189
- * Streaming counterpart of `get` — returns a Readable over the blob bytes.
139
+ * Streaming counterpart of `get` — a Readable over the blob bytes.
190
140
  *
191
- * @param {string} id
192
- * @param {any} [opts]
141
+ * @param {RawBlobId} blobId
142
+ * @param {object} [opts]
193
143
  * @returns {import('streamx').Readable}
194
144
  */
195
- read(id, opts) {
196
- this._guard()
197
- const raw = decodeId(id)
198
- return this.hyperblobs.createReadStream(raw, opts)
199
- }
200
-
201
- /**
202
- * Look up the locally-cached metadata for a blob id, or `null` if absent.
203
- *
204
- * @param {string} id
205
- * @returns {Promise<BlobEntry | null>}
206
- */
207
- async info(id) {
145
+ createReadStream(blobId, opts) {
208
146
  this._guard()
209
- decodeId(id) // validate id shape
210
- return this._byId.get(id) || null
147
+ return this.hyperblobs.createReadStream(blobId, opts)
211
148
  }
212
149
 
213
150
  /**
214
- * Whether the underlying blocks for the blob are present in the local core.
151
+ * Clear the blocks backing a raw blobId from the local core.
215
152
  *
216
- * @param {string} id
217
- * @returns {Promise<boolean>}
218
- */
219
- async has(id) {
220
- this._guard()
221
- const raw = decodeId(id)
222
- const len = raw.blockOffset + raw.blockLength
223
- if (this.core.length < len) return false
224
- return this.core.has(raw.blockOffset, len)
225
- }
226
-
227
- /**
228
- * Pre-fetch the blocks for `id` from peers without reading the bytes.
229
- *
230
- * @param {string} id
231
- * @param {any} [opts]
153
+ * @param {RawBlobId} blobId
232
154
  * @returns {Promise<void>}
233
155
  */
234
- async fetch(id, opts) {
156
+ async clear(blobId) {
235
157
  this._guard()
236
- const raw = decodeId(id)
237
- const start = raw.blockOffset
238
- const end = raw.blockOffset + raw.blockLength
239
- const range = this.core.download({ start, end, ...opts })
240
- await range.done()
158
+ await this.hyperblobs.clear(blobId)
241
159
  }
242
160
 
243
161
  _guard() {
@@ -246,52 +164,12 @@ export class Blobs extends ReadyResource {
246
164
  }
247
165
  }
248
166
 
249
- function contentHash(bytes) {
250
- const out = b4a.alloc(32)
251
- sodium.crypto_generichash(out, bytes)
252
- return out
253
- }
254
-
255
- function isBufferLike(x) {
256
- return b4a.isBuffer(x) || x instanceof Uint8Array
257
- }
258
-
167
+ /**
168
+ * @param {unknown} x
169
+ * @returns {x is import('streamx').Readable}
170
+ */
259
171
  function isReadable(x) {
260
172
  return x && typeof x.pipe === 'function' && typeof x.on === 'function'
261
173
  }
262
174
 
263
- async function readAll(stream) {
264
- const chunks = []
265
- for await (const chunk of stream) chunks.push(chunk)
266
- return b4a.concat(chunks)
267
- }
268
-
269
- // id <-> raw hyperblobs descriptor (positional inside the core)
270
- // 16-byte packed: 4 uint32-BE = blockOffset, blockLength, byteOffset, byteLength
271
-
272
- function encodeId(raw) {
273
- const buf = b4a.alloc(16)
274
- buf.writeUInt32BE(raw.blockOffset, 0)
275
- buf.writeUInt32BE(raw.blockLength, 4)
276
- buf.writeUInt32BE(raw.byteOffset, 8)
277
- buf.writeUInt32BE(raw.byteLength, 12)
278
- return z32.encode(buf)
279
- }
280
-
281
- function decodeId(id) {
282
- if (typeof id !== 'string') throw CeroError.INVALID('id must be a string')
283
- if (id.length === 0) throw CeroError.INVALID('id must be a non-empty string')
284
- let buf
285
- try {
286
- buf = z32.decode(id)
287
- } catch {
288
- throw CeroError.INVALID('id must be valid z32')
289
- }
290
- if (buf.length !== 16) throw CeroError.INVALID('id must be 16 bytes when decoded')
291
- return {
292
- blockOffset: buf.readUInt32BE(0),
293
- blockLength: buf.readUInt32BE(4),
294
- byteOffset: buf.readUInt32BE(8),
295
- byteLength: buf.readUInt32BE(12)
296
- }
297
- }
175
+ export { encodeId, decodeId }
@@ -0,0 +1,48 @@
1
+ import BlobServer from 'hypercore-blob-server'
2
+ import { decodeId } from './index.js'
3
+
4
+ /**
5
+ * @typedef {{ key: Buffer, encryptionKey: Buffer | null }} Resolved
6
+ */
7
+
8
+ /**
9
+ * HTTP server that turns a string file id into a renderable localhost URL.
10
+ * Wraps hypercore-blob-server; the underlying store session is owned by
11
+ * BlobServer and closed when `close()` is called.
12
+ */
13
+ export class FileServer {
14
+ /**
15
+ * @param {object} opts
16
+ * @param {import('corestore')} opts.store
17
+ * @param {(coreKey: Buffer, info: object) => Resolved | null | Promise<Resolved | null>} opts.resolve
18
+ */
19
+ constructor({ store, resolve }) {
20
+ this.resolve = resolve
21
+ this.server = new BlobServer(store.session(), {
22
+ resolve: (key, info) => this.resolve(key, info)
23
+ })
24
+ }
25
+
26
+ get port() {
27
+ return this.server.port
28
+ }
29
+
30
+ async listen() {
31
+ await this.server.listen()
32
+ }
33
+
34
+ /**
35
+ * Build a renderable localhost URL for a string file id.
36
+ *
37
+ * @param {string} id
38
+ * @returns {string}
39
+ */
40
+ getLink(id) {
41
+ const { coreKey, blobId, type } = decodeId(id)
42
+ return this.server.getLink(coreKey, { blob: blobId, type })
43
+ }
44
+
45
+ async close() {
46
+ await this.server.close()
47
+ }
48
+ }
@@ -1,5 +1,7 @@
1
+ import b4a from 'b4a'
1
2
  import Hypercore from 'hypercore'
2
3
  import { Identity } from '../identity/index.js'
4
+ import { CeroError } from '../lib/errors.js'
3
5
  import { toId } from '../lib/utils.js'
4
6
 
5
7
  /**
@@ -11,7 +13,7 @@ import { toId } from '../lib/utils.js'
11
13
  * @param {{ name?: string | null, isMobile?: boolean, recovering?: boolean }} [opts]
12
14
  * @returns {Promise<{ id: Uint8Array, writer: import('../identity/index.js').KeyPair }>}
13
15
  */
14
- export async function bootstrap(db, { name, isMobile, recovering = false } = {}) {
16
+ export async function bootstrap(db, { name, isMobile, recovering = false, timeout = 30000 } = {}) {
15
17
  const keyPair = Identity.randomKeyPair()
16
18
  const manifest = {
17
19
  version: db.store.manifestVersion,
@@ -19,22 +21,36 @@ export async function bootstrap(db, { name, isMobile, recovering = false } = {})
19
21
  }
20
22
  const writerKey = Hypercore.key(manifest)
21
23
 
22
- if (recovering) await waitForFirstPeerAppend(db)
24
+ if (recovering) await waitForFirstPeerAppend(db, timeout)
23
25
  await saveWriter(db, writerKey, { name, isMobile })
24
26
  await swapWriter(db, keyPair, manifest)
25
27
 
26
28
  return { id: writerKey, writer: keyPair }
27
29
  }
28
30
 
29
- async function waitForFirstPeerAppend(db) {
31
+ async function waitForFirstPeerAppend(db, timeout = 30000) {
30
32
  if (db.bee.local.length > 0 || !db.network?.swarm) return
31
- await new Promise((resolve) => {
32
- const onAppend = () => {
33
- if (db.bee.local.length === 0) return
33
+ await new Promise((resolve, reject) => {
34
+ let done = false
35
+ const finish = (err) => {
36
+ if (done) return
37
+ done = true
38
+ clearTimeout(timer)
34
39
  db.bee.local.off('append', onAppend)
35
- resolve()
40
+ db.off('close', onClose)
41
+ if (err) reject(err)
42
+ else resolve()
36
43
  }
44
+ // timeout + close handling so a peer that never replicates can't hang the
45
+ // recovery forever, and the append listener is always removed.
46
+ const onAppend = () => db.bee.local.length > 0 && finish()
47
+ const onClose = () => finish(CeroError.CLOSED('Database'))
48
+ const timer = setTimeout(
49
+ () => finish(CeroError.TIMED_OUT('recovery — no peer append')),
50
+ timeout
51
+ )
37
52
  db.bee.local.on('append', onAppend)
53
+ db.on('close', onClose)
38
54
  })
39
55
  await db.bee.update()
40
56
  }
@@ -46,7 +62,7 @@ async function saveWriter(db, writerKey, { name, isMobile }) {
46
62
  {
47
63
  master: db.identity.publicKey,
48
64
  writer: writerKey,
49
- sig: db.identity.sign(writerKey)
65
+ sig: db.identity.sign(b4a.concat([writerKey, db.writerKey]))
50
66
  }
51
67
  ],
52
68
  [
@@ -65,10 +81,6 @@ async function swapWriter(db, keyPair, manifest) {
65
81
  const enc = await db.bee.local.getUserData('autobee/encryption')
66
82
 
67
83
  if (db.network) db.network.detach(db.bee)
68
- if (db.onLocalAppend && db.bee.local) {
69
- db.bee.local.off('append', db.onLocalAppend)
70
- db.onLocalAppend = null
71
- }
72
84
  await db.bee.close()
73
85
  db.bee = null
74
86