OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-vdrive commits / 359b568

Persist chunk locations, and reopen a drive for serving
A drive stores index -> contentHash. Serving it needs the other half — contentHash -> (file, offset, length) — and rebuilding that by re-chunking every published file is O(all bytes) on every boot, which is untenable for a node holding a repo's whole release history. publishTree now writes that index beside the drive when given a `root`, with paths relative to it so the index survives a move and never bakes a local layout into a shared artifact. openDrive() reads it back and returns a drive ready to serve, with no chunker involved at all. Measured: 6.29MB published as 90 chunk locations = an 8.7KB index. Reopened reads are byte-exact and a stock peer replicated the whole payload out of the reopened drive. serve.cjs documents why `pear seed` cannot do this job: it opens the drive by key in PEAR's own corestore and downloads it, materialising a second full copy of every byte. It is a re-seeder, not a source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
359b568c9b911ce4dbc1b5288fb14e96b078c36b
by plankatron <fleeky@gmail.com> · 2026-08-24T21:36:22.000Z

Changed files (5) +219 -2

lib/locations.cjs +67 -0
……@@ -0,0 +1,67 @@
1// Persist contentHash -> (file, offset, length) beside the drive.
2//
3// A drive stores index -> contentHash. To SERVE it, something must turn a
4// contentHash back into bytes. Rebuilding that by re-chunking every published
5// file costs O(all bytes) on every boot — untenable for a seeder holding a
6// repo's whole release history.
7//
8// So write it once, at publish time, when we already know both halves. Paths
9// are stored RELATIVE to a root so the index survives the tree being moved,
10// and so it never leaks an absolute layout into a file peers could see.
11//
12// This is the same reverse index hyper-overlay is missing (see its issue #140);
13// scoped here to one drive, it is cheap: ~90 bytes per unique chunk as JSON.
14
15const { isBare } = require('which-runtime')
16const fs = isBare ? require('bare-fs') : require('fs')
17const path = isBare ? require('bare-path') : require('path')
18
19const FILE = 'locations.json'
20const VERSION = 1
21
22function locationsPath (storeDir) {
23 return path.join(storeDir, FILE)
24}
25
26/**
27 * @param {string} storeDir the drive's store directory
28 * @param {Map<string,{file,offset,length}>} locations from ContentStore
29 * @param {string} root paths are recorded relative to this
30 */
31function saveLocations (storeDir, locations, root) {
32 const entries = []
33 for (const [hash, loc] of locations) {
34 const rel = path.relative(root, loc.file)
35 // Never record something outside the root — it would not resolve on another
36 // machine, and it would bake a local layout into a shared artifact.
37 if (rel.startsWith('..') || path.isAbsolute(rel)) continue
38 entries.push([hash, rel, loc.offset, loc.length])
39 }
40 fs.mkdirSync(storeDir, { recursive: true })
41 const tmp = locationsPath(storeDir) + '.tmp'
42 fs.writeFileSync(tmp, JSON.stringify({ version: VERSION, entries }))
43 fs.renameSync(tmp, locationsPath(storeDir))
44 return entries.length
45}
46
47/**
48 * Merge a saved index into a ContentStore, resolving paths against `root`.
49 * Returns how many entries were loaded, or 0 when there is no index.
50 */
51function loadLocations (storeDir, content, root) {
52 let raw
53 try {
54 raw = JSON.parse(fs.readFileSync(locationsPath(storeDir), 'utf8'))
55 } catch {
56 return 0
57 }
58 if (!raw || raw.version !== VERSION || !Array.isArray(raw.entries)) return 0
59 let n = 0
60 for (const [hash, rel, offset, length] of raw.entries) {
61 content.add(hash, path.join(root, rel), offset, length)
62 n++
63 }
64 return n
65}
66
67module.exports = { saveLocations, loadLocations, locationsPath }
lib/publish.cjs +9 -1
……@@ -15,6 +15,7 @@ const idEnc = require('hypercore-id-encoding')
1515const Corestore = require('corestore')
1616const Hyperdrive = require('hyperdrive')
1717const { ContentStore, build, stats } = require('./storage.cjs')
18const { saveLocations } = require('./locations.cjs')
1819
1920function writeChunk (ws, buf) {
2021 return new Promise((resolve) => {
……@@ -28,10 +29,12 @@ function writeChunk (ws, buf) {
2829 * @param {string} opts.storeDir where the drive lives (pointers only)
2930 * @param {Array<{name: string, file: string}>} opts.files drive path -> source file on disk
3031 * @param {function} opts.chunk content-defined chunker
32 * @param {string} [opts.root] record chunk locations relative to this,
33 * so a seeder can resolve them later
3134 * @param {object} [opts.corestoreOpts]
3235 * @returns {Promise<{link, key, blocks, files, bytes, pointerBlocks, literalBlocks}>}
3336 */
34async function publishTree ({ storeDir, files, chunk, corestoreOpts } = {}) {
37async function publishTree ({ storeDir, files, chunk, root, corestoreOpts } = {}) {
3538 if (!storeDir) throw new Error('publishTree requires { storeDir }')
3639 if (!Array.isArray(files) || files.length === 0) throw new Error('publishTree requires files')
3740 if (typeof chunk !== 'function') throw new Error('publishTree requires a chunk function')
……@@ -67,7 +70,12 @@ async function publishTree ({ storeDir, files, chunk, corestoreOpts } = {}) {
6770 }
6871 await drive.getBlobs()
6972
73 // Persist the reverse index so a seeder can serve this drive without
74 // re-chunking every published file on boot.
75 const locations = root ? saveLocations(storeDir, content.locations, root) : 0
76
7077 return {
78 locations,
7179 link: 'pear://' + idEnc.normalize(drive.key),
7280 key: drive.key.toString('hex'),
7381 blocks: drive.blobs.core.length,
lib/serve.cjs +68 -0
……@@ -0,0 +1,68 @@
1// Reopen a published drive for SERVING, without re-chunking anything.
2//
3// `pear seed` cannot do this job: it opens the drive by key in PEAR's own
4// corestore and downloads it, which would materialise a full second copy of
5// every byte — the copy this package exists to avoid. It is a re-seeder, not
6// a source. So the origin serves its own drives, out of its own store.
7//
8// The bytes are resolved through the locations index written at publish time,
9// so opening a drive is O(index) rather than O(all published bytes).
10
11const idEnc = require('hypercore-id-encoding')
12const Corestore = require('corestore')
13const Hyperdrive = require('hyperdrive')
14const { ContentStore, build } = require('./storage.cjs')
15const { loadLocations } = require('./locations.cjs')
16
17/**
18 * @param {object} opts
19 * @param {string} opts.storeDir the drive's store (pointers, tree, metadata)
20 * @param {string} opts.root resolves the locations index's relative paths
21 * @param {object} [opts.corestoreOpts]
22 * @returns {Promise<{drive, store, content, link, key, discoveryKey, blocks, locations}>}
23 */
24async function openDrive ({ storeDir, root, corestoreOpts } = {}) {
25 if (!storeDir || !root) throw new Error('openDrive requires { storeDir, root }')
26
27 const content = new ContentStore()
28 const locations = loadLocations(storeDir, content, root)
29
30 const Storage = build(content)
31 const store = new Corestore(new Storage(storeDir), corestoreOpts)
32 const drive = new Hyperdrive(store)
33 await drive.ready()
34 await drive.getBlobs()
35
36 return {
37 drive,
38 store,
39 content,
40 locations,
41 link: 'pear://' + idEnc.normalize(drive.key),
42 key: drive.key.toString('hex'),
43 discoveryKey: drive.discoveryKey,
44 blocks: drive.blobs.core.length
45 }
46}
47
48/**
49 * Announce an already-open drive on a swarm the caller owns.
50 *
51 * `server: true, client: false` — an origin has everything and needs nothing.
52 * A second node holding the same pointers can announce with `client: true` to
53 * become an additional source, which is how a pear:// link ends up served by
54 * more than one backend.
55 */
56function announce (swarm, opened, { client = false } = {}) {
57 const discovery = swarm.join(opened.discoveryKey, { server: true, client })
58 return discovery
59}
60
61async function closeDrive (opened) {
62 if (!opened) return
63 await opened.drive.close().catch(() => {})
64 await opened.store.close().catch(() => {})
65 if (opened.content && typeof opened.content.close === 'function') opened.content.close()
66}
67
68module.exports = { openDrive, announce, closeDrive }
package.json +2 -1
……@@ -10,7 +10,8 @@
1010 ".": "./lib/storage.cjs",
1111 "./overlay": "./lib/overlay-content-store.cjs",
1212 "./passthrough": "./lib/passthrough.cjs",
13 "./publish": "./lib/publish.cjs"
13 "./publish": "./lib/publish.cjs",
14 "./locations": "./lib/locations.cjs"
1415 },
1516 "scripts": {
1617 "test:passthrough": "node test/pass-through.mjs ./work/passthrough",
test/serve-reopen.mjs +73 -0
……@@ -0,0 +1,73 @@
1// Publish, then reopen for serving WITHOUT the chunker — the locations index
2// must carry everything needed to resolve bytes, and a stock peer must be able
3// to replicate the whole drive out of the reopened one.
4import { createRequire } from 'module'
5const require = createRequire(import.meta.url)
6
7const fs = require('fs')
8const path = require('path')
9const crypto = require('hypercore-crypto')
10const Corestore = require('corestore')
11const Hyperdrive = require('hyperdrive')
12const { publishTree } = require('../lib/publish.cjs')
13const { openDrive, closeDrive } = require('../lib/serve.cjs')
14const { locationsPath } = require('../lib/locations.cjs')
15const { chunk } = await import('../../hyper-overlay/lib/chunker.js')
16
17const ROOT = process.argv[2]
18const releaseDir = path.join(ROOT, 'releases')
19const storeDir = path.join(ROOT, 'drive')
20fs.mkdirSync(path.join(releaseDir, 'v1'), { recursive: true })
21
22const payload = crypto.randomBytes(6 * 1024 * 1024)
23const small = Buffer.from('{"name":"demo"}')
24fs.writeFileSync(path.join(releaseDir, 'v1', 'app.bin'), payload)
25fs.writeFileSync(path.join(releaseDir, 'v1', 'package.json'), small)
26
27const published = await publishTree({
28 storeDir,
29 root: releaseDir,
30 chunk,
31 files: [
32 { name: '/app.bin', file: path.join(releaseDir, 'v1', 'app.bin') },
33 { name: '/package.json', file: path.join(releaseDir, 'v1', 'package.json') }
34 ]
35})
36
37// Reopen with NO chunker in sight — everything must come from the index.
38const opened = await openDrive({ storeDir, root: releaseDir })
39
40const gotBin = await opened.drive.get('/app.bin')
41const gotPkg = await opened.drive.get('/package.json')
42
43// And a stock peer must be able to pull it out of the reopened drive.
44const peerStore = new Corestore(path.join(ROOT, 'peer'))
45const peerDrive = new Hyperdrive(peerStore, Buffer.from(published.key, 'hex'))
46await peerDrive.ready()
47const s1 = opened.store.replicate(true)
48const s2 = peerStore.replicate(false)
49s1.pipe(s2).pipe(s1)
50await peerDrive.core.update({ wait: true })
51const peerBin = await peerDrive.get('/app.bin', { timeout: 15000 }).catch(() => null)
52
53s1.destroy()
54s2.destroy()
55await peerDrive.close()
56await peerStore.close()
57await closeDrive(opened)
58
59console.log(
60 JSON.stringify(
61 {
62 locationsWritten: published.locations,
63 locationsLoaded: opened.locations,
64 indexBytes: fs.statSync(locationsPath(storeDir)).size,
65 sameKey: published.key === opened.key,
66 reopenedReadsOk: gotBin !== null && gotBin.equals(payload) && gotPkg.equals(small),
67 stockPeerGotBytes: peerBin !== null && peerBin.equals(payload),
68 payloadBytes: payload.length
69 },
70 null,
71 2
72 )
73)