Compare commits

..

1 Commits

Author SHA1 Message Date
e14bb6d425 feat: implement dynamic P2P polling app with real-time synchronization
- Add complete P2P polling application with React + TypeScript frontend
- Implement Node.js backend with Yjs WebSocket and WebRTC signaling
- Support dynamic poll creation, answer management, and voting
- Add CRDT-based state synchronization using Yjs for conflict-free merging
- Implement user tracking and vote prevention (one vote per user per option)
- Create modern UI with Tailwind CSS and visual feedback
- Add comprehensive documentation and setup instructions

Features:
- Users can create polls with custom questions
- Anyone can add answer options to any poll
- Real-time voting with instant cross-client synchronization
- Smart vote tracking with visual feedback for voted options
- User attribution showing who created polls and options
- Connection status indicators for WebSocket and P2P connections

Technical:
- Hybrid P2P architecture (WebSocket + WebRTC)
- CRDT-based state management with Yjs
2026-03-25 11:51:33 +01:00
37 changed files with 8276 additions and 231 deletions

293
README.md
View File

@@ -1,24 +1,279 @@
# 🗳️ Simple P2P Poll & Chat
# P2P Poll App
This is a minimal prototype of a decentralized governance tool built for the **Demcode Evocracy** experiment. It allows for basic deliberation and voting without a central database or server-side logic.
A peer-to-peer polling application built with React, TypeScript, Tailwind CSS, Node.js, Yjs, and WebSocket for real-time collaborative voting.
## What it does
- **P2P Connectivity:** Uses WebRTC (via PeerJS) to link browsers directly.
- **Mesh-capable:** Allows multiple connections to sync the same poll state.
- **Live Sync:** Changes to the poll topic, options, or votes are broadcast to all connected peers.
- **Chat:** A simple sidebar to discuss the poll in real-time.
**Users can create polls, add answers, and vote in real-time with automatic P2P synchronization across all connected clients.**
## How to use it
1. Open the page: [https://andrewlehr.github.io/evocracy-p2p-poll/]
2. Set your **Nickname** and click "Set".
3. Share your nickname with a friend.
4. One person enters the other's nickname in the **Connect** box and clicks "Add Peer".
5. Once connected, you can set a topic, add options, and vote.
## Architecture
## Technical Notes
- **Data Persistence:** There is no database. If everyone closes their browser, the data is lost.
- **Networking:** It uses a public signaling server to find peers, but the actual data (votes/chat) stays between the browsers.
- **Conflict Handling:** Uses a simple "Last Write Wins" broadcast to keep peers in sync.
**Hybrid P2P Approach:**
- Backend serves as both a Yjs WebSocket provider (for state synchronization) and signaling server (for WebRTC peer discovery)
- Clients sync poll data via Yjs CRDT for conflict-free merging
- Direct P2P connections via WebRTC for real-time updates when possible
- Server fallback ensures reliability when P2P fails
## Transparency
This code was generated with the assistance of **Gemini (Google AI)** to quickly prototype the WebRTC networking logic. The goal was to create a clean, readable foundation that is easy for others to merge or modify during the next phase of the experiment.
## Tech Stack
**Backend:**
- Node.js + TypeScript
- Express.js
- WebSocket (ws)
- y-websocket (Yjs WebSocket provider)
- CORS support
**Frontend:**
- React 18 + TypeScript
- Vite (build tool)
- Tailwind CSS
- Yjs (CRDT library)
- y-websocket (server sync)
- y-webrtc (P2P sync)
- lucide-react (icons)
## Project Structure
```
quicgroup/
├── server/ # Backend Node.js server
│ ├── src/
│ │ ├── index.ts # Main server entry
│ │ ├── yjs-server.ts # Yjs WebSocket provider
│ │ ├── signaling-server.ts # WebRTC signaling
│ │ ├── types/ # TypeScript types
│ │ └── utils/ # Utilities
│ ├── package.json
│ └── tsconfig.json
└── frontend/ # React frontend
├── src/
│ ├── components/ # React components
│ ├── hooks/ # Custom React hooks
│ ├── lib/ # Yjs setup and utilities
│ ├── types/ # TypeScript types
│ └── styles/ # CSS styles
├── package.json
└── vite.config.ts
```
## Setup Instructions
### Prerequisites
- Node.js 18+ and npm
### Backend Setup
1. Navigate to server directory:
```bash
cd server
```
2. Install dependencies:
```bash
npm install
```
3. Copy environment file:
```bash
cp .env.example .env
```
4. Start the development server:
```bash
npm run dev
```
The server will run on `http://localhost:3000` with:
- Yjs WebSocket: `ws://localhost:3000/yjs`
- Signaling WebSocket: `ws://localhost:3000/signal`
### Frontend Setup
1. Navigate to frontend directory:
```bash
cd frontend
```
2. Install dependencies:
```bash
npm install
```
3. Copy environment file (optional):
```bash
cp .env.example .env
```
4. Start the development server:
```bash
npm run dev
```
The frontend will run on `http://localhost:5173`
## Running the Application
1. **Start Backend** (Terminal 1):
```bash
cd server
npm run dev
```
2. **Start Frontend** (Terminal 2):
```bash
cd frontend
npm run dev
```
3. **Open Browser:**
- Navigate to `http://localhost:5173`
- Open multiple tabs/windows to test P2P synchronization
## Features
### Dynamic Poll Creation
- **Create Polls** - Any user can create new polls with custom questions
- **Add Answers** - Anyone can add answer options to any poll
- **Real-time Voting** - Vote on options with instant updates across all clients
- **Smart Vote Tracking** - One vote per user per option (prevents duplicate voting)
- **Visual Feedback** - Green border and " Voted" indicator on voted options
- **User Attribution** - See who created each poll and option
- **Live Vote Counts** - See vote percentages and counts update in real-time
- **P2P Synchronization** - Uses Yjs CRDT for conflict-free state merging
- **Connection Status** - Visual indicator showing WebSocket and peer connections
- **Hybrid Architecture** - Combines WebSocket server sync with WebRTC P2P
- **Beautiful UI** - Modern gradient design with Tailwind CSS
## How to Use
### Create a Poll
1. Enter your question in the "Create a New Poll" form at the top
2. Click "Create Poll"
3. Your poll appears instantly for all connected users
### Add Answer Options
1. Find the poll you want to add an answer to
2. Type your answer in the "Add a new option..." field
3. Click "Add"
4. Your answer appears instantly for all users
### Vote on Options
1. Click the vote button (thumbs up icon) on any option
2. You can only vote once per option
3. Voted options show a green border and " Voted" indicator
4. Vote counts update in real-time across all clients
### Multi-User Testing
1. Open multiple browser tabs/windows
2. Create polls from different tabs
3. Add answers from different tabs
4. Vote from different tabs
5. Watch real-time synchronization in action!
## How It Works
### CRDT Synchronization
The app uses Yjs (a CRDT library) to ensure all clients converge to the same state without conflicts:
- Each client maintains a local Yjs document
- Changes are automatically synced via WebSocket to the server
- WebRTC provides direct P2P connections between clients
- Yjs handles merge conflicts automatically
- One vote per user per option is enforced via `votedBy` tracking
## Data Model
```typescript
{
polls: Array<{
id: string,
question: string,
createdBy: string,
timestamp: number,
options: Array<{
id: string,
text: string,
votes: number,
votedBy: string[], // Tracks which users have voted
createdBy: string,
timestamp: number
}>
}>
}
```
## Testing P2P Functionality
1. Open the app in multiple browser tabs/windows
2. **Create polls** from different tabs - they appear everywhere instantly
3. **Add answer options** from different tabs to the same poll
4. **Vote** from different tabs - watch vote counts update in real-time
5. Try voting twice on the same option - it won't let you!
6. Check the connection status indicator for peer count
7. Verify visual feedback (green border) on options you've voted on
## Development
### Backend Scripts
- `npm run dev` - Start development server with hot reload
- `npm run build` - Build for production
- `npm start` - Run production build
### Frontend Scripts
- `npm run dev` - Start Vite dev server
- `npm run build` - Build for production
- `npm run preview` - Preview production build
## Environment Variables
### Backend (.env)
```
PORT=3000
YJS_WS_PORT=1234
NODE_ENV=development
CORS_ORIGIN=http://localhost:5173
```
### Frontend (.env)
```
VITE_WS_URL=ws://localhost:3000
```
## Components
### Frontend Components
- **PollView** - Main view showing all polls and create poll form
- **CreatePoll** - Form to create new polls
- **PollCard** - Individual poll display with metadata
- **OptionList** - List of answer options with vote tracking
- **AddOption** - Form to add new answer options
- **VoteButton** - Vote button with disabled state for voted options
- **ConnectionStatus** - Shows WebSocket and P2P connection status
### Key Functions
- `createPoll(question)` - Create a new poll
- `addOption(pollId, text)` - Add an option to a specific poll
- `vote(pollId, optionId)` - Vote on an option (one vote per user)
- `hasVoted(option)` - Check if current user has voted on an option
## User Tracking
Each user gets a unique ID stored in localStorage:
- Format: `user-xxxxxxxxx`
- Used to track poll/option creators
- Used to prevent duplicate voting
- Persists across browser sessions
## Future Enhancements
- [ ] Edit/delete polls and options
- [ ] User nicknames instead of IDs
- [ ] Poll expiration/closing
- [ ] Vote history and analytics
- [ ] Export poll results
- [ ] Persistent storage (database)
- [ ] Dark mode toggle
- [ ] Mobile responsive improvements
- [ ] Poll categories/tags
- [ ] Search/filter polls
## License
MIT

1
frontend/.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_WS_URL=ws://localhost:3000

6
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
dist-ssr
*.local
.env
.DS_Store

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>P2P Poll App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4857
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
frontend/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "p2p-poll-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"yjs": "^13.6.8",
"y-websocket": "^1.5.0",
"y-webrtc": "^10.2.5",
"lucide-react": "^0.294.0"
},
"devDependencies": {
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.16",
"eslint": "^8.53.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5",
"typescript": "^5.2.2",
"vite": "^5.0.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

8
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,8 @@
import { PollView } from './components/PollView';
import './styles/index.css';
function App() {
return <PollView />;
}
export default App;

View File

@@ -0,0 +1,38 @@
import { useState } from 'react';
import { Plus } from 'lucide-react';
interface AddOptionProps {
onAdd: (text: string) => void;
}
export function AddOption({ onAdd }: AddOptionProps) {
const [text, setText] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (text.trim()) {
onAdd(text);
setText('');
}
};
return (
<form onSubmit={handleSubmit} className="flex gap-2">
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Add a new option..."
className="flex-1 px-4 py-3 rounded-lg bg-white/10 border border-white/20 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-white/30"
/>
<button
type="submit"
disabled={!text.trim()}
className="px-6 py-3 bg-white text-purple-600 rounded-lg font-semibold hover:bg-white/90 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center gap-2"
>
<Plus className="w-5 h-5" />
Add
</button>
</form>
);
}

