Peer-to-peer video and text chat application built with WebRTC, Socket.IO, and Next.js. Create a room, share the code, and start chatting or video calling directly in the browser with no account required.
- Text chat with real-time message delivery, typing indicators, and presence awareness
- Video calling with peer-to-peer WebRTC connections
- Room system with 6-digit codes for easy sharing (max 2 participants per room)
- Session persistence per browser tab, with a 30-second reconnection grace period (a reload rejoins the room)
- ICE restart for automatic recovery from network disruptions
- Short-lived TURN credentials via HMAC-SHA1, handed only to room members (the shared secret never leaves the server)
- Rate limiting at every layer: HTTP, WebSocket connections, socket events, chat messages, and room join attempts
| Technology | Purpose |
|---|---|
| Next.js 16 | App Router, server components, file-based routing |
| React 19 | UI rendering |
| Tailwind CSS 4 | Styling with CSS custom properties (light/dark mode) |
| Socket.IO Client | WebSocket connection to signaling server |
| WebRTC (browser API) | Peer-to-peer audio/video streams |
| Technology | Purpose |
|---|---|
| Node.js 22 | Runtime |
| Express 5 | HTTP server, REST endpoints (/health, /api/ice-config) |
| Socket.IO 4 | WebSocket signaling for rooms, chat, presence, and WebRTC |
| Helmet | HTTP security headers |
| express-rate-limit | API rate limiting |
| Pino | Structured JSON logging |
| Technology | Purpose |
|---|---|
| Coturn 4.18 | TURN relay server for WebRTC NAT traversal (image pinned by digest) |
| Docker + Docker Compose | Signaling, TURN and Caddy containers |
| Google STUN servers | Public STUN for peer IP discovery |
| Platform | Service |
|---|---|
| Google Cloud (e2-micro VM) | Signaling server + TURN server + Caddy reverse proxy |
| Vercel | Frontend hosting |
| Caddy | Automatic HTTPS via Let's Encrypt |
| Technology | Purpose |
|---|---|
| Turborepo | Build system with dependency-aware task pipelines |
| pnpm workspaces | Package management |
| TypeScript 5 | Type safety across all packages |
| ESLint 9 | Linting with flat config |
| Prettier | Code formatting |
| Vitest | Server integration tests |
| GitHub Actions | CI: types, lint and tests |
Browser A Browser B
β β
β Socket.IO (WebSocket) β Socket.IO (WebSocket)
β β
ββββββββββββ ββββββββββββββ
βΌ βΌ
βββββββββββββββββββ
β Signaling Server β β GCP VM (behind Caddy / HTTPS)
β (Express + S.IO) β
βββββββββββββββββββ
β β
ββββββββββββ ββββββββββββββ
βΌ βΌ
Browser A βββ WebRTC P2P ββββΊ Browser B
β β
β (fallback when P2P fails) β
β β
ββββββββββββ ββββββββββββββ
βΌ βΌ
βββββββββββββββββββ
β TURN Server β β Same GCP VM (host networking)
β (Coturn) β
βββββββββββββββββββ
- Both peers connect to the signaling server via Socket.IO
- Room creation/joining is handled through socket events
- WebRTC offer/answer/ICE candidates are relayed through the signaling server
- Media flows directly peer-to-peer (STUN helps discover public IPs)
- If direct connection fails (~15-20% of cases), traffic is relayed through the TURN server
charlar/
βββ apps/
β βββ server/ # Express + Socket.IO signaling backend
β β βββ src/
β β β βββ index.ts # Entry point, graceful shutdown
β β β βββ server.ts # Builds the HTTP + Socket.IO servers (entry point and tests)
β β β βββ app.ts # Express app, routes, middleware
β β β βββ config/env.ts # Environment variable config
β β β βββ logger.ts # Pino logger setup
β β β βββ socket/
β β β β βββ index.ts # Socket.IO init, session management, rate limiting
β β β β βββ types.ts # Shared socket types
β β β β βββ handlers/
β β β β βββ rooms.ts # room:create, room:join, room:leave
β β β β βββ chat.ts # chat:message, typing:start, typing:stop
β β β β βββ presence.ts # presence:request
β β β β βββ signaling.ts # signal:offer, signal:answer, signal:ice-candidate, media:state
β β β β βββ ice.ts # ice:config (TURN credentials for room members)
β β β βββ services/
β β β βββ index.ts # Singleton wiring
β β β βββ room-service.ts # Room CRUD, 6-digit codes, max 2 participants
β β β βββ session-service.ts # UUID sessions, resume tokens, reconnection grace period
β β β βββ chat-service.ts # Message validation, throttling
β β β βββ typing-service.ts # Typing state with auto-expiry
β β β βββ presence-service.ts # Online/offline participant info
β β β βββ ice-config-service.ts # STUN/TURN config, HMAC credential generation
β β βββ test/ # Vitest integration tests against the real server
β β βββ Dockerfile # Multi-stage production build
β β
β βββ web/ # Next.js 16 App Router frontend
β βββ app/
β βββ layout.tsx # Root layout (Fraunces, Instrument Sans, JetBrains Mono)
β βββ page.tsx # Home page (connection status, room form)
β βββ globals.css # CSS custom properties, light/dark theme
β βββ _components/
β β βββ providers.tsx # Socket + Room context providers
β β βββ connection-status.tsx # WebSocket status indicator
β β βββ room-form.tsx # Create/join room UI
β β βββ room-guard.tsx # Route protection
β β βββ room-header.tsx # Room info bar + leave button
β β βββ logo.tsx # Brand mark
β βββ _lib/
β β βββ env.ts # NEXT_PUBLIC env vars
β β βββ socket.ts # Singleton socket, per-tab session, early call signals
β β βββ socket-context.tsx # SocketProvider + useSocket hook
β β βββ room-context.tsx # RoomProvider + useRoom hook
β β βββ webrtc.ts # PeerManager class + fetchIceServers
β β βββ media.ts # getUserMedia wrapper
β βββ room/[code]/
β βββ chat/
β β βββ page.tsx
β β βββ _components/ # chat-room, message-input, message-list,
β β β # presence-bar, typing-indicator
β β βββ _hooks/use-chat.ts
β βββ video/
β βββ page.tsx
β βββ _components/ # video-room, video-controls
β βββ _hooks/use-webrtc.ts
β
βββ packages/
β βββ tailwind-config/ # Shared Tailwind + PostCSS config
β βββ typescript-config/ # Shared tsconfig (base, node, nextjs)
β βββ eslint-config/ # Shared ESLint flat configs
β
βββ infrastructure/
β βββ docker-compose.yml # Combined stack: signaling + TURN + Caddy
β βββ Caddyfile # Reverse proxy + automatic HTTPS config
β βββ signaling.env.example # Signaling server env template
β βββ verify-turn.mjs # End-to-end TURN relay check (exit 0/1)
β βββ turn/ # Coturn TURN server
β βββ Dockerfile # coturn 4.18, pinned by digest
β βββ docker-entrypoint.sh # Resolves advertised IPs, then execs turnserver
β βββ docker-compose.yml # Standalone TURN-only compose (optional)
β βββ turnserver.conf # Config template with ${ENV_VAR} placeholders
β βββ .env.example
β
βββ .github/workflows/ci.yml # CI: check-types, lint, test
βββ turbo.json
βββ pnpm-workspace.yaml
βββ package.json
- Node.js >= 22.9 (the server's dev script loads
.envwith--env-file-if-exists) - pnpm 10.x (
corepack enable && corepack prepare pnpm@10.19.0 --activate) - Docker (for TURN server)
git clone https://github.com/vagxrth/charlar.git
cd charlar
pnpm installBackend (apps/server/.env):
cp apps/server/.env.example apps/server/.envPORT=3001
NODE_ENV=development
CORS_ORIGIN=http://localhost:3000
RECONNECT_TIMEOUT_MS=30000
STUN_URLS=stun:stun.l.google.com:19302,stun:stun1.l.google.com:19302Frontend (apps/web/.env.local):
cp apps/web/.env.example apps/web/.env.localNEXT_PUBLIC_SERVER_URL=http://localhost:3001pnpm devThis starts both the backend (port 3001) and frontend (port 3000) with hot reload via Turborepo.
pnpm turbo run check-types lint testThe server's tests (Vitest, in apps/server/test) start the real server on a free port and drive it with socket.io-client. CI runs the same command on every pull request and every push to main.
Only needed for testing TURN relay β most local connections work with STUN alone.
cd infrastructure/turn
cp .env.example .env
# Off GCE there is no metadata server, so set these explicitly:
# TURN_SECRET, TURN_EXTERNAL_IP=127.0.0.1, TURN_LISTENING_IP=0.0.0.0
docker compose upThen add to apps/server/.env:
TURN_URLS=turn:127.0.0.1:3478
TURN_SECRET=your-secret-hereThe backend (signaling + TURN + reverse proxy) runs on a single small VM. The frontend runs on Vercel.
GCP's Always Free tier covers one e2-micro instance in us-west1, us-central1, or us-east1 indefinitely.
Provision the VM:
- Create a project at console.cloud.google.com, set up a billing budget alert ($1/mo with email notifications) as a safety net
- Compute Engine β Create Instance:
- Machine type:
e2-micro(free tier) - Region:
us-central1(any zone) - Boot disk: Ubuntu 24.04 LTS, 30 GB Standard persistent disk
- Firewall: allow HTTP + HTTPS
- Machine type:
- VPC network β Firewall β create rule
charlar-turn:- Ingress, allow from
0.0.0.0/0 - TCP:
3478 - UDP:
3478,49152-65535
- Ingress, allow from
Point DNS at the VM:
Create an A record api.<your-domain> pointing to the VM's external IP. Verify with dig +short api.<your-domain>.
Install Docker and deploy:
SSH into the VM (browser SSH from the console works):
# Install Docker
sudo apt update && sudo apt install -y ca-certificates curl gnupg
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
# log out + back in for group to apply
# Clone repo
git clone https://github.com/vagxrth/charlar.git
cd charlar/infrastructure
# Generate values
TURN_SECRET=$(openssl rand -hex 32)
DOMAIN=api.your-domain.com
# Create env files
cp signaling.env.example signaling.env
sed -i "s|^TURN_SECRET=.*|TURN_SECRET=$TURN_SECRET|" signaling.env
# The API domain first: the frontend domain is part of it.
sed -i "s|api.charlar.vagarth.in|$DOMAIN|g" signaling.env
sed -i "s|charlar.vagarth.in|your-frontend-domain.com|g" signaling.env
cp turn/.env.example turn/.env
sed -i "s|^TURN_SECRET=.*|TURN_SECRET=$TURN_SECRET|" turn/.env
sed -i "s|^TURN_REALM=.*|TURN_REALM=$DOMAIN|" turn/.env
# TURN_EXTERNAL_IP / TURN_LISTENING_IP stay blank β the coturn entrypoint reads
# them from the GCE metadata server on every start, so they cannot go stale when
# the VM restarts on a different ephemeral IP.
# Update Caddyfile to use your domain
sed -i "s|api.charlar.vagarth.in|$DOMAIN|" Caddyfile
# Start the stack
sudo docker compose up -d --build
sudo docker compose logs --tail=50The combined docker-compose.yml runs three services:
- signaling β Express + Socket.IO server (internal port 3001)
- caddy β reverse proxy on 80/443 with automatic Let's Encrypt TLS
- coturn β TURN server with host networking (for UDP relay performance)
Verify:
curl https://api.<your-domain>/health
# {"status":"ok","uptime":...,"rooms":0,"sessions":0}
curl https://api.<your-domain>/api/ice-config
# {"iceServers":[{"urls":["stun:..."]}]}
# STUN only: room members get TURN credentials over the socket.Then check the relay itself β a misconfigured TURN server fails silently, since chat and direct P2P calls keep working while only relayed calls break:
node infrastructure/verify-turn.mjs api.<your-domain>
# β Allocate succeeded β HMAC credentials accepted by coturn
# β relay address 34.121.38.38:56331 matches the host-
Import the repo on vercel.com
-
Configure build settings:
Setting Value Root Directory apps/webBuild Command cd ../.. && pnpm turbo run build --filter=webOutput Directory .nextInstall Command cd ../.. && pnpm install --frozen-lockfile -
Set environment variable:
NEXT_PUBLIC_SERVER_URL=https://api.<your-domain> -
Deploy.
TURN_SECRETis never set on Vercel β it stays server-side. Once in a room, the frontend asks the signaling server for short-lived TURN credentials over the socket (ice:config).
Preview deployments have their own URLs, which the server doesn't allow by default. To let them reach the API, set CORS_ORIGIN_PATTERN in signaling.env (see signaling.env.example) and restart signaling.
| Event | Payload | Response |
|---|---|---|
room:create |
(nickname?, mode?, callback), mode "chat" or "video" |
{ ok, code, mode, participantCount, nickname } |
room:join |
(code, nickname?, callback) |
{ ok, nickname, mode, participantCount, participants[] } |
room:leave |
(code, callback) |
{ ok } (members only) |
chat:message |
{ roomCode, content } |
{ ok, id, timestamp } |
typing:start |
(roomCode, callback) |
{ ok } |
typing:stop |
(roomCode, callback) |
{ ok } |
presence:request |
(roomCode, callback) |
{ ok, mode, participants[], participantCount } |
signal:offer |
{ roomCode, targetSessionId, sdp } |
{ ok } |
signal:answer |
{ roomCode, targetSessionId, sdp } |
{ ok } |
signal:ice-candidate |
{ roomCode, targetSessionId, candidate } |
{ ok } |
media:state |
{ roomCode, audioMuted, videoOff } |
{ ok } |
ice:config |
(callback) |
{ ok, iceServers[] } (room members only) |
A failed request is acknowledged with { ok: false, error }. A room created without a mode reports mode: null, and the joiner keeps the mode it picked.
| Event | Payload | Target |
|---|---|---|
session:created |
{ sessionId, resumeToken, features[] } |
Connecting socket |
room:peer-joined |
{ sessionId, nickname, participantCount } |
Room (excl. sender) |
room:peer-left |
{ sessionId, nickname, participantCount } |
Room |
room:peer-disconnected |
{ sessionId, nickname, participantCount } |
Room |
room:peer-reconnected |
{ sessionId, nickname, participantCount } |
Room |
chat:message |
{ id, sessionId, nickname, content, timestamp } |
Room (excl. sender) |
typing:start / typing:stop |
{ sessionId } |
Room |
signal:offer / signal:answer |
{ sessionId, sdp } |
Target socket |
signal:ice-candidate |
{ sessionId, candidate } |
Target socket |
media:state |
{ sessionId, audioMuted, videoOff } |
Room (excl. sender) |
The client keeps { sessionId, resumeToken } in its tab's sessionStorage and sends it in the handshake auth; with a matching token the server hands back the same session and rejoins its rooms. features lists what the server supports (room-mode, ice-config), so the web app can deploy before the server and fall back to the older calls.
- Both peers join a room via socket events
- Both fetch local media (camera + mic) and ICE server config in parallel; the config comes over the socket (
ice:config) - The peer with the lexicographically higher
sessionIdsends the offer (deterministic, no coordination needed) - Offer/answer and ICE candidates are relayed through the signaling server. Any that arrive before the video page has mounted, which is common right after joining, are held for up to 60 seconds and replayed
- Once ICE connectivity is established, media flows directly peer-to-peer
- If the connection drops, automatic ICE restart is attempted (max 2 times)
- If the peer's socket disconnects but the session is within the 30s grace period, the WebRTC connection is kept alive waiting for reconnection
- After a reload, the tab reclaims its session and rejoins the room, and the call is renegotiated with the higher-id side offering