Compare commits

..

1 Commits

Author SHA1 Message Date
f4d6a97abe updated 2026-04-14 18:31:48 +01:00
49 changed files with 8755 additions and 2314 deletions

2
.gitignore vendored
View File

@@ -1,2 +0,0 @@
dist
node_modules

280
README.md
View File

@@ -1,44 +1,280 @@
# P2P Poll App
A peer-to-peer polling application where users create and vote on polls without any central server. All data syncs directly between browsers using WebRTC and CRDTs.
A peer-to-peer polling application built with React, TypeScript, Tailwind CSS, Node.js, Yjs, and WebSocket for real-time collaborative voting.
## Features
**Users can create polls, add answers, and vote in real-time with automatic P2P synchronization across all connected clients.**
- **Real-time P2P sync** — poll options and votes sync instantly across all connected peers via WebRTC
- **Collaborative poll title** — editable title that syncs between all participants
- **One vote per user** — each peer gets a stable ID, enforcing one vote per person per option
- **Vote/Unvote toggle** — change your mind anytime
- **Connection status** — see when you're connected and how many peers are in the room
- **Shareable polls** — share via URL with `?room=your-poll-name`
- **No backend required** — runs entirely in the browser
## Architecture
**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
## Tech Stack
- [Yjs](https://yjs.dev/) — CRDT library for conflict-free shared state
- [y-webrtc](https://github.com/yjs/y-webrtc) — WebRTC provider for peer-to-peer connections
- [Vite](https://vitejs.dev/) — Development server and build tool
- Vanilla JavaScript — no framework dependencies
**Backend:**
- Node.js + TypeScript
- Express.js
- WebSocket (ws)
- y-websocket (Yjs WebSocket provider)
- CORS support
## Getting Started
**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
```
Open `http://localhost:5173/?room=my-poll` in multiple browser tabs to test.
The server will run on `http://localhost:3000` with:
- Yjs WebSocket: `ws://localhost:3000/yjs`
- Signaling WebSocket: `ws://localhost:3000/signal`
To test across devices on the same network:
### Frontend Setup
1. Navigate to frontend directory:
```bash
npm run dev -- --host
cd frontend
```
Then open the URL shown in the terminal on other devices.
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
1. Each browser tab creates a Yjs document and connects to other peers via WebRTC
2. Poll options and votes are stored in Yjs shared data types (Y.Map)
3. Changes propagate automatically to all connected peers using CRDTs
4. A public signaling server handles peer discovery; all poll data flows directly between browsers
### 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:5000

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,50 @@
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(() => {
console.log('[SYNC] Initializing Yjs sync hook');
const { wsProvider: ws } = initializeProviders();
const updatePolls = () => {
const currentPolls = Array.from(yPolls.values());
console.log('[SYNC] Polls updated, count:', currentPolls.length);
setPolls(currentPolls);
};
yPolls.observe(updatePolls);
updatePolls();
const handleStatus = (event: { status: string }) => {
console.log('[SYNC] WebSocket status event:', event.status);
const connected = event.status === 'connected';
setIsConnected(connected);
console.log('[SYNC] Connection state set to:', 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,287 @@
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:5000';
const ROOM_NAME = 'default-poll';
function getSignalingUrl(wsUrl: string): string {
try {
const url = new URL(wsUrl);
const protocol = url.protocol === 'ws:' ? 'ws' : 'wss';
return `${protocol}://${url.host}/signal`;
} catch (error) {
console.error('Invalid WebSocket URL:', wsUrl, error);
return wsUrl + '/signal';
}
}
export const ydoc = new Y.Doc();
export const yPolls = ydoc.getMap<Poll>('polls');
export let wsProvider: WebsocketProvider | null = null;
export let webrtcProvider: WebrtcProvider | null = null;
let wsReconnectAttempts = 0;
let webrtcReconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 5;
const BASE_RECONNECT_DELAY = 1000;
interface ConnectionMetrics {
rtt: number;
bandwidth: number;
packetLoss: number;
lastUpdated: number;
}
const connectionMetrics: ConnectionMetrics = {
rtt: 0,
bandwidth: 0,
packetLoss: 0,
lastUpdated: Date.now()
};
function getReconnectDelay(attempts: number): number {
return Math.min(BASE_RECONNECT_DELAY * Math.pow(2, attempts), 30000);
}
export function getConnectionMetrics(): ConnectionMetrics {
return { ...connectionMetrics };
}
export function initializeProviders() {
console.log('[INIT] Initializing providers with WS_URL:', WS_URL);
console.log('[INIT] Connecting to WebSocket:', WS_URL + '/yjs');
console.log('[INIT] Room name:', ROOM_NAME);
console.log('[INIT] Signaling URL:', getSignalingUrl(WS_URL));
wsProvider = new WebsocketProvider(
WS_URL + '/yjs',
ROOM_NAME,
ydoc,
{ connect: true }
);
webrtcProvider = new WebrtcProvider(
ROOM_NAME,
ydoc,
{
signaling: [getSignalingUrl(WS_URL)],
password: null,
awareness: wsProvider.awareness,
maxConns: 20,
filterBcConns: true,
peerOpts: {}
}
);
wsProvider.on('status', (event: { status: string }) => {
console.log('[WS] WebSocket status changed:', event.status);
if (event.status === 'connected') {
wsReconnectAttempts = 0;
console.log('[WS] Successfully connected to WebSocket');
} else if (event.status === 'disconnected') {
console.log('[WS] WebSocket disconnected');
if (wsReconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
const delay = getReconnectDelay(wsReconnectAttempts);
console.log(`[WS] Reconnecting in ${delay}ms (attempt ${wsReconnectAttempts + 1})`);
setTimeout(() => {
wsReconnectAttempts++;
try {
wsProvider?.connect();
} catch (error) {
console.error('[WS] Failed to reconnect:', error);
}
}, delay);
} else {
console.error('[WS] Max reconnection attempts reached');
}
}
});
wsProvider.on('connection-error', (error: any) => {
console.error('[WS] Connection error:', error);
});
webrtcProvider.on('synced', (synced: boolean) => {
console.log('[WEBRTC] Synced:', synced);
if (synced) {
webrtcReconnectAttempts = 0;
}
});
webrtcProvider.on('peers', (peers: any) => {
console.log('[WEBRTC] Peers changed:', peers.size, 'peers');
if (peers.size === 0 && webrtcReconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
const delay = getReconnectDelay(webrtcReconnectAttempts);
console.log(`[WEBRTC] No peers, reconnecting in ${delay}ms (attempt ${webrtcReconnectAttempts + 1})`);
setTimeout(() => {
webrtcReconnectAttempts++;
try {
webrtcProvider?.connect();
} catch (error) {
console.error('[WEBRTC] Failed to reconnect:', error);
}
}, delay);
} else if (peers.size > 0) {
webrtcReconnectAttempts = 0;
console.log('[WEBRTC] Connected to', peers.size, 'peers');
}
});
// Periodically collect WebRTC stats
const statsInterval = setInterval(async () => {
if (webrtcProvider && webrtcProvider.room) {
let totalRtt = 0;
let totalBandwidth = 0;
let peerCount = 0;
// Access peers through the room's internal structure
const room = webrtcProvider.room as any;
if (room.peers) {
for (const peer of room.peers.values()) {
try {
if (peer.peerConnection) {
const stats = await peer.peerConnection.getStats();
stats.forEach((report: any) => {
if (report.type === 'remote-inbound-rtp' || report.type === 'inbound-rtp') {
totalRtt += report.roundTripTime || 0;
}
if (report.type === 'outbound-rtp') {
totalBandwidth += report.bytesSent || 0;
}
});
peerCount++;
}
} catch (error) {
console.error('Failed to get WebRTC stats:', error);
}
}
}
if (peerCount > 0) {
connectionMetrics.rtt = totalRtt / peerCount;
connectionMetrics.bandwidth = totalBandwidth;
connectionMetrics.packetLoss = 0; // Would need more complex calculation
connectionMetrics.lastUpdated = Date.now();
}
}
}, 5000);
// Cleanup stats interval on destroy
const originalDestroy = webrtcProvider.destroy;
webrtcProvider.destroy = function() {
clearInterval(statsInterval);
originalDestroy.call(this);
};
return { wsProvider, webrtcProvider };
}
export function destroyProviders() {
wsProvider?.destroy();
webrtcProvider?.destroy();
}
export function createPoll(question: string, createdBy: string): string {
const pollId = crypto.randomUUID();
const poll: Poll = {
id: pollId,
question,
createdBy,
timestamp: Date.now(),
options: []
};
yPolls.set(pollId, poll);
return pollId;
}
export function addOption(pollId: string, text: string, createdBy: string): void {
try {
ydoc.transact(() => {
const poll = yPolls.get(pollId);
if (!poll) {
console.error(`Poll not found: ${pollId}`);
throw new Error('Poll not found');
}
const option: PollOption = {
id: crypto.randomUUID(),
text,
votes: 0,
votedBy: [],
createdBy,
timestamp: Date.now()
};
const updatedPoll = {
...poll,
options: [...poll.options, option]
};
yPolls.set(pollId, updatedPoll);
});
} catch (error) {
console.error('Failed to add option:', error);
throw error;
}
}
export function voteForOption(pollId: string, optionId: string, userId: string): void {
try {
ydoc.transact(() => {
const poll = yPolls.get(pollId);
if (!poll) {
console.error(`Poll not found: ${pollId}`);
throw new Error('Poll not found');
}
const optionIndex = poll.options.findIndex(opt => opt.id === optionId);
if (optionIndex === -1) {
console.error(`Option not found: ${optionId}`);
throw new Error('Option not found');
}
const option = poll.options[optionIndex];
if (option.votedBy.includes(userId)) {
console.log(`User ${userId} already voted for option ${optionId}`);
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.set(pollId, updatedPoll);
});
} catch (error) {
console.error('Failed to vote for option:', error);
throw error;
}
}
export function getPolls(): Poll[] {
return Array.from(yPolls.values());
}
export function getPoll(pollId: string): Poll | undefined {
return yPolls.get(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,13 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Polly — P2P Polls</title>
<link rel="stylesheet" href="/src/style.css">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1437
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +0,0 @@
{
"name": "p2p-poll",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"yjs": "^13.6.0",
"y-webrtc": "^10.3.0"
},
"devDependencies": {
"vite": "^6.0.0"
}
}

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

2112
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
server/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"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": {
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"ws": "^8.14.2",
"y-websocket": "^1.5.0",
"yjs": "^13.6.8",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/cors": "^2.8.15",
"@types/express": "^4.17.20",
"@types/node": "^20.9.0",
"@types/ws": "^8.5.8",
"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 || 5000;
app.use(cors({
origin: process.env.CORS_ORIGIN || ['http://localhost:5173', 'http://localhost:5174', 'http://localhost:5175'],
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,305 @@
import { WebSocketServer, WebSocket } from 'ws';
import http from 'http';
import { SignalingMessage, SignalingMessageSchema } from './types/poll.types';
import { logger } from './utils/logger';
interface Client {
id: string;
ws: WebSocket;
roomId: string;
lastSeen: number;
messageCount: number;
lastMessageTime: number;
}
export function createSignalingServer(server: http.Server) {
const wss = new WebSocketServer({
noServer: true
});
// Handle upgrade requests for /signal path
server.on('upgrade', (request, socket, head) => {
const pathname = request.url || '';
if (pathname === '/signal' || pathname.startsWith('/signal?')) {
logger.info(`[SIGNALING] Upgrade request for path: ${pathname}`);
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
}
});
const clients = new Map<string, Client>();
const rooms = new Map<string, Set<string>>();
const roomPasswords = new Map<string, string>();
const HEARTBEAT_INTERVAL = 30000;
const CLIENT_TIMEOUT = 60000;
const RATE_LIMIT_WINDOW = 1000;
const RATE_LIMIT_MAX = 10;
const heartbeatInterval = setInterval(() => {
const now = Date.now();
clients.forEach((client, clientId) => {
if (now - client.lastSeen > CLIENT_TIMEOUT) {
logger.info(`Client ${clientId} timed out, removing...`);
handleClientLeave(clientId);
} else {
client.ws.send(JSON.stringify({ type: 'ping' }));
}
});
}, HEARTBEAT_INTERVAL);
wss.on('connection', (ws: WebSocket) => {
let clientId: string | null = null;
const tempClientId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
logger.info(`[SIGNALING] New WebSocket connection (temp: ${tempClientId})`);
ws.on('message', (data: Buffer) => {
try {
const parsed = JSON.parse(data.toString());
const validationResult = SignalingMessageSchema.safeParse(parsed);
if (!validationResult.success) {
logger.error('Invalid signaling message:', validationResult.error);
ws.send(JSON.stringify({
type: 'error',
message: 'Invalid message format',
errors: validationResult.error.issues
}));
return;
}
const message: SignalingMessage = validationResult.data;
if (clientId) {
const client = clients.get(clientId);
if (client) {
const now = Date.now();
if (now - client.lastMessageTime < RATE_LIMIT_WINDOW) {
client.messageCount++;
if (client.messageCount > RATE_LIMIT_MAX) {
logger.warn(`Client ${clientId} exceeded rate limit`);
ws.send(JSON.stringify({
type: 'error',
message: 'Rate limit exceeded'
}));
return;
}
} else {
client.messageCount = 1;
client.lastMessageTime = now;
}
}
}
switch (message.type) {
case 'subscribe':
// y-webrtc subscribe message - client wants to join topics
if (message.topics) {
message.topics.forEach((topic: string) => {
if (!rooms.has(topic)) {
rooms.set(topic, new Set());
}
const tempId = `sub-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
clientId = tempId;
rooms.get(topic)!.add(tempId);
clients.set(tempId, {
id: tempId,
ws,
roomId: topic,
lastSeen: Date.now(),
messageCount: 0,
lastMessageTime: Date.now()
});
logger.info(`[SIGNALING] Client subscribed to topic: ${topic}`);
});
}
break;
case 'unsubscribe':
// y-webrtc unsubscribe message
if (message.topics && clientId) {
message.topics.forEach((topic: string) => {
const room = rooms.get(topic);
if (room && clientId) {
room.delete(clientId);
if (room.size === 0) {
rooms.delete(topic);
}
}
});
}
break;
case 'publish':
// y-webrtc publish message - broadcast to all subscribers of a topic
if (message.topic) {
const topic = message.topic;
const room = rooms.get(topic);
if (room) {
room.forEach((subscriberId) => {
const subscriber = clients.get(subscriberId);
if (subscriber && subscriber.ws !== ws && subscriber.ws.readyState === WebSocket.OPEN) {
subscriber.ws.send(JSON.stringify({
type: 'publish',
topic: topic,
data: message.data
}));
}
});
}
}
break;
case 'join':
clientId = message.from || `client-${Date.now()}`;
const roomId = message.roomId || 'default-room';
const roomPassword = roomPasswords.get(roomId);
if (roomPassword && message.password !== roomPassword) {
logger.warn(`Client ${clientId} failed password authentication for room ${roomId}`);
ws.send(JSON.stringify({
type: 'error',
message: 'Invalid room password'
}));
return;
}
clients.set(clientId, {
id: clientId,
ws,
roomId,
lastSeen: Date.now(),
messageCount: 0,
lastMessageTime: Date.now()
});
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':
if (message.from) {
handleClientLeave(message.from);
}
break;
case 'create-room':
if (message.roomId && message.password) {
roomPasswords.set(message.roomId, message.password);
logger.info(`Room ${message.roomId} created with password protection`);
ws.send(JSON.stringify({
type: 'room-created',
roomId: message.roomId
}));
}
break;
case 'ping':
if (clientId) {
const client = clients.get(clientId);
if (client) {
client.lastSeen = Date.now();
client.ws.send(JSON.stringify({ type: 'pong', from: 'server' }));
}
}
break;
case 'pong':
if (clientId) {
const client = clients.get(clientId);
if (client) {
client.lastSeen = Date.now();
}
}
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}`);
}
}
wss.on('close', () => {
clearInterval(heartbeatInterval);
logger.info('Signaling server closed');
});
logger.info('Signaling server running at path /signal');
return wss;
}

View File

@@ -0,0 +1,40 @@
import { z } from 'zod';
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 const SignalingMessageSchema = z.object({
type: z.enum(['offer', 'answer', 'ice-candidate', 'join', 'leave', 'ping', 'pong', 'create-room', 'subscribe', 'unsubscribe', 'publish', 'signal']),
from: z.string().optional(),
to: z.string().optional(),
data: z.any().optional(),
roomId: z.string().optional(),
password: z.string().optional(),
topics: z.array(z.string()).optional(),
topic: z.string().optional()
});
export interface SignalingMessage {
type: 'offer' | 'answer' | 'ice-candidate' | 'join' | 'leave' | 'ping' | 'pong' | 'create-room' | 'subscribe' | 'unsubscribe' | 'publish' | 'signal';
from?: string;
to?: string;
data?: any;
roomId?: string;
password?: string;
topics?: string[];
topic?: string;
}

115
server/src/utils/logger.ts Normal file
View File

@@ -0,0 +1,115 @@
type LogLevel = 'info' | 'error' | 'warn' | 'debug';
type LogContext = Record<string, any>;
interface LogEntry {
timestamp: string;
level: LogLevel;
message: string;
context?: LogContext;
}
class Logger {
private context: LogContext = {};
private timers: Map<string, number> = new Map();
setContext(ctx: LogContext): void {
this.context = { ...this.context, ...ctx };
}
clearContext(): void {
this.context = {};
}
private formatLog(level: LogLevel, message: string, args: any[]): LogEntry {
const entry: LogEntry = {
timestamp: new Date().toISOString(),
level,
message,
context: Object.keys(this.context).length > 0 ? { ...this.context } : undefined
};
if (args.length > 0) {
if (entry.context) {
entry.context.args = args;
} else {
entry.context = { args };
}
}
return entry;
}
private log(level: LogLevel, message: string, ...args: any[]): void {
const entry = this.formatLog(level, message, args);
const logString = JSON.stringify(entry);
switch (level) {
case 'info':
console.log(logString);
break;
case 'error':
console.error(logString);
break;
case 'warn':
console.warn(logString);
break;
case 'debug':
if (process.env.NODE_ENV === 'development') {
console.debug(logString);
}
break;
}
}
info(message: string, ...args: any[]): void {
this.log('info', message, ...args);
}
error(message: string, ...args: any[]): void {
this.log('error', message, ...args);
}
warn(message: string, ...args: any[]): void {
this.log('warn', message, ...args);
}
debug(message: string, ...args: any[]): void {
this.log('debug', message, ...args);
}
startTimer(label: string): void {
this.timers.set(label, Date.now());
}
endTimer(label: string): number {
const startTime = this.timers.get(label);
if (!startTime) {
this.warn(`Timer '${label}' not found`);
return 0;
}
const duration = Date.now() - startTime;
this.timers.delete(label);
this.debug(`Timer '${label}': ${duration}ms`);
return duration;
}
time<T>(label: string, fn: () => T): T {
this.startTimer(label);
try {
return fn();
} finally {
this.endTimer(label);
}
}
async timeAsync<T>(label: string, fn: () => Promise<T>): Promise<T> {
this.startTimer(label);
try {
return await fn();
} finally {
this.endTimer(label);
}
}
}
export const logger = new Logger();

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

@@ -0,0 +1,51 @@
import { WebSocketServer } from 'ws';
// @ts-ignore
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({
noServer: true
});
// Handle upgrade requests for /yjs/* paths
server.on('upgrade', (request, socket, head) => {
const pathname = request.url || '';
if (pathname.startsWith('/yjs')) {
logger.info(`[YJS] Upgrade request for path: ${pathname}`);
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
}
});
wss.on('connection', (ws, req) => {
const url = req.url || 'unknown';
const remoteAddress = req.socket.remoteAddress || 'unknown';
logger.info(`[YJS] New connection from ${remoteAddress}, URL: ${url}`);
// Log when connection closes
ws.on('close', () => {
logger.info(`[YJS] Connection closed from ${remoteAddress}`);
});
ws.on('error', (error) => {
logger.error(`[YJS] Connection error from ${remoteAddress}:`, error);
});
// y-websocket automatically handles docName from the URL path
// The room name is passed as part of the URL: /yjs/room-name
// We don't need to manually extract it
setupWSConnection(ws, req, { gc: true });
});
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"]
}

View File

@@ -1,47 +0,0 @@
import { addOption } from '../utils/store.js';
export function AddOption() {
const wrapper = document.createElement('div');
wrapper.className = 'add-option-wrapper';
const input = document.createElement('input');
input.type = 'text';
input.className = 'add-option-input';
input.placeholder = 'Add an option…';
input.maxLength = 100;
input.setAttribute('aria-label', 'New poll option');
const btn = document.createElement('button');
btn.className = 'add-option-btn';
btn.setAttribute('aria-label', 'Add option');
// Plus icon
btn.innerHTML = `
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
</svg>
<span>Add</span>
`;
wrapper.append(input, btn);
function submit() {
const name = input.value.trim();
if (!name) {
input.focus();
input.classList.add('shake');
input.addEventListener('animationend', () => input.classList.remove('shake'), { once: true });
return;
}
addOption(name);
input.value = '';
input.focus();
}
btn.addEventListener('click', submit);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') submit();
});
return wrapper;
}

View File

@@ -1,77 +0,0 @@
import { yOptions, getEntries, getTotalVotes } from '../utils/store.js';
import { PollOption } from './PollOption.js';
export function PollList() {
const wrapper = document.createElement('div');
wrapper.className = 'poll-list-wrapper';
const meta = document.createElement('div');
meta.className = 'poll-list-meta';
const list = document.createElement('div');
list.className = 'poll-list';
const empty = document.createElement('div');
empty.className = 'poll-list-empty';
empty.innerHTML = `
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="10" width="24" height="3" rx="1.5" fill="currentColor" opacity="0.15"/>
<rect x="4" y="16" width="18" height="3" rx="1.5" fill="currentColor" opacity="0.1"/>
<rect x="4" y="22" width="21" height="3" rx="1.5" fill="currentColor" opacity="0.07"/>
</svg>
</div>
<p>No options yet — add the first one above.</p>
`;
wrapper.append(meta, list, empty);
function render() {
const entries = getEntries();
const total = getTotalVotes();
// Meta line
if (entries.length > 0) {
meta.textContent = `${entries.length} option${entries.length !== 1 ? 's' : ''} · ${total} vote${total !== 1 ? 's' : ''} total`;
meta.style.display = '';
} else {
meta.style.display = 'none';
}
// Empty state
empty.style.display = entries.length === 0 ? '' : 'none';
// Diff-render: reuse existing rows when possible
const existing = new Map(
[...list.querySelectorAll('.poll-option')].map((el) => [el.dataset.id, el])
);
// Remove stale rows
existing.forEach((el, id) => {
if (!entries.find((e) => e.id === id)) el.remove();
});
// Update or insert rows in sorted order
entries.forEach((entry, i) => {
const newEl = PollOption({ ...entry, totalVotes: total });
const currentEl = list.children[i];
if (!currentEl) {
list.appendChild(newEl);
} else if (currentEl.dataset.id !== entry.id) {
list.insertBefore(newEl, currentEl);
// Remove the now-displaced old element if it was this id
const old = existing.get(entry.id);
if (old && old !== currentEl) old.remove();
} else {
// Replace in-place so vote bar animation triggers
list.replaceChild(newEl, currentEl);
}
});
}
yOptions.observeDeep(() => render());
render();
return wrapper;
}

View File

@@ -1,44 +0,0 @@
import { toggleVote, deleteOption } from '../utils/store.js';
/**
* @param {{ id: string, name: string, votes: number, voted: boolean, totalVotes: number }} entry
*/
export function PollOption({ id, name, votes, voted, totalVotes }) {
const row = document.createElement('div');
row.className = `poll-option${voted ? ' poll-option--voted' : ''}`;
row.dataset.id = id;
const pct = totalVotes > 0 ? Math.round((votes / totalVotes) * 100) : 0;
row.innerHTML = `
<div class="poll-option__bar" style="width: ${pct}%"></div>
<div class="poll-option__content">
<span class="poll-option__name">${escapeHtml(name)}</span>
<div class="poll-option__actions">
<span class="poll-option__pct">${pct}%</span>
<span class="poll-option__count">${votes} vote${votes !== 1 ? 's' : ''}</span>
<button class="poll-option__vote-btn" aria-pressed="${voted}">
${voted ? 'Voted' : 'Vote'}
</button>
<button class="poll-option__delete-btn" aria-label="Remove option">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
</div>
`;
row.querySelector('.poll-option__vote-btn').addEventListener('click', () => toggleVote(id));
row.querySelector('.poll-option__delete-btn').addEventListener('click', () => deleteOption(id));
return row;
}
function escapeHtml(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

@@ -1,34 +0,0 @@
import { ydoc, yTitle } from '../utils/store.js';
export function PollTitle() {
const wrapper = document.createElement('div');
wrapper.className = 'poll-title-wrapper';
const input = document.createElement('input');
input.type = 'text';
input.id = 'poll-title';
input.className = 'poll-title-input';
input.placeholder = 'Untitled Poll';
input.maxLength = 120;
input.setAttribute('aria-label', 'Poll title');
input.value = yTitle.toString();
wrapper.appendChild(input);
// Sync from Yjs → input (only when not focused to avoid cursor jump)
yTitle.observe(() => {
if (document.activeElement !== input) {
input.value = yTitle.toString();
}
});
// Sync from input → Yjs
input.addEventListener('input', () => {
ydoc.transact(() => {
yTitle.delete(0, yTitle.length);
yTitle.insert(0, input.value);
});
});
return wrapper;
}

View File

@@ -1,38 +0,0 @@
import { roomName } from '../utils/store.js';
export function ShareSection() {
const url = `${window.location.origin}${window.location.pathname}?room=${encodeURIComponent(roomName)}`;
const section = document.createElement('div');
section.className = 'share-section';
section.innerHTML = `
<p class="share-label">Share this poll</p>
<div class="share-row">
<code class="share-url" title="${url}">${url}</code>
<button class="share-copy-btn">Copy link</button>
</div>
`;
const copyBtn = section.querySelector('.share-copy-btn');
copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(url);
copyBtn.textContent = 'Copied!';
copyBtn.classList.add('share-copy-btn--success');
setTimeout(() => {
copyBtn.textContent = 'Copy link';
copyBtn.classList.remove('share-copy-btn--success');
}, 2000);
} catch {
// Fallback: select the text
const range = document.createRange();
range.selectNode(section.querySelector('.share-url'));
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
}
});
return section;
}

View File

@@ -1,50 +0,0 @@
import { provider } from '../utils/store.js';
export function StatusBar() {
const el = document.createElement('div');
el.className = 'status-bar';
const dot = document.createElement('span');
dot.className = 'status-dot connecting';
const statusText = document.createElement('span');
statusText.className = 'status-text';
statusText.textContent = 'Connecting';
const divider = document.createElement('span');
divider.className = 'status-divider';
divider.textContent = '·';
const peerText = document.createElement('span');
peerText.className = 'status-peers';
el.append(dot, statusText, divider, peerText);
// --- Connection state ---
let syncTimeout = setTimeout(() => {
statusText.textContent = 'Ready';
dot.className = 'status-dot ready';
}, 3000);
provider.on('synced', ({ synced }) => {
clearTimeout(syncTimeout);
dot.className = `status-dot ${synced ? 'connected' : 'connecting'}`;
statusText.textContent = synced ? 'Connected' : 'Connecting';
});
// --- Peer count ---
function updatePeerCount() {
const total = provider.awareness.getStates().size;
const others = total - 1;
peerText.textContent = others === 0
? 'Only you'
: `${others} other${others !== 1 ? 's' : ''}`;
}
provider.awareness.on('change', updatePeerCount);
updatePeerCount();
return el;
}

View File

@@ -1,41 +0,0 @@
import { StatusBar } from './components/StatusBar.js';
import { PollTitle } from './components/PollTitle.js';
import { AddOption } from './components/AddOption.js';
import { PollList } from './components/PollList.js';
import { ShareSection } from './components/ShareSection.js';
const app = document.getElementById('app');
// Header: logo + status
const header = document.createElement('header');
header.className = 'app-header';
const wordmark = document.createElement('div');
wordmark.className = 'app-wordmark';
wordmark.innerHTML = `
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect x="2" y="4" width="16" height="2.5" rx="1.25" fill="currentColor"/>
<rect x="2" y="8.75" width="11" height="2.5" rx="1.25" fill="currentColor" opacity="0.6"/>
<rect x="2" y="13.5" width="13" height="2.5" rx="1.25" fill="currentColor" opacity="0.35"/>
</svg>
<span>Polly</span>
`;
header.append(wordmark, StatusBar());
// Main card
const card = document.createElement('main');
card.className = 'app-card';
card.append(
PollTitle(),
AddOption(),
PollList(),
);
// Footer
const footer = document.createElement('footer');
footer.className = 'app-footer';
footer.appendChild(ShareSection());
app.append(header, card, footer);

View File

@@ -1,412 +0,0 @@
/* ── Fonts ─────────────────────────────────────────────── */
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600&family=Playfair+Display:wght@500&display=swap');
/* ── Tokens ────────────────────────────────────────────── */
:root {
--bg: #F7F6F2;
--surface: #FFFFFF;
--surface-hover: #FAFAF8;
--border: #E8E5DF;
--border-focus: #1A1A1A;
--text-primary: #1A1A1A;
--text-secondary: #6B6860;
--text-muted: #AAA79F;
--accent: #1A1A1A;
--accent-text: #FFFFFF;
--vote-bar: rgba(26, 26, 26, 0.07);
--vote-bar-voted: rgba(26, 26, 26, 0.12);
--success: #2D7D46;
--danger: #C0392B;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--font-display: 'Playfair Display', Georgia, serif;
--font-body: 'DM Sans', system-ui, sans-serif;
--shadow-card: 0 1px 3px rgba(0,0,0,0.06), 0 4px 16px rgba(0,0,0,0.05);
}
/* ── Reset ─────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ── Base ──────────────────────────────────────────────── */
html { font-size: 16px; }
body {
font-family: var(--font-body);
background: var(--bg);
color: var(--text-primary);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
/* ── Layout ────────────────────────────────────────────── */
#app {
max-width: 580px;
margin: 0 auto;
padding: 2rem 1.25rem 4rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* ── Header ────────────────────────────────────────────── */
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0.25rem;
}
.app-wordmark {
display: flex;
align-items: center;
gap: 0.5rem;
font-family: var(--font-display);
font-size: 1.1rem;
color: var(--text-primary);
letter-spacing: -0.01em;
}
/* ── Status bar ────────────────────────────────────────── */
.status-bar {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
color: var(--text-secondary);
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
transition: background 0.3s;
}
.status-dot.connecting { background: var(--text-muted); }
.status-dot.ready { background: var(--text-muted); }
.status-dot.connected { background: var(--success); }
.status-divider { color: var(--text-muted); }
/* ── Card ──────────────────────────────────────────────── */
.app-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}
/* ── Poll Title ────────────────────────────────────────── */
.poll-title-wrapper {
padding: 1.75rem 1.75rem 1.25rem;
border-bottom: 1px solid var(--border);
}
.poll-title-input {
width: 100%;
font-family: var(--font-display);
font-size: 1.5rem;
font-weight: 500;
color: var(--text-primary);
background: transparent;
border: none;
outline: none;
line-height: 1.3;
letter-spacing: -0.02em;
}
.poll-title-input::placeholder { color: var(--text-muted); }
/* ── Add Option ────────────────────────────────────────── */
.add-option-wrapper {
display: flex;
gap: 0.625rem;
padding: 1.25rem 1.75rem;
border-bottom: 1px solid var(--border);
}
.add-option-input {
flex: 1;
height: 2.5rem;
padding: 0 0.875rem;
font-family: var(--font-body);
font-size: 0.9rem;
color: var(--text-primary);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
outline: none;
transition: border-color 0.15s;
}
.add-option-input::placeholder { color: var(--text-muted); }
.add-option-input:focus { border-color: var(--border-focus); }
.add-option-input.shake {
animation: shake 0.3s ease;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-4px); }
75% { transform: translateX(4px); }
}
.add-option-btn {
display: flex;
align-items: center;
gap: 0.375rem;
height: 2.5rem;
padding: 0 1rem;
font-family: var(--font-body);
font-size: 0.875rem;
font-weight: 500;
color: var(--accent-text);
background: var(--accent);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: opacity 0.15s;
white-space: nowrap;
}
.add-option-btn:hover { opacity: 0.85; }
.add-option-btn:active { opacity: 0.7; }
/* ── Poll List ─────────────────────────────────────────── */
.poll-list-wrapper {
padding: 0.5rem 0;
}
.poll-list-meta {
padding: 0.5rem 1.75rem 0.75rem;
font-size: 0.775rem;
color: var(--text-muted);
letter-spacing: 0.02em;
text-transform: uppercase;
font-weight: 500;
}
.poll-list-empty {
padding: 3rem 1.75rem;
text-align: center;
color: var(--text-muted);
font-size: 0.875rem;
}
.empty-icon {
margin-bottom: 0.75rem;
opacity: 0.6;
}
/* ── Poll Option ───────────────────────────────────────── */
.poll-option {
position: relative;
overflow: hidden;
transition: background 0.15s;
}
.poll-option:hover {
background: var(--surface-hover);
}
.poll-option__bar {
position: absolute;
inset: 0 auto 0 0;
background: var(--vote-bar);
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
.poll-option--voted .poll-option__bar {
background: var(--vote-bar-voted);
}
.poll-option__content {
position: relative;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1.75rem;
}
.poll-option__name {
flex: 1;
font-size: 0.9375rem;
font-weight: 400;
color: var(--text-primary);
word-break: break-word;
}
.poll-option--voted .poll-option__name {
font-weight: 500;
}
.poll-option__actions {
display: flex;
align-items: center;
gap: 0.625rem;
flex-shrink: 0;
}
.poll-option__pct {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
min-width: 2.5rem;
text-align: right;
}
.poll-option__count {
font-size: 0.775rem;
color: var(--text-muted);
min-width: 3.5rem;
}
.poll-option__vote-btn {
height: 1.875rem;
padding: 0 0.875rem;
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 500;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
}
.poll-option__vote-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.poll-option--voted .poll-option__vote-btn {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
.poll-option--voted .poll-option__vote-btn:hover {
opacity: 0.8;
}
.poll-option__delete-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.625rem;
height: 1.625rem;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, color 0.15s, background 0.15s;
}
.poll-option:hover .poll-option__delete-btn { opacity: 1; }
.poll-option__delete-btn:hover {
color: var(--danger);
background: rgba(192, 57, 43, 0.07);
}
/* ── Footer ────────────────────────────────────────────── */
.app-footer {
padding: 0 0.25rem;
}
/* ── Share Section ─────────────────────────────────────── */
.share-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 1.25rem 1.5rem;
}
.share-label {
font-size: 0.775rem;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.625rem;
}
.share-row {
display: flex;
align-items: center;
gap: 0.625rem;
}
.share-url {
flex: 1;
font-family: 'DM Mono', 'Fira Mono', monospace;
font-size: 0.8rem;
color: var(--text-secondary);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0.5rem 0.75rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
user-select: all;
}
.share-copy-btn {
height: 2rem;
padding: 0 0.875rem;
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
flex-shrink: 0;
}
.share-copy-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.share-copy-btn--success {
color: var(--success) !important;
border-color: var(--success) !important;
}
/* ── Responsive ────────────────────────────────────────── */
@media (max-width: 480px) {
#app { padding: 1rem 0.75rem 3rem; }
.poll-title-wrapper { padding: 1.25rem 1.25rem 1rem; }
.add-option-wrapper { padding: 1rem 1.25rem; }
.poll-option__content { padding: 0.875rem 1.25rem; }
.poll-list-meta { padding: 0.5rem 1.25rem 0.625rem; }
.poll-list-empty { padding: 2.5rem 1.25rem; }
.poll-option__count { display: none; }
.share-section { padding: 1rem 1.25rem; }
}

View File

@@ -1,79 +0,0 @@
import * as Y from 'yjs';
import { WebrtcProvider } from 'y-webrtc';
// --- Peer ID (stable across reloads) ---
function getOrCreatePeerId() {
let id = localStorage.getItem('peer-id');
if (!id) {
id = crypto.randomUUID();
localStorage.setItem('peer-id', id);
}
return id;
}
// --- Room name from URL ---
function getRoomName() {
const params = new URLSearchParams(window.location.search);
return params.get('room') || 'default-poll';
}
// --- Yjs setup ---
export const peerId = getOrCreatePeerId();
export const roomName = getRoomName();
export const ydoc = new Y.Doc();
export const provider = new WebrtcProvider(roomName, ydoc);
export const yOptions = ydoc.getMap('poll-options');
export const yTitle = ydoc.getText('poll-title');
// --- Data operations ---
export function addOption(name) {
const id = crypto.randomUUID();
const optionMap = new Y.Map();
optionMap.set('name', name);
optionMap.set('votes', new Y.Map());
yOptions.set(id, optionMap);
}
export function toggleVote(optionId) {
const optionMap = yOptions.get(optionId);
if (!optionMap) return;
const votes = optionMap.get('votes');
if (votes.has(peerId)) {
votes.delete(peerId);
} else {
votes.set(peerId, true);
}
}
export function deleteOption(optionId) {
yOptions.delete(optionId);
}
// --- Derived read helpers ---
export function getEntries() {
const entries = [];
yOptions.forEach((optionMap, id) => {
entries.push({
id,
name: optionMap.get('name'),
votes: optionMap.get('votes').size,
voted: optionMap.get('votes').has(peerId),
});
});
entries.sort((a, b) => b.votes - a.votes || a.name.localeCompare(b.name));
return entries;
}
export function getTotalVotes() {
let total = 0;
yOptions.forEach((optionMap) => {
total += optionMap.get('votes').size;
});
return total;
}