View File

@@ -0,0 +1,34 @@
import { Wifi, WifiOff, Users } from 'lucide-react';
interface ConnectionStatusProps {
isConnected: boolean;
wsProvider: any;
webrtcProvider: any;
}
export function ConnectionStatus({ isConnected, wsProvider, webrtcProvider }: ConnectionStatusProps) {
const peerCount = webrtcProvider?.room?.peers?.size || 0;
return (
<div className="flex items-center gap-4 text-white/90 text-sm">
<div className="flex items-center gap-2">
{isConnected ? (
<>
<Wifi className="w-4 h-4 text-green-400" />
<span>Connected</span>
</>
) : (
<>
<WifiOff className="w-4 h-4 text-red-400" />
<span>Disconnected</span>
</>
)}
</div>
<div className="flex items-center gap-2">
<Users className="w-4 h-4" />
<span>{peerCount} peer{peerCount !== 1 ? 's' : ''}</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { useState } from 'react';
import { Plus } from 'lucide-react';
interface CreatePollProps {
onCreate: (question: string) => void;
}
export function CreatePoll({ onCreate }: CreatePollProps) {
const [question, setQuestion] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (question.trim()) {
onCreate(question);
setQuestion('');
}
};
return (
<form onSubmit={handleSubmit} className="mb-8">
<div className="bg-white/10 backdrop-blur-lg rounded-2xl shadow-2xl p-6 border border-white/20">
<h2 className="text-xl font-bold text-white mb-4">Create a New Poll</h2>
<div className="flex gap-2">
<input
type="text"
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="Enter your question..."
className="flex-1 px-4 py-3 rounded-lg bg-white/10 border border-white/20 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-white/30"
/>
<button
type="submit"
disabled={!question.trim()}
className="px-6 py-3 bg-white text-purple-600 rounded-lg font-semibold hover:bg-white/90 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center gap-2"
>
<Plus className="w-5 h-5" />
Create Poll
</button>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,60 @@
import { PollOption } from '../types/poll.types';
import { VoteButton } from './VoteButton';
interface OptionListProps {
options: PollOption[];
onVote: (optionId: string) => void;
hasVoted?: (option: PollOption) => boolean;
}
export function OptionList({ options, onVote, hasVoted }: OptionListProps) {
const totalVotes = options.reduce((sum, opt) => sum + opt.votes, 0);
const sortedOptions = [...options].sort((a, b) => b.votes - a.votes);
return (
<div className="space-y-3">
{sortedOptions.length === 0 ? (
<div className="text-center py-8 text-white/60">
No options yet. Add one to get started!
</div>
) : (
sortedOptions.map((option) => {
const percentage = totalVotes > 0 ? (option.votes / totalVotes) * 100 : 0;
const userHasVoted = hasVoted ? hasVoted(option) : false;
return (
<div
key={option.id}
className={`bg-white/10 backdrop-blur-sm rounded-lg p-4 border transition-all duration-200 ${
userHasVoted
? 'border-green-400/50 bg-green-400/10'
: 'border-white/20 hover:border-white/30'
}`}
>
<div className="flex items-center justify-between mb-2">
<span className="text-white font-medium text-lg">
{option.text}
{userHasVoted && <span className="ml-2 text-green-400 text-sm"> Voted</span>}
</span>
<VoteButton optionId={option.id} votes={option.votes} onVote={onVote} disabled={userHasVoted} />
</div>
<div className="w-full bg-white/10 rounded-full h-2 overflow-hidden">
<div
className="bg-gradient-to-r from-green-400 to-blue-500 h-full transition-all duration-500 ease-out"
style={{ width: `${percentage}%` }}
/>
</div>
<div className="mt-2 flex justify-between text-xs text-white/60">
<span>by {option.createdBy}</span>
<span>{percentage.toFixed(1)}%</span>
</div>
</div>
);
})
)}
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { Poll } from '../types/poll.types';
import { AddOption } from './AddOption';
import { OptionList } from './OptionList';
import { User, Clock } from 'lucide-react';
interface PollCardProps {
poll: Poll;
onAddOption: (pollId: string, text: string) => void;
onVote: (pollId: string, optionId: string) => void;
hasVoted: (option: any) => boolean;
}
export function PollCard({ poll, onAddOption, onVote, hasVoted }: PollCardProps) {
const handleAddOption = (text: string) => {
onAddOption(poll.id, text);
};
const handleVote = (optionId: string) => {
onVote(poll.id, optionId);
};
const formatTime = (timestamp: number) => {
const date = new Date(timestamp);
return date.toLocaleString();
};
return (
<div className="bg-white/10 backdrop-blur-lg rounded-2xl shadow-2xl p-8 border border-white/20 mb-6">
<div className="mb-6">
<h2 className="text-3xl font-bold text-white mb-3">{poll.question}</h2>
<div className="flex items-center gap-4 text-white/60 text-sm">
<div className="flex items-center gap-1">
<User className="w-4 h-4" />
<span>{poll.createdBy}</span>
</div>
<div className="flex items-center gap-1">
<Clock className="w-4 h-4" />
<span>{formatTime(poll.timestamp)}</span>
</div>
</div>
</div>
<div className="mb-6">
<AddOption onAdd={handleAddOption} />
</div>
<OptionList options={poll.options} onVote={handleVote} hasVoted={hasVoted} />
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { RefreshCw } from 'lucide-react';
import { usePoll } from '../hooks/usePoll';
import { CreatePoll } from './CreatePoll';
import { PollCard } from './PollCard';
import { ConnectionStatus } from './ConnectionStatus';
export function PollView() {
const { polls, createPoll, addOption, vote, hasVoted, isConnected, wsProvider, webrtcProvider } = usePoll();
return (
<div className="min-h-screen p-4 py-8">
<div className="max-w-4xl mx-auto">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-4xl font-bold text-white mb-2">P2P Polling App</h1>
<p className="text-white/70">Create polls, add answers, and vote in real-time</p>
</div>
<ConnectionStatus
isConnected={isConnected}
wsProvider={wsProvider}
webrtcProvider={webrtcProvider}
/>
</div>
<CreatePoll onCreate={createPoll} />
{polls.length === 0 ? (
<div className="bg-white/10 backdrop-blur-lg rounded-2xl shadow-2xl p-12 border border-white/20 text-center">
<p className="text-white/60 text-lg mb-2">No polls yet!</p>
<p className="text-white/40">Create the first poll to get started.</p>
</div>
) : (
<div className="space-y-6">
{polls.map((poll) => (
<PollCard
key={poll.id}
poll={poll}
onAddOption={addOption}
onVote={vote}
hasVoted={hasVoted}
/>
))}
</div>
)}
<div className="mt-8 text-center text-white/50 text-sm">
<p>QUIC P2P Experiment !</p>
<p className="flex items-center justify-center gap-2 mt-1">
<RefreshCw className="w-4 h-4" />
Real-time P2P synchronization with Yjs
</p>
<p className="mt-1">Open multiple tabs to see live updates!</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { ThumbsUp } from 'lucide-react';
interface VoteButtonProps {
optionId: string;
votes: number;
onVote: (optionId: string) => void;
disabled?: boolean;
}
export function VoteButton({ optionId, votes, onVote, disabled = false }: VoteButtonProps) {
return (
<button
onClick={() => !disabled && onVote(optionId)}
disabled={disabled}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-all duration-200 text-white font-medium ${
disabled
? 'bg-white/10 cursor-not-allowed opacity-60'
: 'bg-white/20 hover:bg-white/30'
}`}
>
<ThumbsUp className="w-4 h-4" />
<span>{votes}</span>
</button>
);
}

View File

@@ -0,0 +1,37 @@
import { useCallback } from 'react';
import { pollManager } from '../lib/poll-manager';
import { useYjsSync } from './useYjsSync';
export function usePoll() {
const { polls, isConnected, wsProvider, webrtcProvider } = useYjsSync();
const createPoll = useCallback((question: string) => {
return pollManager.createPoll(question);
}, []);
const addOption = useCallback((pollId: string, text: string) => {
pollManager.addOption(pollId, text);
}, []);
const vote = useCallback((pollId: string, optionId: string) => {
pollManager.vote(pollId, optionId);
}, []);
const hasVoted = useCallback((option: any) => {
return pollManager.hasVoted(option);
}, []);
const userId = pollManager.getUserId();
return {
polls,
createPoll,
addOption,
vote,
hasVoted,
userId,
isConnected,
wsProvider,
webrtcProvider
};
}

View File

@@ -0,0 +1,44 @@
import { useEffect, useState } from 'react';
import { Poll } from '../types/poll.types';
import {
initializeProviders,
destroyProviders,
yPolls,
wsProvider,
webrtcProvider
} from '../lib/yjs-setup';
export function useYjsSync() {
const [polls, setPolls] = useState<Poll[]>([]);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
const { wsProvider: ws } = initializeProviders();
const updatePolls = () => {
setPolls([...yPolls.toArray()]);
};
yPolls.observe(updatePolls);
updatePolls();
const handleStatus = (event: { status: string }) => {
setIsConnected(event.status === 'connected');
};
ws?.on('status', handleStatus);
return () => {
yPolls.unobserve(updatePolls);
ws?.off('status', handleStatus);
destroyProviders();
};
}, []);
return {
polls,
isConnected,
wsProvider,
webrtcProvider
};
}

View File

@@ -0,0 +1,43 @@
import { PollOption } from '../types/poll.types';
import { createPoll as yjsCreatePoll, addOption as yjsAddOption, voteForOption as yjsVoteForOption } from './yjs-setup';
export class PollManager {
private userId: string;
constructor() {
this.userId = this.generateUserId();
}
private generateUserId(): string {
const stored = localStorage.getItem('p2p-poll-user-id');
if (stored) return stored;
const newId = 'user-' + Math.random().toString(36).substr(2, 9);
localStorage.setItem('p2p-poll-user-id', newId);
return newId;
}
getUserId(): string {
return this.userId;
}
createPoll(question: string): string {
if (!question.trim()) return '';
return yjsCreatePoll(question.trim(), this.userId);
}
addOption(pollId: string, text: string): void {
if (!text.trim()) return;
yjsAddOption(pollId, text.trim(), this.userId);
}
vote(pollId: string, optionId: string): void {
yjsVoteForOption(pollId, optionId, this.userId);
}
hasVoted(option: PollOption): boolean {
return option.votedBy.includes(this.userId);
}
}
export const pollManager = new PollManager();

View File

@@ -0,0 +1,133 @@
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { WebrtcProvider } from 'y-webrtc';
import { Poll, PollOption } from '../types/poll.types';
const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:3000';
const ROOM_NAME = 'default-poll';
export const ydoc = new Y.Doc();
export const yPolls = ydoc.getArray<Poll>('polls');
export let wsProvider: WebsocketProvider | null = null;
export let webrtcProvider: WebrtcProvider | null = null;
export function initializeProviders() {
wsProvider = new WebsocketProvider(
WS_URL + '/yjs',
ROOM_NAME,
ydoc,
{ connect: true }
);
webrtcProvider = new WebrtcProvider(
ROOM_NAME,
ydoc,
{
signaling: [WS_URL.replace('ws://', 'wss://').replace('http://', 'https://') + '/signal'],
password: null,
awareness: wsProvider.awareness,
maxConns: 20,
filterBcConns: true,
peerOpts: {}
}
);
wsProvider.on('status', (event: { status: string }) => {
console.log('WebSocket status:', event.status);
});
webrtcProvider.on('synced', (synced: boolean) => {
console.log('WebRTC synced:', synced);
});
return { wsProvider, webrtcProvider };
}
export function destroyProviders() {
wsProvider?.destroy();
webrtcProvider?.destroy();
}
export function createPoll(question: string, createdBy: string): string {
const pollId = Math.random().toString(36).substr(2, 9);
const poll: Poll = {
id: pollId,
question,
createdBy,
timestamp: Date.now(),
options: []
};
yPolls.push([poll]);
return pollId;
}
export function addOption(pollId: string, text: string, createdBy: string): void {
const polls = yPolls.toArray();
const pollIndex = polls.findIndex(p => p.id === pollId);
if (pollIndex !== -1) {
const poll = polls[pollIndex];
const option: PollOption = {
id: Math.random().toString(36).substr(2, 9),
text,
votes: 0,
votedBy: [],
createdBy,
timestamp: Date.now()
};
const updatedPoll = {
...poll,
options: [...poll.options, option]
};
yPolls.delete(pollIndex, 1);
yPolls.insert(pollIndex, [updatedPoll]);
}
}
export function voteForOption(pollId: string, optionId: string, userId: string): void {
const polls = yPolls.toArray();
const pollIndex = polls.findIndex(p => p.id === pollId);
if (pollIndex !== -1) {
const poll = polls[pollIndex];
const optionIndex = poll.options.findIndex(opt => opt.id === optionId);
if (optionIndex !== -1) {
const option = poll.options[optionIndex];
if (option.votedBy.includes(userId)) {
return;
}
const updatedOption = {
...option,
votes: option.votes + 1,
votedBy: [...option.votedBy, userId]
};
const updatedOptions = [...poll.options];
updatedOptions[optionIndex] = updatedOption;
const updatedPoll = {
...poll,
options: updatedOptions
};
yPolls.delete(pollIndex, 1);
yPolls.insert(pollIndex, [updatedPoll]);
}
}
}
export function getPolls(): Poll[] {
return yPolls.toArray();
}
export function getPoll(pollId: string): Poll | undefined {
return yPolls.toArray().find(p => p.id === pollId);
}

9
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -0,0 +1,17 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}

View File

@@ -0,0 +1,22 @@
export interface PollOption {
id: string;
text: string;
votes: number;
votedBy: string[];
createdBy: string;
timestamp: number;
}
export interface Poll {
id: string;
question: string;
createdBy: string;
timestamp: number;
options: PollOption[];
}
export interface ConnectionStatus {
websocket: boolean;
webrtc: boolean;
peers: number;
}

View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

21
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

10
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
host: true
}
})

View File

@@ -1,212 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Mesh Governance Tool</title>
<script src="https://unpkg.com/peerjs@1.5.2/dist/peerjs.min.js"></script>
<style>
:root { --primary: #007AFF; --bg: #f2f2f7; --success: #34c759; --secondary: #8e8e93; }
* { box-sizing: border-box; }
body { font-family: -apple-system, sans-serif; margin: 0; background: var(--bg); height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
/* Header & Peer List */
.header { background: #fff; padding: 12px; border-bottom: 1px solid #d1d1d6; flex-shrink: 0; }
.setup-row { display: flex; gap: 8px; margin-bottom: 10px; align-items: center; }
.peer-list-container { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 4px; scrollbar-width: none; }
.peer-list-container::-webkit-scrollbar { display: none; }
.peer-chip { background: #e9ecef; padding: 4px 10px; border-radius: 12px; font-size: 11px; white-space: nowrap; border: 1px solid #dee2e6; display: flex; align-items: center; gap: 5px; }
.dot { width: 6px; height: 6px; background: var(--success); border-radius: 50%; }
/* Main Layout */
.main-container { display: flex; flex: 1; overflow: hidden; }
@media (max-width: 768px) { .main-container { flex-direction: column; } }
.column { flex: 1; display: flex; flex-direction: column; overflow: hidden; background: #fff; border-right: 1px solid #d1d1d6; }
.scroll-area { flex: 1; overflow-y: auto; padding: 16px; -webkit-overflow-scrolling: touch; }
/* Components */
.card { background: #f9f9f9; border-radius: 10px; padding: 12px; margin-bottom: 15px; border: 1px solid #e5e5ea; }
input { border: 1px solid #c6c6c8; padding: 10px; border-radius: 8px; font-size: 16px; width: 100%; outline: none; }
button { background: var(--primary); color: white; border: none; padding: 10px; border-radius: 8px; font-weight: 600; cursor: pointer; }
/* Poll UI */
.poll-question { font-size: 20px; font-weight: 700; margin-bottom: 15px; color: var(--primary); }
.option-item { display: flex; justify-content: space-between; align-items: center; background: white; padding: 12px; border-radius: 10px; margin-bottom: 8px; border: 1px solid #e5e5ea; }
/* Chat UI */
#chat-messages { display: flex; flex-direction: column; gap: 8px; }
.msg { padding: 8px 12px; border-radius: 14px; max-width: 85%; font-size: 14px; }
.msg.sent { background: var(--primary); color: white; align-self: flex-end; }
.msg.received { background: #e5e5ea; color: black; align-self: flex-start; }
.msg-sender { font-size: 10px; font-weight: bold; opacity: 0.7; display: block; }
.chat-input-bar { padding: 12px; background: #fff; border-top: 1px solid #d1d1d6; display: flex; gap: 8px; padding-bottom: calc(12px + env(safe-area-inset-bottom)); }
</style>
</head>
<body>
<div class="header">
<div class="setup-row">
<span style="font-size: 13px;">Me: <strong id="my-id-display">...</strong></span>
<div id="peer-list" class="peer-list-container">
</div>
</div>
<div class="setup-row">
<input type="text" id="nickname-input" placeholder="My Name" style="flex: 0.5;">
<button onclick="joinAs(document.getElementById('nickname-input').value)" style="background: var(--secondary); padding: 8px 12px;">Set</button>
<input type="text" id="peer-id-input" placeholder="Friend's Name" style="flex: 1;">
<button onclick="connectToPeer()" style="padding: 8px 12px;">Connect</button>
</div>
</div>
<div class="main-container">
<div class="column">
<div class="scroll-area">
<div class="card">
<input type="text" id="poll-title-input" placeholder="Question/Topic?">
<button onclick="updateTitle()" style="width:100%; margin-top:8px;">Set Topic</button>
</div>
<div id="active-question" class="poll-question">Waiting for Topic...</div>
<div style="display:flex; gap:8px; margin-bottom:15px;">
<input type="text" id="new-option-text" placeholder="Add option...">
<button onclick="addOption()">Add</button>
</div>
<div id="poll-container"></div>
</div>
</div>
<div class="column">
<div class="scroll-area" id="chat-scroll">
<div id="chat-messages"></div>
</div>
<div class="chat-input-bar">
<input type="text" id="chat-input" placeholder="Discuss...">
<button onclick="sendMessage()">Send</button>
</div>
</div>
</div>
<script>
let pollData = { question: "Open Discussion", options: [] };
let myName = "";
let connections = [];
let peer = null;
function joinAs(id) {
if (peer) peer.destroy();
peer = new Peer(id || undefined);
peer.on('open', newId => {
myName = newId;
document.getElementById('my-id-display').innerText = newId;
});
peer.on('connection', conn => setupConnection(conn));
}
function connectToPeer() {
const target = document.getElementById('peer-id-input').value;
if (target && target !== myName) {
setupConnection(peer.connect(target));
}
document.getElementById('peer-id-input').value = "";
}
function setupConnection(conn) {
conn.on('open', () => {
// Prevent duplicate connections
if (!connections.find(c => c.peer === conn.peer)) {
connections.push(conn);
updatePeerUI();
broadcast({ type: 'SYNC', payload: pollData });
}
});
conn.on('data', data => {
if (data.type === 'SYNC') {
pollData = data.payload;
renderPoll();
} else if (data.type === 'CHAT') {
appendMessage(data.sender, data.payload, 'received');
}
});
conn.on('close', () => {
connections = connections.filter(c => c.peer !== conn.peer);
updatePeerUI();
});
}
function broadcast(data) {
connections.forEach(c => { if (c.open) c.send(data); });
}
function updatePeerUI() {
const list = document.getElementById('peer-list');
if (connections.length === 0) {
list.innerHTML = '<span style="font-size:11px; color:#999;">No peers connected</span>';
return;
}
list.innerHTML = connections.map(c => `
<div class="peer-chip"><div class="dot"></div>${c.peer}</div>
`).join('');
}
// --- APP LOGIC ---
function updateTitle() {
const val = document.getElementById('poll-title-input').value;
if (!val) return;
pollData.question = val;
renderPoll();
broadcast({ type: 'SYNC', payload: pollData });
}
function addOption() {
const txt = document.getElementById('new-option-text').value;
if (!txt) return;
pollData.options.push({ text: txt, votes: 0 });
document.getElementById('new-option-text').value = "";
renderPoll();
broadcast({ type: 'SYNC', payload: pollData });
}
function vote(i) {
pollData.options[i].votes++;
renderPoll();
broadcast({ type: 'SYNC', payload: pollData });
}
function renderPoll() {
document.getElementById('active-question').innerText = pollData.question;
const container = document.getElementById('poll-container');
container.innerHTML = pollData.options.map((opt, i) => `
<div class="option-item">
<span>${opt.text}</span>
<div style="display:flex; align-items:center;">
<span style="font-weight:bold; color:var(--primary); margin-right:12px; font-size:18px;">${opt.votes}</span>
<button onclick="vote(${i})" style="background:var(--success); padding:5px 12px;">Vote</button>
</div>
</div>
`).join('');
}
function sendMessage() {
const input = document.getElementById('chat-input');
if (!input.value) return;
broadcast({ type: 'CHAT', sender: myName, payload: input.value });
appendMessage('You', input.value, 'sent');
input.value = "";
}
function appendMessage(sender, text, side) {
const div = document.createElement('div');
div.className = `msg ${side}`;
div.innerHTML = `<span class="msg-sender">${sender}</span>${text}`;
document.getElementById('chat-messages').appendChild(div);
const scroll = document.getElementById('chat-scroll');
scroll.scrollTop = scroll.scrollHeight;
}
document.getElementById('chat-input').addEventListener('keypress', (e) => { if (e.key === 'Enter') sendMessage(); });
joinAs(null);
</script>
</body>
</html>

4
server/.env.example Normal file
View File

@@ -0,0 +1,4 @@
PORT=3000
YJS_WS_PORT=1234
NODE_ENV=development
CORS_ORIGIN=http://localhost:5173

5
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
.env
*.log
.DS_Store

2102
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
server/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "p2p-poll-server",
"version": "1.0.0",
"description": "Backend server for P2P polling app with Yjs and WebRTC signaling",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"keywords": ["yjs", "websocket", "webrtc", "p2p"],
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"ws": "^8.14.2",
"y-websocket": "^1.5.0",
"yjs": "^13.6.8",
"cors": "^2.8.5",
"dotenv": "^16.3.1"
},
"devDependencies": {
"@types/express": "^4.17.20",
"@types/ws": "^8.5.8",
"@types/cors": "^2.8.15",
"@types/node": "^20.9.0",
"tsx": "^4.6.2",
"typescript": "^5.2.2"
}
}

59
server/src/index.ts Normal file
View File

@@ -0,0 +1,59 @@
import express from 'express';
import http from 'http';
import cors from 'cors';
import dotenv from 'dotenv';
import { createYjsServer } from './yjs-server';
import { createSignalingServer } from './signaling-server';
import { logger } from './utils/logger';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3000;
app.use(cors({
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
credentials: true
}));
app.use(express.json());
app.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
services: {
yjs: 'running',
signaling: 'running'
}
});
});
app.get('/', (req, res) => {
res.json({
message: 'P2P Poll Server',
endpoints: {
health: '/health',
yjs: 'ws://localhost:' + PORT + '/yjs',
signaling: 'ws://localhost:' + PORT + '/signal'
}
});
});
const server = http.createServer(app);
createYjsServer(server, PORT as number);
createSignalingServer(server);
server.listen(PORT, () => {
logger.info(`Server running on port ${PORT}`);
logger.info(`Yjs WebSocket: ws://localhost:${PORT}/yjs`);
logger.info(`Signaling WebSocket: ws://localhost:${PORT}/signal`);
});
process.on('SIGTERM', () => {
logger.info('SIGTERM signal received: closing HTTP server');
server.close(() => {
logger.info('HTTP server closed');
});
});

View File

@@ -0,0 +1,126 @@
import { WebSocketServer, WebSocket } from 'ws';
import http from 'http';
import { SignalingMessage } from './types/poll.types';
import { logger } from './utils/logger';
interface Client {
id: string;
ws: WebSocket;
roomId: string;
}
export function createSignalingServer(server: http.Server) {
const wss = new WebSocketServer({
server,
path: '/signal'
});
const clients = new Map<string, Client>();
const rooms = new Map<string, Set<string>>();
wss.on('connection', (ws: WebSocket) => {
let clientId: string | null = null;
ws.on('message', (data: Buffer) => {
try {
const message: SignalingMessage = JSON.parse(data.toString());
switch (message.type) {
case 'join':
clientId = message.from;
const roomId = message.roomId || 'default-room';
clients.set(clientId, { id: clientId, ws, roomId });
if (!rooms.has(roomId)) {
rooms.set(roomId, new Set());
}
rooms.get(roomId)!.add(clientId);
logger.info(`Client ${clientId} joined room ${roomId}`);
const roomClients = Array.from(rooms.get(roomId)!).filter(id => id !== clientId);
ws.send(JSON.stringify({
type: 'peers',
peers: roomClients
}));
roomClients.forEach(peerId => {
const peer = clients.get(peerId);
if (peer && peer.ws.readyState === WebSocket.OPEN) {
peer.ws.send(JSON.stringify({
type: 'peer-joined',
peerId: clientId
}));
}
});
break;
case 'offer':
case 'answer':
case 'ice-candidate':
if (message.to) {
const targetClient = clients.get(message.to);
if (targetClient && targetClient.ws.readyState === WebSocket.OPEN) {
targetClient.ws.send(JSON.stringify({
type: message.type,
from: message.from,
data: message.data
}));
}
}
break;
case 'leave':
handleClientLeave(message.from);
break;
}
} catch (error) {
logger.error('Error processing signaling message:', error);
}
});
ws.on('close', () => {
if (clientId) {
handleClientLeave(clientId);
}
});
ws.on('error', (error) => {
logger.error('WebSocket error:', error);
});
});
function handleClientLeave(clientId: string) {
const client = clients.get(clientId);
if (client) {
const roomId = client.roomId;
const room = rooms.get(roomId);
if (room) {
room.delete(clientId);
room.forEach(peerId => {
const peer = clients.get(peerId);
if (peer && peer.ws.readyState === WebSocket.OPEN) {
peer.ws.send(JSON.stringify({
type: 'peer-left',
peerId: clientId
}));
}
});
if (room.size === 0) {
rooms.delete(roomId);
}
}
clients.delete(clientId);
logger.info(`Client ${clientId} left room ${roomId}`);
}
}
logger.info('Signaling server running at path /signal');
return wss;
}

View File

@@ -0,0 +1,24 @@
export interface PollOption {
id: string;
text: string;
votes: number;
votedBy: string[];
createdBy: string;
timestamp: number;
}
export interface Poll {
id: string;
question: string;
createdBy: string;
timestamp: number;
options: PollOption[];
}
export interface SignalingMessage {
type: 'offer' | 'answer' | 'ice-candidate' | 'join' | 'leave';
from: string;
to?: string;
data?: any;
roomId?: string;
}

View File

@@ -0,0 +1,16 @@
export const logger = {
info: (message: string, ...args: any[]) => {
console.log(`[INFO] ${new Date().toISOString()} - ${message}`, ...args);
},
error: (message: string, ...args: any[]) => {
console.error(`[ERROR] ${new Date().toISOString()} - ${message}`, ...args);
},
warn: (message: string, ...args: any[]) => {
console.warn(`[WARN] ${new Date().toISOString()} - ${message}`, ...args);
},
debug: (message: string, ...args: any[]) => {
if (process.env.NODE_ENV === 'development') {
console.debug(`[DEBUG] ${new Date().toISOString()} - ${message}`, ...args);
}
}
};

26
server/src/yjs-server.ts Normal file
View File

@@ -0,0 +1,26 @@
import { WebSocketServer } from 'ws';
import { setupWSConnection } from 'y-websocket/bin/utils';
import http from 'http';
import { logger } from './utils/logger';
export function createYjsServer(server: http.Server, port: number) {
const wss = new WebSocketServer({
server,
path: '/yjs'
});
wss.on('connection', (ws, req) => {
const docName = req.url?.split('?')[1]?.split('=')[1] || 'default-poll';
logger.info(`New Yjs connection for document: ${docName}`);
setupWSConnection(ws, req, { docName });
});
wss.on('error', (error) => {
logger.error('Yjs WebSocket server error:', error);
});
logger.info(`Yjs WebSocket server running on port ${port} at path /yjs`);
return wss;
}

20
server/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}