# Share files in a peer-to-peer app





This guide shows you how to **swap the [`hello-pear-electron`](https://github.com/holepunchto/hello-pear-electron) room for a [Hyperdrive](/p2p/reference/building-blocks/hyperdrive)** so peers share files instead of messages. The reference implementation is [`pear-file-sharing`](https://github.com/holepunchto/pear-docs/tree/preview/examples/how-to/stream-and-share-media/file-sharing).

<Callout type="info">
  **Start from the desktop boilerplate.** This guide has a frontend, so it builds on [`hello-pear-electron`](/p2p/getting-started/from-a-template/start-from-hello-pear-electron)—the renderer, preload bridge, and Bare [worker](/pear/explanation/workers) it extends. Read [Start from the hello-pear-electron template](/p2p/getting-started/from-a-template/start-from-hello-pear-electron) first. The Pear-end logic itself is portable—only the UI is desktop-specific (see [Runtime and languages](/pear/explanation/runtime-and-languages)); the terminal template [`hello-pear-bare`](/p2p/getting-started/from-a-template/start-from-hello-pear-bare) has no UI layer. See all starting points in [Start from a template](/p2p/getting-started/from-a-template).
</Callout>

* [Create a full peer-to-peer filesystem with Hyperdrive](/p2p/how-to/stream-and-share-media/create-a-full-peer-to-peer-filesystem-with-hyperdrive)—the building block this guide layers a desktop UI on top of.

## Before you begin [#before-you-begin]

* A working clone of `hello-pear-electron` (or your own app built from the getting-started path).
* Familiarity with [Hyperdrive](/p2p/reference/building-blocks/hyperdrive) and [Localdrive](/p2p/reference/helpers/localdrive).

## What changes [#what-changes]

| Layer           | Change                                                                                                                                                                                                                                                                                       |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Dependencies    | Add [`hyperdrive`](https://www.npmjs.com/package/hyperdrive), [`localdrive`](https://www.npmjs.com/package/localdrive), and [`hypercore-id-encoding`](https://www.npmjs.com/package/hypercore-id-encoding).                                                                                  |
| Worker          | Add a `DriveRoom`: each peer owns a [Hyperdrive](/p2p/reference/building-blocks/hyperdrive) mirrored from a local `my-drive` folder, and the [Autobase](/p2p/reference/building-blocks/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 [#steps]

<Steps>
  <Step>
    ### Add the dependencies [#add-the-dependencies]

    ```bash skip="desktop-gui"
    npm install hyperdrive localdrive hypercore-id-encoding
    ```
  </Step>

  <Step>
    ### Add a `DriveRoom` worker [#add-a-driveroom-worker]

    Create `workers/drive-room.js` (`DriveRoom`) by adapting `chat-room.js`. The pairing, [Autobase](/p2p/reference/building-blocks/autobase), and writer plumbing stay the same; what changes is the data each peer publishes:

    * Each peer owns one **[Hyperdrive](/p2p/reference/building-blocks/hyperdrive)** (`this.myDrive`) backed by a local `my-drive` folder (`this.myLocalDrive`, a [`localdrive`](https://www.npmjs.com/package/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).

    ```js file=<rootDir>/examples/how-to/stream-and-share-media/file-sharing/workers/drive-room.js#L141-L167 title="workers/drive-room.js" lineNumbers {1,7,9,12-13,16,20,22-23,25-26} skip="example-import"
      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)
      }
    ```
  </Step>

  <Step>
    ### Preserve the `clearInterval` teardown [#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:

    ```js file=<rootDir>/examples/how-to/stream-and-share-media/file-sharing/workers/worker-task.js#L61-L66 title="workers/worker-task.js" lineNumbers {2,3-5} skip="example-import"
      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`](https://www.npmjs.com/package/graceful-goodbye) hook in `workers/index.js` is what fires this on SIGINT / IPC end.
  </Step>

  <Step>
    ### Surface the drives over the worker pipe [#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/`:

    ```bash skip="desktop-gui"
    npm run build:db
    ```

    The worker–renderer transport stays the plain-JSON-over-[`framed-stream`](https://www.npmjs.com/package/framed-stream) pipe inherited from hello-pear-electron, to keep this guide's diff small—see [HRPC](/p2p/reference/helpers/hrpc) for the recommended typed alternative in a real app. `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):

    ```js file=<rootDir>/examples/how-to/stream-and-share-media/file-sharing/workers/worker-task.js#L37-L59 title="workers/worker-task.js" lineNumbers {8-14,15-17,20,22} skip="example-import"
      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):

    ```js file=<rootDir>/examples/how-to/stream-and-share-media/file-sharing/workers/worker-task.js#L68-L95 title="workers/worker-task.js" lineNumbers {2,7-10,17-18,22-25,27} skip="example-import"
      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 }))
      }
    ```
  </Step>

  <Step>
    ### Update the renderer [#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):

    ```js file=<rootDir>/examples/how-to/stream-and-share-media/file-sharing/renderer/app.js title="renderer/app.js" lineNumbers {35,36,43-100,111-115,117-120,131-132} skip="example-import"
    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()
    })
    ```
  </Step>
</Steps>

## Run it [#run-it]

```bash skip="desktop-gui"
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:

```bash skip="desktop-gui"
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 [#where-to-go-next]

* [Create a full peer-to-peer filesystem with Hyperdrive](/p2p/how-to/stream-and-share-media/create-a-full-peer-to-peer-filesystem-with-hyperdrive)—the underlying mechanics.
* [Stream stored video in a peer-to-peer app](/p2p/how-to/stream-and-share-media/stream-stored-video-in-a-peer-to-peer-app)—same scaffold, range-served blobs instead of files.
* [From append-only logs to files](/p2p/explanation/from-logs-to-files)—why a [Hyperdrive](/p2p/reference/building-blocks/hyperdrive) ends up looking like a filesystem.
