11 KiB
P2P Poll App — Implementation Plan
A simple peer-to-peer polling app where users can add options and vote, built with no central server (beyond a public relay for WebSocket sync).
1. Project Setup
# Scaffold a Waku project with Bun
bunx create-waku@latest . --template basic --package-manager bun
# Install dependencies
bun add @automerge/automerge @automerge/automerge-repo @automerge/automerge-repo-react-hooks @automerge/automerge-repo-network-websocket @automerge/automerge-repo-network-broadcastchannel @automerge/automerge-repo-storage-indexeddb
# Tailwind CSS v4
bun add tailwindcss @tailwindcss/vite
Waku uses Vite under the hood, so add @tailwindcss/vite to the Vite plugin list in waku.config.ts (see file structure below). Import Tailwind in a global CSS file with @import "tailwindcss".
2. Data Model
A single Automerge document represents one poll:
interface Poll {
title: string;
options: PollOption[];
}
interface PollOption {
id: string; // nanoid or crypto.randomUUID()
text: string;
votes: string[]; // list of peer IDs who voted for this option
}
- votes as
string[]— storing peer IDs instead of a count lets us prevent double-voting and support vote retracting. The count is derived via.length. - Each peer generates a stable random ID on first visit (stored in
localStorage).
3. File Structure
waku.config.ts — Waku + Vite config (Tailwind plugin)
src/
pages/
_layout.tsx — root layout (server component), imports Providers + global CSS
index.tsx — landing page: create new poll or join via URL
poll/
[id].tsx — main poll view (resolved from Automerge document URL)
components/
Providers.tsx — 'use client': initializes Automerge Repo + RepoContext.Provider
PollView.tsx — displays poll title, options, votes; contains AddOption form and VoteButtons
ConnectionStatus.tsx — shows sync/connection state
lib/
repo.ts — creates and exports the Automerge Repo singleton
types.ts — Poll and PollOption type definitions
peer.ts — get-or-create local peer ID from localStorage
poll.ts — poll mutation logic (pure functions)
__tests__/
poll.test.ts — tests for poll logic
styles/
global.css — Tailwind import
Total: ~12 files (5 components/pages + 1 config + 4 lib modules + 1 test file + 1 CSS)
4. Implementation Steps
Step 1 — Scaffold and configure
- Run the setup commands from Section 1.
- Create
waku.config.ts:import { defineConfig } from 'waku/config'; import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ vite: { plugins: [tailwindcss()], }, }); - Create
src/styles/global.csswith@import "tailwindcss". - Verify
bun run devstarts without errors.
Step 2 — Define types, peer identity, and poll logic
- Create
src/lib/types.tswith thePollandPollOptioninterfaces. - Create
src/lib/peer.ts: readlocalStorage.getItem("peerId"), or generate one withcrypto.randomUUID()and persist it. - Create
src/lib/poll.ts: extract poll mutation logic into pure functions (createPoll,addOption,vote,unvote,hasVoted) so they are testable independently of Automerge and React.
Step 3 — Initialize Automerge Repo
- Create
src/lib/repo.ts:import { Repo } from "@automerge/automerge-repo"; import { WebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket"; import { BroadcastChannelNetworkAdapter } from "@automerge/automerge-repo-network-broadcastchannel"; import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb"; export const repo = new Repo({ network: [ new WebSocketClientAdapter("wss://sync.automerge.org"), new BroadcastChannelNetworkAdapter(), ], storage: new IndexedDBStorageAdapter("p2p-poll"), });
Sync server note:
wss://sync.automerge.orgis a free public relay provided by the Automerge team. For production use or better reliability, you can run your own sync server using@automerge/automerge-repo-network-websocket's server adapter with a simple Node/Bun WebSocket server.
Step 4 — Providers (client component) and root layout (server component)
Waku layouts are server components — they cannot use browser APIs or React context directly. Instead, create a client component wrapper:
-
Create
src/components/Providers.tsx:'use client'; import { RepoContext } from "@automerge/automerge-repo-react-hooks"; import { repo } from "../lib/repo"; export const Providers = ({ children }: { children: React.ReactNode }) => { return ( <RepoContext.Provider value={repo}> {children} </RepoContext.Provider> ); }; -
Create
src/pages/_layout.tsx(server component — no'use client'):import '../styles/global.css'; import { Providers } from '../components/Providers'; export default async function RootLayout({ children }: { children: React.ReactNode }) { return ( <Providers> {children} </Providers> ); }
Step 5 — Landing page (create / join)
src/pages/index.tsx('use client'):- "Create Poll" button: calls
repo.create()to make a new Automerge doc, initializes it with a default title and empty options array, then navigates to/poll/<documentId>. - "Join Poll" input: paste a document ID or full URL, navigate to
/poll/<id>. - Show a list of recently visited polls from
localStoragefor convenience.
- "Create Poll" button: calls
Step 6 — Poll view page
src/pages/poll/[id].tsx('use client'):- Read
idfrom the route segment prop (Waku passes dynamic segments as props automatically). - Reconstruct the Automerge
DocumentUrlfromid. - Use
useDocument<Poll>(url)from@automerge/automerge-repo-react-hooksto get[doc, changeDoc]. - Wrap the document-dependent UI in
<Suspense fallback={<div>Loading poll...</div>}>—useDocumentmay suspend while the document is being fetched/synced. - Render
<PollView doc={doc} changeDoc={changeDoc} />and<ConnectionStatus />.
- Read
Step 7 — PollView component
src/components/PollView.tsx('use client'):- Display poll title (editable inline).
- List each option with its text, vote count (
option.votes.length), and a Vote/Unvote button. - Vote button logic:
changeDoc(d => { const opt = d.options.find(o => o.id === optionId); if (!opt.votes.includes(peerId)) { opt.votes.push(peerId); } }); - Unvote: filter out the peer ID.
- "Add Option" form at the bottom: text input + submit button that pushes a new
PollOptionintodoc.options. - Show a shareable link/document ID so users can invite peers.
Step 8 — ConnectionStatus component
src/components/ConnectionStatus.tsx('use client'):- Simple indicator (green dot / grey dot) based on whether the WebSocket adapter is connected.
- Can use
repo.networkSubsystem.on("peer", ...)to track connected peers.
Step 9 — Polish and test
- Style with Tailwind: center layout, card-style poll, colored vote bars.
- Test with two browser tabs (BroadcastChannel sync).
- Test with two devices on the same network pointing at the sync server.
- Handle loading state (doc is
undefineduntil synced — use<Suspense>or a null check). - Handle invalid/missing document IDs gracefully.
5. UI Components Summary
| Component | Responsibility |
|---|---|
_layout.tsx |
Server-component root layout, imports Providers + global styles |
Providers.tsx |
Client component: Repo initialization, RepoContext provider |
index.tsx |
Create new poll, join existing poll by ID |
[id].tsx |
Load Automerge doc by URL, wrap in Suspense, pass to PollView |
PollView.tsx |
Render poll title, options list, vote buttons, add-option form, share link |
ConnectionStatus.tsx |
Show online/offline and peer count |
6. P2P Setup Details
How peers connect:
- Creator makes a new poll — Automerge generates a unique document URL (e.g.,
automerge:2Xp9Q3...). - The app extracts the document ID and puts it in the page URL:
https://localhost:3000/poll/2Xp9Q3.... - Creator shares that URL with others (copy button in UI).
- When another peer opens the URL, the app reconstructs the Automerge document URL and calls
useDocument(url)— Automerge syncs the doc via the WebSocket relay automatically. - Cross-tab sync happens instantly via BroadcastChannel (no network needed).
- IndexedDB ensures the poll persists across page reloads.
No server to deploy — the public wss://sync.automerge.org relay handles document sync. For production, consider running your own sync server for reliability and control.
8. Tests
Tests use Bun's built-in test runner (bun test) — no extra test dependencies needed. Bun provides describe, it, and expect out of the box.
Tests target the pure functions in src/lib/poll.ts, not React components. Test file: src/lib/__tests__/poll.test.ts.
Pure functions under test:
createPoll(title: string): PolladdOption(poll: Poll, text: string): Pollvote(poll: Poll, optionId: string, peerId: string): Pollunvote(poll: Poll, optionId: string, peerId: string): PollhasVoted(poll: Poll, optionId: string, peerId: string): boolean
Test cases (6 total):
- Peer ID management —
getPeerId()generates a UUID and returns the same value on subsequent calls (persisted in localStorage) - Poll creation —
createPoll("Lunch?")returns a poll with the given title and an empty options array - Adding an option —
addOption(poll, "Pizza")appends an option with the given text, a generated ID, and an empty votes array - Voting —
vote(poll, optionId, peerId)adds the peer ID to that option's votes array - Double-vote prevention — calling
votetwice with the same peer ID and option does not duplicate the entry - Vote retracting —
unvote(poll, optionId, peerId)removes the peer ID from the votes array
Run with:
bun test
9. Nice-to-Haves (if time permits)
- Poll title editing — inline editable title field (trivial with
changeDoc). - Vote retracting — toggle vote on/off (already supported by the data model).
- Peer count display — show how many peers are currently connected to this document.
- Option reordering — drag to reorder or sort by vote count.
- Results visualization — simple bar chart showing vote distribution using Tailwind width utilities.
- Multiple polls — list/manage multiple polls from the landing page.
- Poll closing — creator can lock a poll to prevent further votes.