hyperdrive
Version:
Hyperdrive is a secure, real-time distributed file system
775 lines (599 loc) • 19.3 kB
JavaScript
const Hyperbee = require('hyperbee')
const Hyperblobs = require('hyperblobs')
const isOptions = require('is-options')
const { Writable, Readable } = require('streamx')
const unixPathResolve = require('unix-path-resolve')
const MirrorDrive = require('mirror-drive')
const SubEncoder = require('sub-encoder')
const ReadyResource = require('ready-resource')
const safetyCatch = require('safety-catch')
const crypto = require('hypercore-crypto')
const Hypercore = require('hypercore')
const { BLOCK_NOT_AVAILABLE, BAD_ARGUMENT } = require('hypercore-errors')
const Monitor = require('./lib/monitor')
const keyEncoding = new SubEncoder('files', 'utf-8')
const [BLOBS] = crypto.namespace('hyperdrive', 1)
module.exports = class Hyperdrive extends ReadyResource {
constructor (corestore, key, opts = {}) {
super()
if (isOptions(key)) {
opts = key
key = null
}
this.corestore = corestore
this.db = opts._db || makeBee(key, corestore, opts)
this.core = this.db.core
this.blobs = null
this.supportsMetadata = true
this.encryptionKey = opts.encryptionKey || null
this.monitors = new Set()
this._active = opts.active !== false
this._openingBlobs = null
this._onwait = opts.onwait || null
this._batching = !!(opts._checkout === null && opts._db)
this._checkout = opts._checkout || null
this.ready().catch(safetyCatch)
}
[Symbol.asyncIterator] () {
return this.entries()[Symbol.asyncIterator]()
}
static async getDriveKey (corestore) {
const core = makeBee(undefined, corestore)
await core.ready()
const key = core.key
await core.close()
return key
}
static getContentKey (m, key) {
if (m instanceof Hypercore) {
if (m.core.compat) return null
return Hyperdrive.getContentKey(m.manifest, m.key)
}
const manifest = generateContentManifest(m, key)
if (!manifest) return null
return Hypercore.key(manifest)
}
static getContentManifest (m, key) {
return generateContentManifest(m, key)
}
_generateBlobsManifest () {
const m = this.db.core.manifest
if (this.db.core.core.compat) return null
return generateContentManifest(m, this.core.key)
}
get id () {
return this.core.id
}
get key () {
return this.core.key
}
get discoveryKey () {
return this.core.discoveryKey
}
get contentKey () {
return this.blobs?.core.key
}
get version () {
return this.db.version
}
get writable () {
return this.core.writable
}
get readable () {
return this.core.readable
}
findingPeers () {
return this.corestore.findingPeers()
}
async truncate (version, { blobs = -1 } = {}) {
if (!this.opened) await this.ready()
if (version > this.core.length) {
throw BAD_ARGUMENT('Bad truncation length')
}
const blobsVersion = blobs === -1 ? await this.getBlobsLength(version) : blobs
const bl = await this.getBlobs()
if (blobsVersion > bl.core.length) {
throw BAD_ARGUMENT('Bad truncation length')
}
await this.core.truncate(version)
await bl.core.truncate(blobsVersion)
}
async getBlobsLength (checkout) {
if (!this.opened) await this.ready()
if (!checkout) checkout = this.version
const c = this.db.checkout(checkout)
try {
return await getBlobsLength(c)
} finally {
await c.close()
}
}
replicate (isInitiator, opts) {
return this.corestore.replicate(isInitiator, opts)
}
update (opts) {
return this.db.update(opts)
}
_makeCheckout (snapshot) {
return new Hyperdrive(this.corestore, this.key, {
onwait: this._onwait,
encryptionKey: this.encryptionKey,
_checkout: this._checkout || this,
_db: snapshot
})
}
checkout (version) {
return this._makeCheckout(this.db.checkout(version))
}
batch () {
return new Hyperdrive(this.corestore, this.key, {
onwait: this._onwait,
encryptionKey: this.encryptionKey,
_checkout: null,
_db: this.db.batch()
})
}
setActive (bool) {
const active = !!bool
if (active === this._active) return
this._active = active
this.core.setActive(active)
if (this.blobs) this.blobs.core.setActive(active)
}
async flush () {
await this.db.flush()
return this.close()
}
async _close () {
if (this.blobs && (!this._checkout || this.blobs !== this._checkout.blobs)) {
await this.blobs.core.close()
}
await this.db.close()
if (!this._checkout && !this._batching) {
await this.corestore.close()
}
await this.closeMonitors()
}
async _openBlobsFromHeader (opts) {
if (this.blobs) return true
const header = await getBee(this.db).getHeader(opts)
if (!header) return false
if (this.blobs) return true
const contentKey = header.metadata && header.metadata.contentFeed && header.metadata.contentFeed.subarray(0, 32)
const blobsKey = contentKey || Hypercore.key(this._generateBlobsManifest())
if (!blobsKey || blobsKey.length < 32) throw new Error('Invalid or no Blob store key set')
const blobsCore = this.corestore.get({
key: blobsKey,
cache: false,
onwait: this._onwait,
encryptionKey: this.encryptionKey,
keyPair: (!contentKey && this.db.core.writable) ? this.db.core.keyPair : null,
active: this._active
})
await blobsCore.ready()
if (this.closing) {
await blobsCore.close()
return false
}
this.blobs = new Hyperblobs(blobsCore)
this.emit('blobs', this.blobs)
this.emit('content-key', blobsCore.key)
return true
}
async _open () {
if (this._checkout) {
await this._checkout.ready()
this.blobs = this._checkout.blobs
return
}
await this._openBlobsFromHeader({ wait: false })
if (this.db.core.writable && !this.blobs) {
const m = this._generateBlobsManifest()
const blobsCore = this.corestore.get({
manifest: m,
name: m ? null : this.db.core.id + '/blobs', // simple trick to avoid blobs clashing if no namespace is provided...
cache: false,
onwait: this._onwait,
encryptionKey: this.encryptionKey,
compat: this.db.core.core.compat,
active: this._active,
keyPair: (m && this.db.core.writable) ? this.db.core.keyPair : null
})
await blobsCore.ready()
this.blobs = new Hyperblobs(blobsCore)
if (!m) getBee(this.db).metadata.contentFeed = this.blobs.core.key
this.emit('blobs', this.blobs)
this.emit('content-key', blobsCore.key)
}
await this.db.ready()
if (!this.blobs) {
// eagerly load the blob store....
this._openingBlobs = this._openBlobsFromHeader()
this._openingBlobs.catch(safetyCatch)
}
}
async getBlobs () {
if (this.blobs) return this.blobs
if (this._checkout) {
this.blobs = await this._checkout.getBlobs()
} else {
await this.ready()
await this._openingBlobs
}
return this.blobs
}
monitor (name, opts = {}) {
const monitor = new Monitor(this, { name, ...opts })
this.monitors.add(monitor)
return monitor
}
async closeMonitors () {
const closing = []
for (const monitor of this.monitors) closing.push(monitor.close())
await Promise.allSettled(closing)
}
async get (name, opts) {
const node = await this.entry(name, opts)
if (!node?.value.blob) return null
await this.getBlobs()
const res = await this.blobs.get(node.value.blob, opts)
if (res === null) throw BLOCK_NOT_AVAILABLE()
return res
}
async put (name, buf, { executable = false, metadata = null } = {}) {
await this.getBlobs()
const blob = await this.blobs.put(buf)
return this.db.put(std(name, false), { executable, linkname: null, blob, metadata }, { keyEncoding })
}
async del (name) {
return this.db.del(std(name, false), { keyEncoding })
}
compare (a, b) {
const diff = a.seq - b.seq
return diff > 0 ? 1 : (diff < 0 ? -1 : 0)
}
async clear (name, opts) {
if (!this.opened) await this.ready()
let node = null
try {
node = await this.entry(name, { wait: false })
} catch {
// do nothing, prop not available
}
if (node === null || this.blobs === null) {
return (opts && opts.diff) ? { blocks: 0 } : null
}
return this.blobs.clear(node.value.blob, opts)
}
async clearAll (opts) {
if (!this.opened) await this.ready()
if (this.blobs === null) {
return (opts && opts.diff) ? { blocks: 0 } : null
}
return this.blobs.core.clear(0, this.blobs.core.length, opts)
}
async purge () {
if (this._checkout || this._batch) throw new Error('Can only purge the main session')
await this.ready() // Ensure blobs loaded if present
await this.close()
const proms = [this.core.purge()]
if (this.blobs) proms.push(this.blobs.core.purge())
await Promise.all(proms)
}
async symlink (name, dst, { metadata = null } = {}) {
return this.db.put(std(name, false), { executable: false, linkname: dst, blob: null, metadata }, { keyEncoding })
}
async entry (name, opts) {
if (!opts || !opts.follow) return this._entry(name, opts)
for (let i = 0; i < 16; i++) {
const node = await this._entry(name, opts)
if (!node || !node.value.linkname) return node
name = unixPathResolve(node.key, node.value.linkname)
}
throw new Error('Recursive symlink')
}
async _entry (name, opts) {
if (typeof name !== 'string') return name
return this.db.get(std(name, false), { ...opts, keyEncoding })
}
async exists (name) {
return await this.entry(name) !== null
}
watch (folder) {
folder = std(folder || '/', true)
return this.db.watch(prefixRange(folder), { keyEncoding, map: (snap) => this._makeCheckout(snap) })
}
diff (length, folder, opts) {
if (typeof folder === 'object' && folder && !opts) return this.diff(length, null, folder)
folder = std(folder || '/', true)
return this.db.createDiffStream(length, prefixRange(folder), { ...opts, keyEncoding })
}
async downloadDiff (length, folder, opts) {
const dls = []
for await (const entry of this.diff(length, folder, opts)) {
if (!entry.left) continue
const b = entry.left.value.blob
if (!b) continue
const blobs = await this.getBlobs()
dls.push(blobs.core.download({ start: b.blockOffset, length: b.blockLength }))
}
const proms = []
for (const r of dls) proms.push(r.downloaded())
await Promise.allSettled(proms)
}
async downloadRange (dbRanges, blobRanges) {
const dls = []
await this.ready()
for (const range of dbRanges) {
dls.push(this.db.core.download(range))
}
const blobs = await this.getBlobs()
for (const range of blobRanges) {
dls.push(blobs.core.download(range))
}
const proms = []
for (const r of dls) proms.push(r.downloaded())
await Promise.allSettled(proms)
}
entries (range, opts) {
const stream = this.db.createReadStream(range, { ...opts, keyEncoding })
if (opts && opts.ignore) stream._readableState.map = createStreamMapIgnore(opts.ignore)
return stream
}
async download (folder = '/', opts) {
if (typeof folder === 'object') return this.download(undefined, folder)
const dls = []
const entry = (!folder || folder.endsWith('/')) ? null : await this.entry(folder)
if (entry) {
const b = entry.value.blob
if (!b) return
const blobs = await this.getBlobs()
await blobs.core.download({ start: b.blockOffset, length: b.blockLength }).downloaded()
return
}
// first preload the list so we can use the full power afterwards to actually preload everything
// eslint-disable-next-line
for await (const _ of this.list(folder, opts)) {
// ignore
}
for await (const entry of this.list(folder, opts)) {
const b = entry.value.blob
if (!b) continue
const blobs = await this.getBlobs()
dls.push(blobs.core.download({ start: b.blockOffset, length: b.blockLength }))
}
const proms = []
for (const r of dls) proms.push(r.downloaded())
await Promise.allSettled(proms)
}
async has (path) {
const blobs = await this.getBlobs()
const entry = (!path || path.endsWith('/')) ? null : await this.entry(path)
if (entry) {
const b = entry.value.blob
if (!b) return false
return await blobs.core.has(b.blockOffset, b.blockOffset + b.blockLength)
}
for await (const entry of this.list(path)) {
const b = entry.value.blob
if (!b) continue
const has = await blobs.core.has(b.blockOffset, b.blockOffset + b.blockLength)
if (!has) return false
}
return true
}
// atm always recursive, but we should add some depth thing to it
list (folder, opts = {}) {
if (typeof folder === 'object') return this.list(undefined, folder)
folder = std(folder || '/', true)
const ignore = opts.ignore ? toIgnoreFunction(opts.ignore) : null
const stream = opts && opts.recursive === false ? shallowReadStream(this.db, folder, false, ignore, opts) : this.entries(prefixRange(folder), { ...opts, ignore })
return stream
}
readdir (folder, opts) {
folder = std(folder || '/', true)
return shallowReadStream(this.db, folder, true, null, opts)
}
mirror (out, opts) {
return new MirrorDrive(this, out, opts)
}
createReadStream (name, opts) {
const self = this
let destroyed = false
let rs = null
const stream = new Readable({
open (cb) {
self.getBlobs().then(onblobs, cb)
function onblobs () {
self.entry(name).then(onnode, cb)
}
function onnode (node) {
if (destroyed) return cb(null)
if (!node) return cb(new Error('Blob does not exist'))
if (self.closing) return cb(new Error('Closed'))
if (!node.value.blob) {
stream.push(null)
return cb(null)
}
rs = self.blobs.createReadStream(node.value.blob, opts)
rs.on('data', function (data) {
if (!stream.push(data)) rs.pause()
})
rs.on('end', function () {
stream.push(null)
})
rs.on('error', function (err) {
stream.destroy(err)
})
cb(null)
}
},
read (cb) {
rs.resume()
cb(null)
},
predestroy () {
destroyed = true
if (rs) rs.destroy()
}
})
return stream
}
createWriteStream (name, { executable = false, metadata = null } = {}) {
const self = this
let destroyed = false
let ws = null
let ondrain = null
let onfinish = null
const stream = new Writable({
open (cb) {
self.getBlobs().then(onblobs, cb)
function onblobs () {
if (destroyed) return cb(null)
ws = self.blobs.createWriteStream()
ws.on('error', function (err) {
stream.destroy(err)
})
ws.on('close', function () {
const err = new Error('Closed')
callOndrain(err)
callOnfinish(err)
})
ws.on('finish', function () {
callOnfinish(null)
})
ws.on('drain', function () {
callOndrain(null)
})
cb(null)
}
},
write (data, cb) {
if (ws.write(data) === true) return cb(null)
ondrain = cb
},
final (cb) {
onfinish = cb
ws.end()
},
predestroy () {
destroyed = true
if (ws) ws.destroy()
}
})
return stream
function callOnfinish (err) {
if (!onfinish) return
const cb = onfinish
onfinish = null
if (err) return cb(err)
self.db.put(std(name, false), { executable, linkname: null, blob: ws.id, metadata }, { keyEncoding }).then(() => cb(null), cb)
}
function callOndrain (err) {
if (ondrain) {
const cb = ondrain
ondrain = null
cb(err)
}
}
}
static normalizePath (name) {
return std(name, false)
}
}
function shallowReadStream (files, folder, keys, ignore, opts) {
let prev = '/'
let prevName = ''
return new Readable({
async read (cb) {
let node = null
try {
node = await files.peek(prefixRange(folder, prev), { ...opts, keyEncoding })
} catch (err) {
return cb(err)
}
if (!node) {
this.push(null)
return cb(null)
}
const suffix = node.key.slice(folder.length + 1)
const i = suffix.indexOf('/')
const name = i === -1 ? suffix : suffix.slice(0, i)
prev = '/' + name + (i === -1 ? '' : '0')
// just in case someone does /foo + /foo/bar, but we should prop not even support that
if (name === prevName) {
this._read(cb)
return
}
prevName = name
if (ignore && ignore(node.key)) {
this._read(cb)
return
}
this.push(keys ? name : node)
cb(null)
}
})
}
function makeBee (key, corestore, opts = {}) {
const name = key ? undefined : 'db'
const core = corestore.get({ key, name, exclusive: true, onwait: opts.onwait, encryptionKey: opts.encryptionKey, compat: opts.compat, active: opts.active })
return new Hyperbee(core, {
keyEncoding: 'utf-8',
valueEncoding: 'json',
metadata: { contentFeed: null }
})
}
function getBee (bee) {
// A Batch instance will have a .tree property for the actual Hyperbee
return bee.tree || bee
}
function std (name, removeSlash) {
// Note: only remove slash if you're going to use it as prefix range
name = unixPathResolve('/', name)
if (removeSlash && name.endsWith('/')) name = name.slice(0, -1)
validateFilename(name)
return name
}
function validateFilename (name) {
if (name === '/') throw new Error('Invalid filename: ' + name)
}
function prefixRange (name, prev = '/') {
// '0' is binary +1 of /
return { gt: name + prev, lt: name + '0' }
}
function generateContentManifest (m, key) {
if (m.version < 1) return null
const signers = []
if (!key) key = Hypercore.key(m)
for (const s of m.signers) {
const namespace = crypto.hash([BLOBS, key, s.namespace])
signers.push({ ...s, namespace })
}
return {
version: m.version,
hash: 'blake2b',
allowPatch: m.allowPatch,
quorum: m.quorum,
signers,
prologue: null // TODO: could be configurable through the header still...
}
}
async function getBlobsLength (db) {
let length = 0
for await (const { value } of db.createReadStream()) {
const b = value && value.blob
if (!b) continue
const len = b.blockOffset + b.blockLength
if (len > length) length = len
}
return length
}
function toIgnoreFunction (ignore) {
if (typeof ignore === 'function') return ignore
const all = [].concat(ignore).map(e => unixPathResolve('/', e))
return key => all.some(path => path === key || key.startsWith(path + '/'))
}
function createStreamMapIgnore (ignore) {
return (node) => ignore(node.key) ? null : node
}