Share files in a peer-to-peer app
Swap the hello-pear-electron room for a Hyperdrive so peers can publish files into a shared folder and replicate them through the same scaffold.
This guide shows you how to swap the hello-pear-electron room for a Hyperdrive so peers share files instead of messages. The reference implementation is pear-file-sharing.
This guide is about the Pear-end, not the shell. The code below lives in the Bare worker — the peer-to-peer logic, not the user interface. Because the Pear-end never imports DOM APIs and never assumes a UI framework, the same worker is portable across desktop (Electron), mobile (React Native via Bare iOS / Bare Android), and terminal. The example apps ship an Electron shell, but only the UI half changes per platform — the logic here stays the same. See Runtime and languages for the cross-platform model and current support.
This is a delta-only how-to. The shared Electron + PearRuntime + Bare worker scaffold — with plain-JSON messages over a framed-stream pipe and a vanilla HTML renderer — is explained in the Start from the hello-pear-electron template tutorial — read it first.
- Create a full peer-to-peer filesystem with Hyperdrive — the building block this guide layers a desktop UI on top of.
Before you begin
- A working clone of
hello-pear-electron(or your own app built from the getting-started path). - Familiarity with Hyperdrive and Localdrive.
What changes
| Layer | Change |
|---|---|
| Dependencies | Add hyperdrive, localdrive, and hypercore-id-encoding. |
| Worker | Add a DriveRoom: each peer owns a Hyperdrive mirrored from a local my-drive folder, and the Autobase view tracks the set of drive keys so peers mirror each others' drives into shared-drives. |
| Worker teardown | Cancel the mirror/file-list interval timers in _close before closing the room → swarm → store. |
| Worker messages | Push a drives message (each drive plus its files) and handle an add-file message that copies a chosen file into my-drive. |
| Renderer | Render a per-drive file list with an "add file" picker. |
Steps
Add the dependencies
npm install hyperdrive localdrive hypercore-id-encodingAdd a DriveRoom worker
Create workers/drive-room.js (DriveRoom) by adapting chat-room.js. The pairing, Autobase, and writer plumbing stay the same; what changes is the data each peer publishes:
- Each peer owns one Hyperdrive (
this.myDrive) backed by a localmy-drivefolder (this.myLocalDrive, alocaldrive)._uploadMyDrive(L160) publishes the drive key to the Autobase (L162), joins the swarm on it (L163), and mirrors the folder into the Hyperdrive on a 1-second timer (L165–L166), so dropping a file intomy-drivepublishes it to peers. - The Autobase view stores just the set of drive keys (
@pear-file-sharing/drives)._downloadSharedDrives(L141) replicates every peer's Hyperdrive: it opens a per-keyLocalDriveundershared-drives(L147), reusesmyDriveor constructs a peer Hyperdrive from the key (L149), mirrors the drive down on everyappend(L152–L153), and joins the swarm on its discovery key (L156).
async _downloadSharedDrives () {
const drives = await this.getDrives()
await Promise.all(drives.map(async (item) => {
const key = idEnc.normalize(item.key)
if (this.drives[key]) return
const local = new LocalDrive(path.join(this.sharedDrivesPath, key))
this.localDrives[key] = local
const drive = key === idEnc.normalize(this.myDrive.key) ? this.myDrive : new Hyperdrive(this.store, item.key)
this.drives[key] = drive
const mirror = debounce(() => drive.mirror(local).done())
drive.core.on('append', () => mirror())
await drive.ready()
this.swarm.join(drive.discoveryKey)
}))
}
async _uploadMyDrive () {
await this.myDrive.ready()
this.addDrive(this.myDrive.key, { name: this.name })
this.swarm.join(this.myDrive.discoveryKey)
const mirror = debounce(() => this.myLocalDrive.mirror(this.myDrive).done())
this.uploadInterval = setInterval(() => mirror(), 1000)
}Preserve the clearInterval teardown
pear-file-sharing runs two polling loops: DriveRoom._uploadMyDrive mirrors the user's my-drive folder into the Hyperdrive, and WorkerTask rebuilds the file list for the renderer. Both store their timer handles, and _close clears them (L62) before the standard room → swarm → store chain (L63–L65). Do not drop this, or shutdown leaks an interval timer:
async _close () {
clearInterval(this.intervalFiles)
await this.room.close()
await this.swarm.destroy()
await this.store.close()
}DriveRoom._close does the same for its own uploadInterval. The graceful-goodbye hook in workers/index.js is what fires this on SIGINT / IPC end.
Surface the drives over the worker pipe
The worker uses a HyperDispatch for its Autobase, with add-drive standing in for add-message (schema.js registers the drive/drives schemas and the add-drive dispatch). Regenerate spec/:
npm run build:dbThe worker–renderer transport stays the plain-JSON-over-framed-stream pipe in hello-pear-electron — no HRPC. WorkerTask._open wires both ends: it parses each plain-JSON message off the pipe (L44–L50), and an add-file message copies the chosen file into the my-drive folder (L51–L53) where _uploadMyDrive picks it up. A 1-second interval starts _drives (L56), and the initial invite is written back to the renderer (L58):
async _open () {
await this.store.ready()
await this.room.ready()
await fs.promises.mkdir(this.myDrivePath, { recursive: true })
await fs.promises.mkdir(this.sharedDrivesPath, { recursive: true })
this.pipe.on('data', async (data) => {
let message
try {
message = JSON.parse(data)
} catch {
return
}
if (message.type === 'add-file') {
await fs.promises.copyFile(message.uri, path.join(this.myDrivePath, message.name))
}
})
this.intervalFiles = setInterval(() => this._drives(), 1000)
this.pipe.write(JSON.stringify({ type: 'invite', invite: await this.room.getInvite() }))
}_drives reads each mirrored drive folder off disk and writes the full list — drive name plus its files as file:// URIs — back to the renderer as a drives message. It walks every known drive (L69), reads its mirrored folder recursively (L74–L77), builds the drive plus its files as file:// URIs (L84–L85), pins the user's own drive first (L89–L92), and writes the drives message over the pipe (L94):
async _drives () {
const rawDrives = await this.room.getDrives()
const drives = await Promise.all(rawDrives.map(async (drive) => {
const key = idEnc.normalize(drive.key)
const dir = path.join(this.sharedDrivesPath, key)
await fs.promises.mkdir(dir, { recursive: true })
const files = await fs.promises.readdir(dir, { recursive: true }).catch((err) => {
if (err.code === 'ENOENT') return []
throw err
})
const isMyDrive = key === idEnc.normalize(this.room.myDrive.key)
return {
...drive,
info: {
...drive.info,
isMyDrive,
uri: `file://${isMyDrive ? this.myDrivePath : dir}`,
files: files.map((name) => ({ name, uri: `file://${path.join(dir, name)}` }))
}
}
}))
drives.sort((a, b) => {
if (a.info.isMyDrive && !b.info.isMyDrive) return -1
if (!a.info.isMyDrive && b.info.isMyDrive) return 1
return a.info.name.localeCompare(b.info.name)
})
this.pipe.write(JSON.stringify({ type: 'drives', drives }))
}Update the renderer
In the vanilla renderer/app.js, render a list grouped by drive — each peer's drive and its files, with the user's own drive pinned first. Add a drag-and-drop zone plus a "browse" file picker that send an add-file message over the worker pipe. Use bridge.getPathForFile(file) (L35) — backed by webUtils.getPathForFile, already exposed in electron/preload.js of hello-pear-electron — to turn each picked file into a local path the worker can copy, then send it as an add-file message (L36). renderDrives builds one card per drive and links each file to its file:// URI (L43–L100). The drop zone (L111–L115) and the "browse" picker (L117–L120) both feed addFiles, and incoming worker messages route drives/invite events to the renderer (L131–L132):
const bridge = window.bridge
const decoder = new TextDecoder('utf-8')
const SPECIFIER = '/workers/index.js'
const countEl = document.getElementById('count')
const dropzoneEl = document.getElementById('dropzone')
const fileInputEl = document.getElementById('fileInput')
const drivesEl = document.getElementById('drives')
const emptyEl = document.getElementById('empty')
const inviteBarEl = document.getElementById('invite-bar')
const inviteEl = document.getElementById('invite')
const copyEl = document.getElementById('copy')
let invite = ''
function setInvite (value) {
invite = value
if (!invite) {
inviteBarEl.classList.add('hidden')
return
}
inviteEl.textContent = invite
inviteBarEl.classList.remove('hidden')
}
copyEl.addEventListener('click', () => {
if (!invite) return
bridge.writeClipboard(invite)
copyEl.textContent = 'Copied'
setTimeout(() => { copyEl.textContent = 'Copy' }, 1500)
})
function addFile (file) {
const uri = bridge.getPathForFile(file)
bridge.writeWorkerIPC(SPECIFIER, JSON.stringify({ type: 'add-file', name: file.name, uri }))
}
function addFiles (files) {
for (const file of files) addFile(file)
}
function renderDrives (drives) {
const totalFiles = drives.reduce((sum, d) => sum + d.info.files.length, 0)
countEl.textContent =
`${drives.length} drive${drives.length === 1 ? '' : 's'} · ${totalFiles} file${totalFiles === 1 ? '' : 's'}`
// Re-render the list from scratch; keep the empty-state element in the DOM.
for (const node of [...drivesEl.children]) {
if (node !== emptyEl) node.remove()
}
emptyEl.style.display = drives.length === 0 ? '' : 'none'
for (const drive of drives) {
const card = document.createElement('div')
card.className = 'rounded-2xl border border-neutral-800 bg-neutral-900 px-4 py-3'
const head = document.createElement('div')
head.className = 'flex items-center gap-2 mb-2'
const title = document.createElement('a')
title.className = 'text-sm font-medium text-neutral-100 hover:text-white hover:underline truncate'
title.href = drive.info.uri
title.textContent = drive.info.name
head.append(title)
if (drive.info.isMyDrive) {
const badge = document.createElement('span')
badge.className = 'rounded-full bg-neutral-800 px-2 py-0.5 text-[10px] uppercase tracking-wider text-neutral-400'
badge.textContent = 'You'
head.append(badge)
}
card.append(head)
if (drive.info.files.length === 0) {
const empty = document.createElement('div')
empty.className = 'text-xs text-neutral-500'
empty.textContent = 'Empty drive.'
card.append(empty)
} else {
const list = document.createElement('ul')
list.className = 'space-y-1'
for (const file of drive.info.files) {
const item = document.createElement('li')
item.className = 'text-sm'
const link = document.createElement('a')
link.className = 'text-neutral-300 hover:text-neutral-100 hover:underline break-all'
link.href = file.uri
link.textContent = file.name
item.append(link)
list.append(item)
}
card.append(list)
}
drivesEl.append(card)
}
}
dropzoneEl.addEventListener('dragover', (event) => {
event.preventDefault()
dropzoneEl.classList.add('border-neutral-600', 'bg-neutral-900')
})
dropzoneEl.addEventListener('dragleave', () => {
dropzoneEl.classList.remove('border-neutral-600', 'bg-neutral-900')
})
dropzoneEl.addEventListener('drop', (event) => {
event.preventDefault()
dropzoneEl.classList.remove('border-neutral-600', 'bg-neutral-900')
addFiles(event.dataTransfer.files)
})
fileInputEl.addEventListener('change', (event) => {
addFiles(event.target.files)
event.target.value = ''
})
bridge.startWorker(SPECIFIER)
const offWorkerIPC = bridge.onWorkerIPC(SPECIFIER, (data) => {
let message
try {
message = JSON.parse(decoder.decode(data))
} catch {
return
}
if (message.type === 'drives') renderDrives(message.drives)
if (message.type === 'invite') setInvite(message.invite)
})
const offWorkerExit = bridge.onWorkerExit(SPECIFIER, (code) => {
console.log('worker exited with code', code)
offWorkerIPC()
offWorkerExit()
})Run it
npm run build
# user1: create room + print invite + watch folder
npm start -- --storage /tmp/files-user1 --name user1Drop files into the path printed as My drive: in the terminal. They appear in the file list. In a second terminal:
npm start -- --storage /tmp/files-user2 --name user2 --invite <invite>user2's app lists user1's files. They are mirrored down into the shared-drives folder automatically, and each entry links to the local file:// path on disk.
Where to go next
- Create a full peer-to-peer filesystem with Hyperdrive — the underlying mechanics.
- Stream stored video in a peer-to-peer app — same scaffold, range-served blobs instead of files.
- From append-only logs to files — why a Hyperdrive ends up looking like a filesystem.