Stream a live camera in a peer-to-peer app
Chunk live camera frames into Hyperblobs and serve them with hypercore-blob-server on top of the hello-pear-electron scaffold.
This guide shows you how to stream a live camera feed peer-to-peer by adapting the hello-pear-electron scaffold to push camera frames through Hyperblobs and serve them via hypercore-blob-server. The reference implementation is pear-live-cam.
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 scaffold 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). - A camera the host machine can access. The renderer uses the browser
getUserMediaAPI.
What changes
| Layer | Change |
|---|---|
| Dependencies | Add hyperblobs, hypercore-blob-server, and hypercore-id-encoding. |
| Roles | Distinguish a creator (camera owner) from viewers at startup via a CLI flag and a small role message over the worker pipe. |
| Worker | Add a Hyperblobs core in the worker; each camera fragment becomes a blob, and hypercore-blob-server serves a per-fragment HTTP link. |
| Renderer | Creator: capture MediaStream → MediaRecorder → tagged binary frames over the worker pipe. Viewer: feed each fragment's info.link into a MediaSource. |
The Electron shell, preload bridge, packaging, and graceful teardown stay as in hello-pear-electron.
Steps
Add the dependencies
npm install hyperblobs hypercore-blob-server hypercore-id-encodingDifferentiate creator vs. viewer at startup
In workers/index.js the example derives a role based on whether --invite was passed; the worker then sends that role to the renderer as a JSON role message over the pipe:
pipe.resume()
const invite = await workerTask.room.getInvite()
const role = cmd.flags.invite ? 'viewer' : 'creator'The renderer listens for the role message and either opens the camera (creator) or plays the replicated fragments (viewer).
Build the room around Hyperblobs
workers/live-cam-room.js (LiveCamRoom) uses Autobase + pairing plumbing, and adds a Hyperblobs core plus a hypercore-blob-server (both constructed in the constructor and opened in _open). Each camera fragment is written as one blob, then recorded in the view tagged with a session id and an incrementing fragIdx — that ordering is what lets viewers reassemble a continuous stream. getVideos attaches a blob-server link to every fragment so the renderer can fetch it:
async getVideos ({ limit = 100 } = {}) {
const videos = await this.view.find('@pear-live-cam/videos', { limit }).toArray()
for (const item of videos) {
if (!this.blobsCores[item.blob.key]) {
const blobsCore = this.store.get({ key: idEnc.decode(item.blob.key) })
this.blobsCores[item.blob.key] = blobsCore
await blobsCore.ready()
this.swarm.join(blobsCore.discoveryKey)
}
}
return videos.map(item => {
const link = this.blobServer.getLink(item.blob.key, { blob: item.blob })
return { ...item, info: { ...item.info, link } }
}).sort((a, b) => (a.info.session - b.info.session) || (a.info.fragIdx - b.info.fragIdx))
}
async addFragment (frag) {
const ws = this.blobs.createWriteStream()
ws.write(frag)
ws.end()
await new Promise((resolve) => ws.on('close', resolve))
const blob = { key: idEnc.normalize(this.blobs.core.key), ...ws.id }
const id = Math.random().toString(16).slice(2)
await this.base.append(
LiveCamDispatch.encode('@pear-live-cam/add-video', { id, blob, info: { session: this.session, fragIdx: this.fragIdx } })
)
if (this.fragIdx % 30 === 0) console.log('[live-cam] fragments uploaded: ' + (this.fragIdx + 1))
this.fragIdx += 1
}Pairing still uses blind-pairing — viewers join the creator's swarm via an invite code exactly as in the getting-started chat path.
Wire MediaRecorder in the renderer
In the creator path (renderer/app.js), request the camera and feed each dataavailable chunk into the worker. Video frames are large, so the renderer sends them as raw bytes over the worker pipe (tagged with a one-byte frame kind) rather than as JSON — only small control/event messages are JSON-encoded. Chromium reliably encodes WebM/VP8 only (video/webm; codecs="vp8"), so the example pins that mime type:
if (!window.MediaRecorder || !window.MediaRecorder.isTypeSupported(RECORDER_MIME)) {
throw new Error('MediaRecorder does not support ' + RECORDER_MIME)
}
stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false })
// Hidden preview keeps Chromium pumping frames into the stream.
previewEl.srcObject = stream
previewEl.play().catch(() => {})
recordingEl.classList.remove('hidden')
const recorder = new window.MediaRecorder(stream, { mimeType: RECORDER_MIME })
let firstUpload = false
recorder.ondataavailable = async (e) => {
if (!e.data || e.data.size === 0) return
if (!firstUpload) {
console.log('[live-cam] first chunk encoded, ' + e.data.size + ' bytes')
firstUpload = true
}
const buf = new Uint8Array(await e.data.arrayBuffer())
sendFragment(buf)
}
recorder.onerror = (e) => {
console.error('[live-cam] recorder error:', e?.error?.message || e)
setCaptureError(e?.error?.message || 'recorder error')
}
recorder.start(TIMESLICE_MS)In the viewer path, each replicated fragment carries an info.link from the blob server. The player appends them to a MediaSource strictly in session + fragIdx order — WebM/VP8 is one continuous stream, so a gap or out-of-order append puts the <video> element into a permanent error state.
Run it
npm run build
# creator
npm start -- --storage /tmp/cam-creator --name camera-hostCopy the invite from stdout. In a second terminal:
npm start -- --storage /tmp/cam-viewer --name viewer --invite <invite>The creator's camera preview shows in their window; the viewer's window plays the live feed off the replicated Hyperblob.
Where to go next
- Store and serve large media with Hyperblobs — the Pear/Bare blob primitive behind this app, with no UI.
- Stream stored video in a peer-to-peer app — same blob mechanics for finished video files.
- Back up photos in a peer-to-peer app — the same scaffold with
bare-ffmpeg/bare-mediafor media decoding. - Hypercore — the append-only log that backs Hyperblobs.