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 mirrors the folder into the Hyperdrive on a timer, 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 replicates every peer's Hyperdrive and mirrors it down into a per-key folder under shared-drives.
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 before the standard room → swarm → store chain. 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: an add-file message copies a chosen file into the my-drive folder (where _uploadMyDrive picks it up), while a 1-second interval starts _drives:

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:

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

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-a --name alice

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-b --name bob --invite <invite>

Bob's app lists Alice's files. They are mirrored down into his shared-drives folder automatically, and each entry links to the local file:// path on disk.

Where to go next

On this page