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.