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
This commit is contained in:
2026-03-25 11:51:33 +01:00
parent 4275cbd795
commit e14bb6d425
36 changed files with 8281 additions and 1 deletions

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"]
}