Files
427e7578-d7bf-49c8-aee9-2dd…/PLAN.md
2026-03-08 13:49:28 +00:00

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.css with @import "tailwindcss".
  • Verify bun run dev starts without errors.

Step 2 — Define types, peer identity, and poll logic

  • Create src/lib/types.ts with the Poll and PollOption interfaces.
  • Create src/lib/peer.ts: read localStorage.getItem("peerId"), or generate one with crypto.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.org is 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 localStorage for convenience.

Step 6 — Poll view page

  • src/pages/poll/[id].tsx ('use client'):
    • Read id from the route segment prop (Waku passes dynamic segments as props automatically).
    • Reconstruct the Automerge DocumentUrl from id.
    • Use useDocument<Poll>(url) from @automerge/automerge-repo-react-hooks to get [doc, changeDoc].
    • Wrap the document-dependent UI in <Suspense fallback={<div>Loading poll...</div>}>useDocument may suspend while the document is being fetched/synced.
    • Render <PollView doc={doc} changeDoc={changeDoc} /> and <ConnectionStatus />.

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 PollOption into doc.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 undefined until 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:

  1. Creator makes a new poll — Automerge generates a unique document URL (e.g., automerge:2Xp9Q3...).
  2. The app extracts the document ID and puts it in the page URL: https://localhost:3000/poll/2Xp9Q3....
  3. Creator shares that URL with others (copy button in UI).
  4. 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.
  5. Cross-tab sync happens instantly via BroadcastChannel (no network needed).
  6. 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): Poll
  • addOption(poll: Poll, text: string): Poll
  • vote(poll: Poll, optionId: string, peerId: string): Poll
  • unvote(poll: Poll, optionId: string, peerId: string): Poll
  • hasVoted(poll: Poll, optionId: string, peerId: string): boolean

Test cases (6 total):

  • Peer ID managementgetPeerId() generates a UUID and returns the same value on subsequent calls (persisted in localStorage)
  • Poll creationcreatePoll("Lunch?") returns a poll with the given title and an empty options array
  • Adding an optionaddOption(poll, "Pizza") appends an option with the given text, a generated ID, and an empty votes array
  • Votingvote(poll, optionId, peerId) adds the peer ID to that option's votes array
  • Double-vote prevention — calling vote twice with the same peer ID and option does not duplicate the entry
  • Vote retractingunvote(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.