Skip to content
vagxrthPublic

About

π™Žπ™ƒπ˜Όπ™π™€ π˜Όπ™‰π˜Ώ π˜Ύπ™Šπ™‰π™‰π™€π˜Ύπ™.

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

Β 

History

31 Commits

Folders and files

Repository files navigation

Charlar

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.

Features

  • 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

Tech Stack

Frontend (apps/web)

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

Backend (apps/server)

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

Infrastructure

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

Deployment

Platform Service
Google Cloud (e2-micro VM) Signaling server + TURN server + Caddy reverse proxy
Vercel Frontend hosting
Caddy Automatic HTTPS via Let's Encrypt

Monorepo Tooling

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

Architecture

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)       β”‚
         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
  1. Both peers connect to the signaling server via Socket.IO
  2. Room creation/joining is handled through socket events
  3. WebRTC offer/answer/ICE candidates are relayed through the signaling server
  4. Media flows directly peer-to-peer (STUN helps discover public IPs)
  5. If direct connection fails (~15-20% of cases), traffic is relayed through the TURN server

Project Structure

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

Prerequisites

  • Node.js >= 22.9 (the server's dev script loads .env with --env-file-if-exists)
  • pnpm 10.x (corepack enable && corepack prepare pnpm@10.19.0 --activate)
  • Docker (for TURN server)

Local Development

1. Clone and install

git clone https://github.com/vagxrth/charlar.git
cd charlar
pnpm install

2. Configure environment variables

Backend (apps/server/.env):

cp apps/server/.env.example apps/server/.env
PORT=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:19302

Frontend (apps/web/.env.local):

cp apps/web/.env.example apps/web/.env.local
NEXT_PUBLIC_SERVER_URL=http://localhost:3001

3. Start development servers

pnpm dev

This starts both the backend (port 3001) and frontend (port 3000) with hot reload via Turborepo.

4. Run the checks

pnpm turbo run check-types lint test

The 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.

5. (Optional) Local TURN server

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 up

Then add to apps/server/.env:

TURN_URLS=turn:127.0.0.1:3478
TURN_SECRET=your-secret-here

Production Deployment

The backend (signaling + TURN + reverse proxy) runs on a single small VM. The frontend runs on Vercel.

1. Backend (Google Cloud e2-micro)

GCP's Always Free tier covers one e2-micro instance in us-west1, us-central1, or us-east1 indefinitely.

Provision the VM:

  1. Create a project at console.cloud.google.com, set up a billing budget alert ($1/mo with email notifications) as a safety net
  2. 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
  3. VPC network β†’ Firewall β†’ create rule charlar-turn:
    • Ingress, allow from 0.0.0.0/0
    • TCP: 3478
    • UDP: 3478,49152-65535

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=50

The 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

2. Frontend (Vercel)

  1. Import the repo on vercel.com

  2. Configure build settings:

    Setting Value
    Root Directory apps/web
    Build Command cd ../.. && pnpm turbo run build --filter=web
    Output Directory .next
    Install Command cd ../.. && pnpm install --frozen-lockfile
  3. Set environment variable:

    NEXT_PUBLIC_SERVER_URL=https://api.<your-domain>
    
  4. Deploy. TURN_SECRET is 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.

Socket Events

Client to Server

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.

Server to Client

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.

WebRTC Call Flow

  1. Both peers join a room via socket events
  2. Both fetch local media (camera + mic) and ICE server config in parallel; the config comes over the socket (ice:config)
  3. The peer with the lexicographically higher sessionId sends the offer (deterministic, no coordination needed)
  4. 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
  5. Once ICE connectivity is established, media flows directly peer-to-peer
  6. If the connection drops, automatic ICE restart is attempted (max 2 times)
  7. If the peer's socket disconnects but the session is within the 30s grace period, the WebRTC connection is kept alive waiting for reconnection
  8. After a reload, the tab reclaims its session and rejoins the room, and the call is renegotiated with the higher-id side offering

About

π™Žπ™ƒπ˜Όπ™π™€ π˜Όπ™‰π˜Ώ π˜Ύπ™Šπ™‰π™‰π™€π˜Ύπ™.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages