LogoPear Docs
How ToStream and share media

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.

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

LayerChange
DependenciesAdd hyperdrive, localdrive, and hypercore-id-encoding.
WorkerAdd 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 teardownCancel the mirror/file-list interval timers in _close before closing the room → swarm → store.
Worker messagesPush a drives message (each drive plus its files) and handle an add-file message that copies a chosen file into my-drive.
RendererRender a per-drive file list with an "add file" picker.

Steps

Add the dependencies

npm install hyperdrive localdrive hypercore-id-encoding

Add 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 local my-drive folder (this.myLocalDrive, a localdrive). _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 into my-drive publishes 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-key LocalDrive under shared-drives (L147), reuses myDrive or constructs a peer Hyperdrive from the key (L149), mirrors the drive down on every append (L152–L153), and joins the swarm on its discovery key (L156).
workers/drive-room.js
  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:

workers/worker-task.js
  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:db

The 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):

workers/worker-task.js
  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):

workers/worker-task.js
  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):

renderer/app.js
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 user1

Drop 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

On this page