diff --git a/README.docker.md b/README.docker.md new file mode 100644 index 0000000..1139544 --- /dev/null +++ b/README.docker.md @@ -0,0 +1,96 @@ +# Running CrowdStream with Docker + +Two top-level Compose files bring up the app plus its core dependencies: + +| File | Purpose | +| --- | --- | +| `docker-compose.local.yml` | Local development — backend & frontend hot reload | +| `docker-compose.prod.yml` | Production — compiled backend, static frontend built into nginx | + +Each stack runs **backend + frontend + a 6-node Redis cluster + MongoDB + an nginx ingress**. +The NAT-traversal layers (Coturn, Envoy, HAProxy) are **not** included here — start those from +`infra/` as before when you need TURN/relay. + +``` +browser ──▶ nginx :80 ──/──────────▶ frontend (local: Vite :5173 · prod: static in nginx) + ──/backend/──▶ backend :3000 (/backend/ prefix stripped → /api/v1, /db) + ──/socket.io/▶ backend :3000 (WebSocket) +backend :3000 ──▶ mongo 127.0.0.1:27017 + redis cluster 127.0.0.1:6379-6384 +``` + +> **Networking:** every service uses `network_mode: host`, matching the existing `infra/` setup. +> This is required so the Redis cluster can advertise `127.0.0.1:` and mediasoup can bind +> the WebRTC/recording UDP ports directly. **Host networking is a Linux feature** — on Docker +> Desktop for macOS/Windows the port mapping semantics differ and this stack is not supported as-is. + +## 1. Prerequisites + +- Docker Engine + Compose v2 on **Linux**. +- The RTC port range (`RTC_MIN_PORT`–`RTC_MAX_PORT`, default `40000-40100` UDP/TCP) free on the host. + +## 2. Environment + +The backend requires ~25 env vars (all mandatory — it exits on any missing one). Seed them from the +template, then edit the secrets: + +```bash +cp deploy/env.docker.template backend/.env +# edit backend/.env: set JWT_SECRET, TURN_SECRET, DATABASE_NAME, etc. +``` + +You don't need to get the infra endpoints right in `backend/.env` — the Compose files force-override +`MONGO_DB_URL`, `REDIS_HOST`/`REDIS_PORT*`, `PORT`, `INSTANCE_ID`, and the `*_IP` vars so the +containerized Mongo/Redis are always used. + +For **local** frontend dev, Vite reads `frontend/.env` at runtime (it's bind-mounted) — put any +`VITE_*` values there. For **production**, `VITE_*` are baked in at build time; provide them via your +shell or a root `.env` that `docker compose` reads (see the `args:` in `docker-compose.prod.yml`). + +## 3. Local development + +```bash +docker compose -f docker-compose.local.yml up --build +``` + +- Waits for Mongo to be healthy and `redis-init` to form the cluster, then starts the backend. +- Open **http://localhost/** — the nginx ingress serves the Vite app and proxies the API/socket. +- Editing `backend/src/**` → `nodemon` restart; editing `frontend/src/**` → Vite HMR. + +Health checks: + +```bash +curl http://localhost/backend/db/__ping # -> PING OK +curl http://localhost/backend/health # -> HEALTH OK +``` + +## 4. Production + +```bash +cp deploy/env.docker.template backend/.env # set real secrets +export HOST_PUBLIC_IP= # so WebRTC reaches remote clients +docker compose -f docker-compose.prod.yml up --build -d +``` + +- Backend runs the compiled `node dist/index.js`. +- The frontend container is nginx serving the built SPA **and** proxying `/backend` + `/socket.io` + to the backend — it is the :80 ingress (no separate nginx service in prod). +- Verify the runtime asset copied by the build (the Lua rate-limit script): + + ```bash + docker compose -f docker-compose.prod.yml exec backend ls dist/scripts # -> rateLimit.lua + ``` + +## 5. Notes & caveats + +- **Redis cluster** is created once by the `redis-init` one-shot service; it's idempotent (re-runs + detect `cluster_state:ok` and exit). Data persists in the `redis-N-data` named volumes. Node configs + are reused read-only from `infra/redis/redis-N/redis.conf`. The image is `redis:7-alpine` (Redis ≥7 is + required for the sharded pub/sub the app uses; bump the tag if you want 8.x). +- **MongoDB** binds to `127.0.0.1` only (host networking) with data in the `mongo-data` volume. It has + no auth by default — fine because it isn't reachable off-host, but enable auth for a hardened deploy. +- **Recordings** are written relative to the working dir (`recording.mp4` and + `src/recording/.sdp`). In local dev they land on the host via the bind mount. In production they + live inside the container and are **ephemeral** — bind-mount a host path onto `/app` (or change the + output path in `backend/src/recording/`) if you need them to persist. +- **TURN/relay:** for connectivity across restrictive NATs, run Coturn/Envoy/HAProxy from `infra/` and + point the frontend `VITE_TURN_*` values at them. diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..fcadf9d --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,15 @@ +node_modules +dist +logs +*.log +.env +.env.* +.git +.gitignore +Dockerfile +.dockerignore +Makefile +README.md +*.pem +recording*.mp4 +src/recording/*.sdp diff --git a/backend/Dockerfile b/backend/Dockerfile index 5400608..650b520 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,20 +1,50 @@ -FROM node:20-slim +# syntax=docker/dockerfile:1 +# +# Multi-stage backend image for CrowdStream (mediasoup SFU, Node/TS). +# - target `dev` : nodemon + ts-node hot reload (source bind-mounted by compose) +# - target `prod` : TypeScript compiled to dist/, lean runtime, no build tools +# +# Build deps (python3/build-essential/pkg-config) are required to compile the +# mediasoup native worker and ffmpeg-static during `npm install`. -RUN apt-get update && apt-get install -y \ +# ---- base: toolchain shared by dev and build ---- +FROM node:20-slim AS base +RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ build-essential \ pkg-config \ && rm -rf /var/lib/apt/lists/* +WORKDIR /app -WORKDIR /src - +# ---- dev: hot reload; real source comes from a bind mount in compose ---- +FROM base AS dev +ENV NODE_ENV=development COPY package*.json ./ - RUN npm install +COPY . . +EXPOSE 3000 +CMD ["npm", "run", "dev"] +# ---- build: compile TS -> dist, then drop dev dependencies ---- +FROM base AS build +ENV NODE_ENV=production +COPY package*.json ./ +RUN npm ci COPY . . +# `npm run build` = tsc + `cp -r src/scripts dist/scripts` (rateLimit.lua is read +# at runtime via __dirname and is NOT emitted by tsc). Then prune dev deps while +# keeping the already-built mediasoup worker + ffmpeg-static binaries. +RUN npm run build \ + && npm prune --omit=dev +# ---- prod: minimal runtime image ---- +FROM node:20-slim AS prod +ENV NODE_ENV=production +WORKDIR /app +# recording/sdp.ts writes `src/recording/.sdp` relative to the working dir. +RUN mkdir -p src/recording +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +COPY --from=build /app/package.json ./package.json EXPOSE 3000 - -# Start app -CMD ["npm", "run","dev"] \ No newline at end of file +CMD ["node", "dist/index.js"] diff --git a/backend/package.json b/backend/package.json index 21ffdfc..63fe0e3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,7 +4,9 @@ "description": "> Backend system for scalable HTTP Live Streaming (HLS) using FFmpeg, built with Node.js and TypeScript.", "main": "index.js", "scripts": { - "dev": "nodemon --watch src --ext ts,d.ts --exec ts-node --files src/index.ts" + "dev": "nodemon --watch src --ext ts,d.ts --exec ts-node --files src/index.ts", + "build": "tsc -p tsconfig.json && cp -r src/scripts dist/scripts", + "start": "node dist/index.js" }, "author": "Harshit Singh Parihar", "license": "ISC", diff --git a/backend/src/utils/socket.util.ts b/backend/src/utils/socket.util.ts index 6277d07..5cf68a6 100644 --- a/backend/src/utils/socket.util.ts +++ b/backend/src/utils/socket.util.ts @@ -140,8 +140,8 @@ io.on("connection", (socket) => { } }) - socket.on("disconnect", async (reason) => { - logger.info(`User disconnected ${socket.id} beacuse of ${reason}`) + socket.on("disconnect", async (reason, details) => { + logger.error(`User disconnected, ${socket.id} reason: ${reason} details: ${details}`) handleDisconnect(socket) await stopFfmpegRecording(socket.id) }); diff --git a/deploy/env.docker.template b/deploy/env.docker.template new file mode 100644 index 0000000..0608be5 --- /dev/null +++ b/deploy/env.docker.template @@ -0,0 +1,66 @@ +# ============================================================================= +# CrowdStream — Docker environment reference +# +# Copy the BACKEND section into backend/.env (the compose files load it via +# env_file). The compose files then FORCE-OVERRIDE the infra endpoints +# (MONGO_DB_URL, REDIS_HOST/PORT*, PORT, INSTANCE_ID, *_IP, NODE_ENV) via their +# own `environment:` blocks, so the containerized Mongo/Redis are always used +# regardless of what you put here — you only really need to set the secrets and +# tuning values below. +# +# cp deploy/env.docker.template backend/.env # then edit the secrets +# +# The FRONTEND section is used two ways: +# - local : Vite reads frontend/.env at runtime (bind-mounted) — put them there. +# - prod : passed as build args (Vite inlines them at build time). Provide +# them in your shell or a root .env consumed by docker compose. +# ============================================================================= + +# ------------------------------- BACKEND ------------------------------------- +NODE_ENV=development +PORT=3000 +INSTANCE_ID=node-1 + +# --- Mongo & Redis (overridden by compose to point at the containers) --- +MONGO_DB_URL=mongodb://127.0.0.1:27017 +DATABASE_NAME=crowdstream +REDIS_HOST=127.0.0.1 +REDIS_PORT1=6379 +REDIS_PORT2=6380 +REDIS_PORT3=6381 +# Used by infra/redis/init-cluster.sh (the compose init uses fixed ports): +REDIS_PORT4=6382 +REDIS_PORT5=6383 +REDIS_PORT6=6384 + +# --- mediasoup / WebRTC --- +# With host networking these bind directly to the host. For real remote clients +# set PUBLIC_IP / ANNOUCED_IP to the server's public IP and open the RTC range. +PUBLIC_IP=127.0.0.1 +ANNOUCED_IP=127.0.0.1 +RECORDING_IP=127.0.0.1 +RTC_MIN_PORT=40000 +RTC_MAX_PORT=40100 +MEDIASOUP_WORKER=2 +MEDIASOUP_MAX_WORKERS=4 +WORKER_THRESHOLD=500 +VIDEO_PORT=5004 +AUDIO_PORT=5006 + +# --- HTTP / auth / TURN secrets (CHANGE THESE) --- +CORS_ORIGINS=* +JWT_SECRET=change-me-to-a-long-random-string +ACCESS_TOKEN_EXPIRY=1d +TURN_SECRET=change-me-turn-shared-secret +TURN_TTL=86400 + +# ------------------------------- FRONTEND ------------------------------------ +# API/signaling are same-origin relative paths in the app, so these are mostly +# for TURN. Leave TURN blank to rely on STUN only. +VITE_API_URL=/backend/api/v1 +VITE_SIGNALING_URL=/ +VITE_TURN_USERNAME= +VITE_TURN_CREDENTIAL= +VITE_TURN_UDP_URL= +VITE_TURN_TCP_URL= +VITE_TURNS_TCP_URL= diff --git a/deploy/nginx/local.conf b/deploy/nginx/local.conf new file mode 100644 index 0000000..8f5be5e --- /dev/null +++ b/deploy/nginx/local.conf @@ -0,0 +1,41 @@ +# Local ingress: single same-origin entrypoint (:80) for the dev stack. +# Runs with host networking, so upstreams are on 127.0.0.1. +# / -> Vite dev server (:5173), WebSocket upgrade enables HMR +# /backend/ -> backend (:3000), prefix stripped by the trailing slash +# /socket.io/ -> backend (:3000) Socket.IO +server { + listen 80; + server_name _; + + location / { + proxy_pass http://127.0.0.1:5173; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /backend/ { + proxy_pass http://127.0.0.1:3000/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /socket.io/ { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } +} diff --git a/deploy/redis/init-cluster.sh b/deploy/redis/init-cluster.sh new file mode 100755 index 0000000..0949acc --- /dev/null +++ b/deploy/redis/init-cluster.sh @@ -0,0 +1,33 @@ +#!/bin/sh +# Initialize the 6-node Redis cluster used by the backend for sharded pub/sub. +# +# Runs as a one-shot, host-networked init container (see the compose files), so +# the nodes are reachable on 127.0.0.1. Idempotent: exits early if the cluster +# is already formed, otherwise creates it (3 masters + 3 replicas). +set -e + +HOST="127.0.0.1" +PORTS="6379 6380 6381 6382 6383 6384" + +echo "Waiting for Redis nodes..." +for PORT in $PORTS; do + echo " waiting for $HOST:$PORT ..." + until redis-cli -h "$HOST" -p "$PORT" ping >/dev/null 2>&1; do + sleep 1 + done + echo " $HOST:$PORT is up." +done + +if redis-cli -h "$HOST" -p 6379 cluster info 2>/dev/null | grep -q "cluster_state:ok"; then + echo "Redis cluster already initialized." + exit 0 +fi + +echo "Creating Redis cluster (3 masters + 3 replicas)..." +redis-cli --cluster create \ + "$HOST:6379" "$HOST:6380" "$HOST:6381" \ + "$HOST:6382" "$HOST:6383" "$HOST:6384" \ + --cluster-replicas 1 \ + --cluster-yes + +echo "Redis cluster created successfully." diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..8c14ebb --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,166 @@ +# ============================================================================= +# CrowdStream — LOCAL development stack (hot reload) +# +# cp deploy/env.docker.template backend/.env # once, then edit secrets +# docker compose -f docker-compose.local.yml up --build +# +# Then open http://localhost/ (nginx ingress -> Vite + backend). +# +# All services use host networking (matching infra/): they reach each other on +# 127.0.0.1:. Backend & frontend source are bind-mounted for hot reload. +# ============================================================================= + +services: + mongo: + image: mongo:7 + container_name: crowdstream-mongo + network_mode: host + command: ["mongod", "--bind_ip", "127.0.0.1", "--port", "27017"] + volumes: + - mongo-data:/data/db + healthcheck: + test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + restart: unless-stopped + + redis-1: + image: redis:7-alpine + container_name: crowdstream-redis-1 + network_mode: host + command: ["redis-server", "/usr/local/etc/redis/redis.conf"] + volumes: + - ./infra/redis/redis-1/redis.conf:/usr/local/etc/redis/redis.conf:ro + - redis-1-data:/data + restart: unless-stopped + + redis-2: + image: redis:7-alpine + container_name: crowdstream-redis-2 + network_mode: host + command: ["redis-server", "/usr/local/etc/redis/redis.conf"] + volumes: + - ./infra/redis/redis-2/redis.conf:/usr/local/etc/redis/redis.conf:ro + - redis-2-data:/data + restart: unless-stopped + + redis-3: + image: redis:7-alpine + container_name: crowdstream-redis-3 + network_mode: host + command: ["redis-server", "/usr/local/etc/redis/redis.conf"] + volumes: + - ./infra/redis/redis-3/redis.conf:/usr/local/etc/redis/redis.conf:ro + - redis-3-data:/data + restart: unless-stopped + + redis-4: + image: redis:7-alpine + container_name: crowdstream-redis-4 + network_mode: host + command: ["redis-server", "/usr/local/etc/redis/redis.conf"] + volumes: + - ./infra/redis/redis-4/redis.conf:/usr/local/etc/redis/redis.conf:ro + - redis-4-data:/data + restart: unless-stopped + + redis-5: + image: redis:7-alpine + container_name: crowdstream-redis-5 + network_mode: host + command: ["redis-server", "/usr/local/etc/redis/redis.conf"] + volumes: + - ./infra/redis/redis-5/redis.conf:/usr/local/etc/redis/redis.conf:ro + - redis-5-data:/data + restart: unless-stopped + + redis-6: + image: redis:7-alpine + container_name: crowdstream-redis-6 + network_mode: host + command: ["redis-server", "/usr/local/etc/redis/redis.conf"] + volumes: + - ./infra/redis/redis-6/redis.conf:/usr/local/etc/redis/redis.conf:ro + - redis-6-data:/data + restart: unless-stopped + + # One-shot: forms the cluster once all nodes are up (idempotent). + redis-init: + image: redis:7-alpine + container_name: crowdstream-redis-init + network_mode: host + depends_on: + - redis-1 + - redis-2 + - redis-3 + - redis-4 + - redis-5 + - redis-6 + volumes: + - ./deploy/redis/init-cluster.sh:/init-cluster.sh:ro + entrypoint: ["sh", "/init-cluster.sh"] + restart: "no" + + backend: + build: + context: ./backend + target: dev + container_name: crowdstream-backend + network_mode: host + env_file: + - ./backend/.env + # These override backend/.env so the containerized infra is always used. + environment: + NODE_ENV: development + PORT: "3000" + INSTANCE_ID: node-1 + MONGO_DB_URL: mongodb://127.0.0.1:27017 + REDIS_HOST: "127.0.0.1" + REDIS_PORT1: "6379" + REDIS_PORT2: "6380" + REDIS_PORT3: "6381" + PUBLIC_IP: "127.0.0.1" + ANNOUCED_IP: "127.0.0.1" + RECORDING_IP: "127.0.0.1" + volumes: + - ./backend:/app + - /app/node_modules + depends_on: + mongo: + condition: service_healthy + redis-init: + condition: service_completed_successfully + restart: unless-stopped + + frontend: + build: + context: ./frontend + target: dev + container_name: crowdstream-frontend + network_mode: host + volumes: + - ./frontend:/app + - /app/node_modules + restart: unless-stopped + + nginx: + image: nginx:alpine + container_name: crowdstream-nginx + network_mode: host + volumes: + - ./deploy/nginx/local.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - backend + - frontend + restart: unless-stopped + +volumes: + mongo-data: + redis-1-data: + redis-2-data: + redis-3-data: + redis-4-data: + redis-5-data: + redis-6-data: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..2aeb849 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,166 @@ +# ============================================================================= +# CrowdStream — PRODUCTION stack (optimized images) +# +# cp deploy/env.docker.template backend/.env # once, then set real secrets +# export HOST_PUBLIC_IP= # for WebRTC to remote clients +# docker compose -f docker-compose.prod.yml up --build -d +# +# Differences from local: +# - backend runs compiled `node dist/index.js` (no ts-node, no bind mounts) +# - frontend is a static build served by nginx, which also proxies the API and +# acts as the :80 ingress (there is no separate nginx service) +# - VITE_* values are baked into the frontend at build time (build args below) +# +# All services use host networking. Set HOST_PUBLIC_IP (and open the RTC port +# range on the host firewall) for media to reach clients outside the host. +# ============================================================================= + +services: + mongo: + image: mongo:7 + container_name: crowdstream-mongo + network_mode: host + command: ["mongod", "--bind_ip", "127.0.0.1", "--port", "27017"] + volumes: + - mongo-data:/data/db + healthcheck: + test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + restart: unless-stopped + + redis-1: + image: redis:7-alpine + container_name: crowdstream-redis-1 + network_mode: host + command: ["redis-server", "/usr/local/etc/redis/redis.conf"] + volumes: + - ./infra/redis/redis-1/redis.conf:/usr/local/etc/redis/redis.conf:ro + - redis-1-data:/data + restart: unless-stopped + + redis-2: + image: redis:7-alpine + container_name: crowdstream-redis-2 + network_mode: host + command: ["redis-server", "/usr/local/etc/redis/redis.conf"] + volumes: + - ./infra/redis/redis-2/redis.conf:/usr/local/etc/redis/redis.conf:ro + - redis-2-data:/data + restart: unless-stopped + + redis-3: + image: redis:7-alpine + container_name: crowdstream-redis-3 + network_mode: host + command: ["redis-server", "/usr/local/etc/redis/redis.conf"] + volumes: + - ./infra/redis/redis-3/redis.conf:/usr/local/etc/redis/redis.conf:ro + - redis-3-data:/data + restart: unless-stopped + + redis-4: + image: redis:7-alpine + container_name: crowdstream-redis-4 + network_mode: host + command: ["redis-server", "/usr/local/etc/redis/redis.conf"] + volumes: + - ./infra/redis/redis-4/redis.conf:/usr/local/etc/redis/redis.conf:ro + - redis-4-data:/data + restart: unless-stopped + + redis-5: + image: redis:7-alpine + container_name: crowdstream-redis-5 + network_mode: host + command: ["redis-server", "/usr/local/etc/redis/redis.conf"] + volumes: + - ./infra/redis/redis-5/redis.conf:/usr/local/etc/redis/redis.conf:ro + - redis-5-data:/data + restart: unless-stopped + + redis-6: + image: redis:7-alpine + container_name: crowdstream-redis-6 + network_mode: host + command: ["redis-server", "/usr/local/etc/redis/redis.conf"] + volumes: + - ./infra/redis/redis-6/redis.conf:/usr/local/etc/redis/redis.conf:ro + - redis-6-data:/data + restart: unless-stopped + + redis-init: + image: redis:7-alpine + container_name: crowdstream-redis-init + network_mode: host + depends_on: + - redis-1 + - redis-2 + - redis-3 + - redis-4 + - redis-5 + - redis-6 + volumes: + - ./deploy/redis/init-cluster.sh:/init-cluster.sh:ro + entrypoint: ["sh", "/init-cluster.sh"] + restart: "no" + + backend: + build: + context: ./backend + target: prod + image: crowdstream-backend:prod + container_name: crowdstream-backend + network_mode: host + env_file: + - ./backend/.env + environment: + NODE_ENV: production + PORT: "3000" + INSTANCE_ID: node-1 + MONGO_DB_URL: mongodb://127.0.0.1:27017 + REDIS_HOST: "127.0.0.1" + REDIS_PORT1: "6379" + REDIS_PORT2: "6380" + REDIS_PORT3: "6381" + PUBLIC_IP: "${HOST_PUBLIC_IP:-127.0.0.1}" + ANNOUCED_IP: "${HOST_PUBLIC_IP:-127.0.0.1}" + RECORDING_IP: "127.0.0.1" + depends_on: + mongo: + condition: service_healthy + redis-init: + condition: service_completed_successfully + restart: unless-stopped + + # Built SPA served by nginx; also proxies /backend + /socket.io -> :3000. + # This is the production ingress on :80. + frontend: + build: + context: ./frontend + target: prod + args: + VITE_API_URL: "${VITE_API_URL:-/backend/api/v1}" + VITE_SIGNALING_URL: "${VITE_SIGNALING_URL:-/}" + VITE_TURN_USERNAME: "${VITE_TURN_USERNAME:-}" + VITE_TURN_CREDENTIAL: "${VITE_TURN_CREDENTIAL:-}" + VITE_TURN_UDP_URL: "${VITE_TURN_UDP_URL:-}" + VITE_TURN_TCP_URL: "${VITE_TURN_TCP_URL:-}" + VITE_TURNS_TCP_URL: "${VITE_TURNS_TCP_URL:-}" + image: crowdstream-frontend:prod + container_name: crowdstream-frontend + network_mode: host + depends_on: + - backend + restart: unless-stopped + +volumes: + mongo-data: + redis-1-data: + redis-2-data: + redis-3-data: + redis-4-data: + redis-5-data: + redis-6-data: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..9bb2198 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,13 @@ +node_modules +dist +dist-ssr +*.local +logs +*.log +.env +.env.* +.git +.gitignore +Dockerfile +.dockerignore +README.md diff --git a/frontend/Dockerfile b/frontend/Dockerfile index da9f2ba..a8ce905 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,18 +1,46 @@ -FROM node:20-slim AS builder - +# syntax=docker/dockerfile:1 +# +# Multi-stage frontend image for CrowdStream (React + Vite). +# - target `dev` : Vite dev server with HMR (source bind-mounted by compose) +# - target `prod` : static build served by nginx, which also reverse-proxies +# /backend and /socket.io to the backend (acts as ingress). + +# ---- dev: Vite dev server; real source comes from a bind mount in compose ---- +FROM node:20-slim AS dev WORKDIR /app - COPY package*.json ./ RUN npm install - COPY . . +EXPOSE 5173 +# package.json "dev" = `vite --host` +CMD ["npm", "run", "dev"] +# ---- build: produce static assets. Vite inlines import.meta.env.VITE_* at +# build time, so they must be present as env before `vite build`. ---- +FROM node:20-slim AS build +WORKDIR /app +ARG VITE_API_URL= +ARG VITE_SIGNALING_URL= +ARG VITE_TURN_USERNAME= +ARG VITE_TURN_CREDENTIAL= +ARG VITE_TURN_UDP_URL= +ARG VITE_TURN_TCP_URL= +ARG VITE_TURNS_TCP_URL= +ENV VITE_API_URL=$VITE_API_URL \ + VITE_SIGNALING_URL=$VITE_SIGNALING_URL \ + VITE_TURN_USERNAME=$VITE_TURN_USERNAME \ + VITE_TURN_CREDENTIAL=$VITE_TURN_CREDENTIAL \ + VITE_TURN_UDP_URL=$VITE_TURN_UDP_URL \ + VITE_TURN_TCP_URL=$VITE_TURN_TCP_URL \ + VITE_TURNS_TCP_URL=$VITE_TURNS_TCP_URL +COPY package*.json ./ +RUN npm ci +COPY . . RUN npm run build -FROM nginx:alpine - -COPY --from=builder /app/dist /usr/share/nginx/html - +# ---- prod: nginx serves the SPA and proxies the API/socket to the backend ---- +FROM nginx:alpine AS prod +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html EXPOSE 80 - -CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..92700c2 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,38 @@ +# Production ingress: serve the built SPA and reverse-proxy the API + Socket.IO +# to the backend. Runs with host networking, so the backend is at 127.0.0.1:3000. +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # SPA client-side routing fallback. + location / { + try_files $uri $uri/ /index.html; + } + + # REST API. Trailing slash strips the /backend/ prefix: + # /backend/api/v1/... -> /api/v1/... ; /backend/db/__ping -> /db/__ping + location /backend/ { + proxy_pass http://127.0.0.1:3000/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Socket.IO signaling (WebSocket upgrade + long-lived connections). + location /socket.io/ { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } +} diff --git a/frontend/src/pages/Broadcaster.tsx b/frontend/src/pages/Broadcaster.tsx index 2558300..234d9dd 100644 --- a/frontend/src/pages/Broadcaster.tsx +++ b/frontend/src/pages/Broadcaster.tsx @@ -1,4 +1,5 @@ -import { useRef, useState } from "react"; +import { useRef, useState, useEffect } from "react"; +import { connectSocket , disconnectSocket } from "../socket"; import { CameraOff, Camera, @@ -51,6 +52,16 @@ export default function BroadcasterPage() { setLogs((prev) => [...prev, { message, timestamp: new Date() }]); }; + + + useEffect(() => { + const socket = connectSocket(); + + return () => { + disconnectSocket(); + }; + }, []); + async function startBroadcast() { try { log("Creating room..."); @@ -58,6 +69,7 @@ export default function BroadcasterPage() { const room = await broadcaster.createRoom(); setRoomId(room.id); + (window as any).__csRoomId = room.id; log("Fetching RTP capabilities..."); @@ -84,6 +96,7 @@ export default function BroadcasterPage() { await broadcaster.startProducing(stream); setIsLive(true); + (window as any).__csLiveAt = Date.now(); log("Broadcast started successfully."); } catch (err: any) { diff --git a/frontend/src/pages/ViewerPage.tsx b/frontend/src/pages/ViewerPage.tsx index 71a06bd..dd87551 100644 --- a/frontend/src/pages/ViewerPage.tsx +++ b/frontend/src/pages/ViewerPage.tsx @@ -3,6 +3,7 @@ import { useSearchParams } from "react-router-dom"; import { X, Circle } from "lucide-react"; import api from "../api/axios"; +import { connectSocket , disconnectSocket } from "../socket"; import Viewer from "../viewer"; import SystemLogs from "../components/broadcaster/SystemLogs"; @@ -20,7 +21,7 @@ interface Log { const viewer = new Viewer(); export default function ViewerPage() { - const socket = getSocket() + const socket = connectSocket() const [searchParams] = useSearchParams(); const videoRef = useRef(null); @@ -52,6 +53,14 @@ export default function ViewerPage() { ]); }; + // useEffect(() => { + // const socket = connectSocket(); + + // return () => { + // disconnectSocket(); + // }; + // }, []); + async function joinRoom(e: React.FormEvent) { e.preventDefault(); @@ -86,6 +95,7 @@ export default function ViewerPage() { await viewer.connectionState(roomId); + (window as any).__csJoinedAt = Date.now(); setConnected(true); startHeartBeat(); diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index 72bcdee..adc6128 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -16,10 +16,10 @@ export default function Router() { } /> } /> + } /> + } /> + } /> }> - } /> - } /> - } /> diff --git a/frontend/src/socket.ts b/frontend/src/socket.ts index 694eedc..003b4d3 100644 --- a/frontend/src/socket.ts +++ b/frontend/src/socket.ts @@ -11,6 +11,7 @@ export function connectSocket() { } socket = io(window.location.origin); + (window as any).__csSocket = socket; socket.on("connect", () => { console.log("Client connected", socket?.id); @@ -22,6 +23,7 @@ export function connectSocket() { socket.on("connect_error", (error) => { console.log("Error", error.message); + console.log("SOCKET CONNECT ERROR DETAILS:", error); }); socket.on("debug:instance", (data) => { diff --git a/frontend/src/viewer.ts b/frontend/src/viewer.ts index 7e9b0c6..223c6f5 100644 --- a/frontend/src/viewer.ts +++ b/frontend/src/viewer.ts @@ -283,6 +283,9 @@ class Viewer{ mediaStream.addTrack(track) }) viewerVideo.current.srcObject = mediaStream + viewerVideo.current.addEventListener('loadeddata', () => { + (window as any).__csFirstFrameAt = Date.now(); + }, { once: true }); if(viewerVideo.current){ console.log('Viewer started playing') }else{ diff --git a/load-test/sfu-capacity-results.csv b/load-test/sfu-capacity-results.csv new file mode 100644 index 0000000..3301022 --- /dev/null +++ b/load-test/sfu-capacity-results.csv @@ -0,0 +1,101 @@ +timestamp,viewers,joinP50,joinP99,firstFrameP50,firstFrameP99,failures +2026-08-19T03:33:16.173Z,1,341,341,362,362,0 +2026-08-19T03:33:20.123Z,2,346,346,388,388,0 +2026-08-19T03:33:23.971Z,3,336,336,381,381,0 +2026-08-19T03:33:28.038Z,4,373,373,397,397,0 +2026-08-19T03:33:31.922Z,5,353,353,385,385,0 +2026-08-19T03:33:36.021Z,6,366,366,410,410,0 +2026-08-19T03:33:40.121Z,7,349,349,394,394,0 +2026-08-19T03:33:44.121Z,8,387,387,434,434,0 +2026-08-19T03:33:48.489Z,9,378,378,395,395,0 +2026-08-19T03:33:52.587Z,10,395,395,419,419,0 +2026-08-19T03:33:56.771Z,11,396,396,436,436,0 +2026-08-19T03:34:01.380Z,12,389,389,408,408,0 +2026-08-19T03:34:05.571Z,13,407,407,435,435,0 +2026-08-19T03:34:09.634Z,14,428,428,461,461,0 +2026-08-19T03:34:13.986Z,15,423,423,457,457,0 +2026-08-19T03:34:18.386Z,16,410,410,442,442,0 +2026-08-19T03:34:22.536Z,17,417,417,468,468,0 +2026-08-19T03:34:26.983Z,18,445,445,465,465,0 +2026-08-19T03:34:31.244Z,19,461,461,487,487,0 +2026-08-19T03:34:35.383Z,20,475,475,494,494,0 +2026-08-19T03:34:39.585Z,21,486,486,516,516,0 +2026-08-19T03:34:43.967Z,22,457,457,489,489,0 +2026-08-19T03:34:48.652Z,23,468,468,533,533,0 +2026-08-19T03:34:53.038Z,24,507,507,563,563,0 +2026-08-19T03:34:57.740Z,25,500,500,554,554,0 +2026-08-19T03:35:02.559Z,26,486,486,516,516,0 +2026-08-19T03:35:07.261Z,27,541,541,574,574,0 +2026-08-19T03:35:12.083Z,28,559,559,580,580,0 +2026-08-19T03:35:16.852Z,29,500,500,549,549,0 +2026-08-19T03:35:21.758Z,30,552,552,597,597,0 +2026-08-19T03:35:26.751Z,31,534,534,599,599,0 +2026-08-19T03:35:31.664Z,32,535,535,577,577,0 +2026-08-19T03:35:36.757Z,33,597,597,652,652,0 +2026-08-19T03:35:41.663Z,34,556,556,610,610,0 +2026-08-19T03:35:46.585Z,35,581,581,607,607,0 +2026-08-19T03:35:51.574Z,36,630,630,675,675,0 +2026-08-19T03:35:56.683Z,37,666,666,688,688,0 +2026-08-19T03:36:01.866Z,38,690,690,760,760,0 +2026-08-19T03:36:06.964Z,39,679,679,739,739,0 +2026-08-19T03:36:12.085Z,40,673,673,699,699,0 +2026-08-19T03:36:17.367Z,41,695,695,771,771,0 +2026-08-19T03:36:22.760Z,42,695,695,750,750,0 +2026-08-19T03:36:28.098Z,43,727,727,749,749,0 +2026-08-19T03:36:33.672Z,44,737,737,821,821,0 +2026-08-19T03:36:39.166Z,45,745,745,822,822,0 +2026-08-19T03:36:44.684Z,46,813,813,894,894,0 +2026-08-19T03:36:50.184Z,47,818,818,882,882,0 +2026-08-19T03:36:55.881Z,48,850,850,926,926,0 +2026-08-19T03:37:01.900Z,49,911,911,952,952,0 +2026-08-19T03:37:07.882Z,50,800,800,876,876,0 +2026-08-19T03:37:13.888Z,51,951,951,1029,1029,0 +2026-08-19T03:37:20.212Z,52,978,978,1023,1023,0 +2026-08-19T03:37:26.388Z,53,987,987,1084,1084,0 +2026-08-19T03:37:32.855Z,54,1262,1262,1301,1301,0 +2026-08-19T03:37:39.202Z,55,1037,1037,1097,1097,0 +2026-08-19T03:37:46.003Z,56,1077,1077,1109,1109,0 +2026-08-19T03:37:52.557Z,57,1062,1062,1107,1107,0 +2026-08-19T03:37:59.297Z,58,1209,1209,1301,1301,0 +2026-08-19T03:38:06.254Z,59,1210,1210,1308,1308,0 +2026-08-19T03:38:13.195Z,60,1386,1386,1489,1489,0 +2026-08-19T03:38:20.308Z,61,1333,1333,1422,1422,0 +2026-08-19T03:38:28.064Z,62,1319,1319,1412,1412,0 +2026-08-19T03:38:35.652Z,63,1412,1412,1505,1505,0 +2026-08-19T03:38:44.576Z,64,2868,2868,2958,2958,0 +2026-08-19T03:38:52.598Z,65,1935,1935,2032,2032,0 +2026-08-19T03:39:41.698Z,66,n/a,n/a,n/a,n/a,1 +2026-08-19T03:39:50.785Z,67,1782,1782,1860,1860,1 +2026-08-19T03:40:00.474Z,68,1721,1721,1872,1872,1 +2026-08-19T03:40:10.696Z,69,1821,1821,1968,1968,1 +2026-08-19T03:40:20.764Z,70,2004,2004,2103,2103,1 +2026-08-19T03:40:31.929Z,71,2290,2290,2400,2400,1 +2026-08-19T03:41:22.868Z,72,n/a,n/a,n/a,n/a,2 +2026-08-19T03:41:34.524Z,73,2372,2372,2581,2581,2 +2026-08-19T03:41:47.701Z,74,2935,2935,3021,3021,2 +2026-08-19T03:42:39.292Z,75,n/a,n/a,n/a,n/a,3 +2026-08-19T03:42:51.845Z,76,2236,2236,2347,2347,3 +2026-08-19T03:43:04.971Z,77,2510,2510,2545,2545,3 +2026-08-19T03:43:21.097Z,78,3961,3961,4067,4067,3 +2026-08-19T03:44:15.879Z,79,n/a,n/a,n/a,n/a,4 +2026-08-19T03:44:32.753Z,80,4622,4622,4902,4902,4 +2026-08-19T03:44:43.493Z,81,1798,1798,1902,1902,4 +2026-08-19T03:44:51.573Z,82,1697,1697,1756,1756,4 +2026-08-19T03:45:07.981Z,83,3197,3197,3389,3389,4 +2026-08-19T03:45:23.895Z,84,3248,3248,3566,3566,4 +2026-08-19T03:45:30.251Z,85,1241,1241,1373,1373,4 +2026-08-19T03:45:40.065Z,86,1708,1708,1813,1813,4 +2026-08-19T03:45:52.749Z,87,4098,4098,4200,4200,4 +2026-08-19T03:46:04.922Z,88,2382,2382,2444,2444,4 +2026-08-19T03:46:56.247Z,89,n/a,n/a,n/a,n/a,5 +2026-08-19T03:47:08.676Z,90,748,748,874,874,5 +2026-08-19T03:47:16.974Z,91,2736,2736,2854,2854,5 +2026-08-19T03:47:28.818Z,92,2608,2608,2792,2792,5 +2026-08-19T03:48:22.194Z,93,n/a,n/a,n/a,n/a,6 +2026-08-19T03:48:28.777Z,94,1179,1179,1258,1258,6 +2026-08-19T03:49:15.344Z,95,n/a,n/a,n/a,n/a,7 +2026-08-19T03:49:26.690Z,96,1321,1321,1393,1393,7 +2026-08-19T03:49:37.293Z,97,2924,2924,3184,3184,7 +2026-08-19T03:50:28.698Z,98,n/a,n/a,n/a,n/a,8 +2026-08-19T03:50:42.666Z,99,1211,1211,1274,1274,8 +2026-08-19T03:50:56.765Z,100,3810,3810,3888,3888,8 \ No newline at end of file diff --git a/load-test/sfu-capacity.js b/load-test/sfu-capacity.js new file mode 100644 index 0000000..6048293 --- /dev/null +++ b/load-test/sfu-capacity.js @@ -0,0 +1,854 @@ +const puppeteer = require("puppeteer"); +const fs = require("fs"); + +function arg(name, def) { + const i = process.argv.indexOf(`--${name}`); + if (i === -1) return def; + return process.argv[i + 1]; +} + +const BASE_URL = arg("baseUrl", "http://localhost"); +// const TOKEN = arg("token", null); + +const TEST_EMAIL = "harsxit04@gmail.com" +const TEST_PASSWORD = "@Harshit1308" + +if (!TEST_EMAIL || !TEST_PASSWORD) { + console.error( + "Set CROWDSTREAM_TEST_EMAIL and CROWDSTREAM_TEST_PASSWORD" + ); + process.exit(1); +} + + +const BATCH_SIZE = parseInt(arg("batchSize", "5"), 10); +const BATCH_INTERVAL_MS = parseInt( + arg("batchIntervalMs", "10000"), + 10 +); +const MAX_VIEWERS = parseInt( + arg("maxViewers", "100"), + 10 +); + +const OUT_CSV = arg( + "out", + "sfu-capacity-results.csv" +); + +const WAIT_TIMEOUT_MS = 20000; +let failures = 0; + +// if (!TOKEN) { +// console.error("Missing --token "); +// process.exit(1); +// } + +const CHROME_FLAGS = [ + "--use-fake-device-for-media-stream", + "--use-fake-ui-for-media-stream", + "--disable-gpu", + "--no-sandbox", + "--mute-audio", +]; + +async function login(page) { + console.log("Opening signin page..."); + + await page.goto(`${BASE_URL}/signin`, { + waitUntil: "networkidle2", + timeout: 30000, + }); + + await page.waitForSelector("#email", { + timeout: WAIT_TIMEOUT_MS, + }); + + await page.type("#email", TEST_EMAIL); + await page.type("#password", TEST_PASSWORD); + + await page.click('button[type="submit"]'); + + // SignInPage navigates to /dashboard after successful sign-in. + await page.waitForFunction( + () => window.location.pathname === "/dashboard", + { timeout: WAIT_TIMEOUT_MS } + ); + + console.log("Login successful:", await page.url()); + await page.waitForFunction( + () => + window.__csSocket && + window.__csSocket.connected === true, + { + timeout: WAIT_TIMEOUT_MS, + } + ); + + console.log( + "Socket connected:", + await page.evaluate(() => ({ + connected: window.__csSocket.connected, + id: window.__csSocket.id, + })) + ); +} + +async function withAuthCookie(page) { + const hostname = new URL(BASE_URL).hostname; + + await page.setCookie({ + name: "accessToken", + value: TOKEN, + domain: hostname, + path: "/", + }); +} + +function percentile(values, p) { + if (!values.length) return null; + + const index = + Math.ceil((p / 100) * values.length) - 1; + + return values[ + Math.min( + Math.max(index, 0), + values.length - 1 + ) + ]; +} + +/* + * --------------------------------------------------------- + * BROADCASTER + * --------------------------------------------------------- + */ + +async function launchBroadcaster(browser) { + const context = + browser.defaultBrowserContext(); + + await context.overridePermissions( + BASE_URL, + ["camera", "microphone"] + ); + + const page = await browser.newPage(); + await login(page); + + page.on("response", async (response) => { + const request = response.request(); + + await page.waitForFunction( + () => window.__csSocket, + { timeout: 10000 } + ); + + const socketState = await page.evaluate(() => ({ + connected: window.__csSocket.connected, + id: window.__csSocket.id, + })); + + console.log("SOCKET STATE:", socketState); + + if ( + request.method() !== "GET" || + response.status() >= 400 + ) { + console.log( + `[HTTP ${response.status()}] ${request.method()} ${response.url()}` + ); + + try { + const body = await response.text(); + console.log( + `[HTTP BODY] ${body.slice(0, 1000)}` + ); + } catch {} + } + }); + + /* + * IMPORTANT: + * Show us what the React application is actually doing. + */ + + page.on("console", (msg) => { + console.log( + `[BROADCASTER ${msg.type()}] ${msg.text()}` + ); + }); + + page.on("pageerror", (error) => { + console.error( + `[BROADCASTER PAGE ERROR] ${error.message}` + ); + }); + + page.on("requestfailed", (request) => { + console.error( + `[REQUEST FAILED] ${request.method()} ${request.url()}` + ); + + console.error( + `Reason: ${request.failure()?.errorText}` + ); + }); + + // await withAuthCookie(page); + + console.log( + `Opening broadcaster: ${BASE_URL}/broadcaster` + ); + + await page.goto( + `${BASE_URL}/broadcaster`, + { + waitUntil: "networkidle2", + timeout: 30000, + } + ); + + console.log( + "Broadcaster page loaded." + ); + + console.log("\n========== PAGE DEBUG =========="); + + const debug = await page.evaluate(() => ({ + url: window.location.href, + title: document.title, + body: document.body.innerText, + buttons: Array.from(document.querySelectorAll("button")).map( + (b) => ({ + text: b.innerText, + disabled: b.disabled, + }) + ), + })); + + console.log("URL:", debug.url); + console.log("TITLE:", debug.title); + + console.log("\nBUTTONS:"); + console.log(JSON.stringify(debug.buttons, null, 2)); + + console.log("\nPAGE TEXT:"); + console.log(debug.body); + + console.log("========== END DEBUG ==========\n"); + + /* + * Make sure the Go Live button actually exists. + */ + + await page.waitForFunction( + () => + Array.from( + document.querySelectorAll("button") + ).some((button) => + button.textContent?.includes("Go live") + ), + { + timeout: WAIT_TIMEOUT_MS, + } + ); + + console.log( + '"Go live" button found.' + ); + + /* + * Click Go Live. + */ + + await page.evaluate(() => { + const button = Array.from( + document.querySelectorAll("button") + ).find((button) => + button.textContent?.includes("Go live") + ); + + if (!button) { + throw new Error( + "Go live button disappeared" + ); + } + + button.click(); + }); + + console.log( + 'Clicked "Go live".' + ); + + /* + * Now wait for the room marker. + * + * Your BroadcasterPage does: + * + * setRoomId(room.id) + * window.__csRoomId = room.id + * + * AFTER createRoom() succeeds. + */ + + try { + await page.waitForFunction( + () => Boolean(window.__csRoomId), + { + timeout: WAIT_TIMEOUT_MS, + } + ); + } catch (error) { + console.error( + "\n========================================" + ); + + console.error( + "BROADCASTER DID NOT CREATE A ROOM" + ); + + console.error( + "========================================" + ); + + console.error( + "Current URL:", + page.url() + ); + + /* + * Print the visible logs from your broadcaster UI. + */ + + const logs = await page.evaluate(() => { + const text = document.body.innerText; + + return text.slice(-5000); + }); + + console.error( + "\nBrowser page text:\n" + ); + + console.error(logs); + + /* + * Save a screenshot so we can see exactly + * what Puppeteer saw. + */ + + await page.screenshot({ + path: "broadcaster-failure.png", + fullPage: true, + }); + + console.error( + "\nScreenshot saved as:" + ); + + console.error( + "broadcaster-failure.png" + ); + + throw new Error( + "Broadcaster did not expose window.__csRoomId" + ); + } + + const roomId = await page.evaluate( + () => window.__csRoomId + ); + + console.log( + `\nROOM CREATED SUCCESSFULLY` + ); + + console.log( + `Room ID: ${roomId}` + ); + + /* + * Wait for your __csLiveAt marker. + */ + + try { + await page.waitForFunction( + () => Boolean(window.__csLiveAt), + { + timeout: WAIT_TIMEOUT_MS, + } + ); + + console.log( + "Broadcaster is LIVE." + ); + } catch { + console.warn( + "Room exists, but __csLiveAt was not detected." + ); + } + + return { + page, + roomId, + }; +} + +/* + * --------------------------------------------------------- + * VIEWER + * --------------------------------------------------------- + */ + +async function launchViewer( + browser, + roomId, + index +) { + const page = await browser.newPage(); + + page.on("console", (msg) => { + console.log( + `[VIEWER ${index} ${msg.type()}] ${msg.text()}` + ); + }); + + page.on("pageerror", (error) => { + console.error( + `[VIEWER ${index} PAGE ERROR] ${error.message}` + ); + }); + + page.on("requestfailed", (request) => { + console.error( + `[VIEWER ${index} REQUEST FAILED] ${request.url()}` + ); + }); + + page.on("websocketcreated", (ws) => { + console.error("[WS CREATED]", ws.url()); + }); + + page.on("websocketclosed", (ws) => { + console.error("[WS CLOSED]", ws.url()); + }); + + page.on("websocketframereceived", (ws, frame) => { + console.error("[WS RX]", frame.slice(0, 300)); + }); + + page.on("websocketframesent", (ws, frame) => { + console.error("[WS TX]", frame.slice(0, 300)); + }); + + // await withAuthCookie(page); + await login(page) + + const startTime = Date.now(); + + try { + await page.goto( + `${BASE_URL}/viewer?roomId=${roomId}`, + { + waitUntil: "domcontentloaded", + timeout: 30000, + } + ); + + await page.waitForSelector( + "form", + { + timeout: WAIT_TIMEOUT_MS, + } + ); + + /* + * Submit the viewer form. + */ + + await page.$eval( + "form", + (form) => { + form.requestSubmit(); + } + ); + + /* + * Wait for your ViewerPage marker. + */ + + let joinedAt = null; + + try { + await page.waitForFunction( + () => Boolean(window.__csJoinedAt), + { + timeout: WAIT_TIMEOUT_MS, + } + ); + + joinedAt = await page.evaluate( + () => window.__csJoinedAt + ); + } catch {} + + /* + * Wait for actual first video frame. + */ + + let firstFrameAt = null; + + try { + await page.waitForFunction( + () => + Boolean( + window.__csFirstFrameAt + ), + { + timeout: WAIT_TIMEOUT_MS, + } + ); + + firstFrameAt = await page.evaluate( + () => window.__csFirstFrameAt + ); + } catch {} + + return { + index, + page, + + joined: Boolean(joinedAt), + + joinLatencyMs: joinedAt + ? joinedAt - startTime + : null, + + firstFrameLatencyMs: + firstFrameAt + ? firstFrameAt - startTime + : null, + + error: null, + }; + } catch (error) { + return { + index, + page, + + joined: false, + + joinLatencyMs: null, + + firstFrameLatencyMs: null, + + error: error.message, + }; + } +} + +/* + * --------------------------------------------------------- + * MAIN + * --------------------------------------------------------- + */ + +async function main() { + console.log( + "========================================" + ); + + console.log( + "CrowdStream REAL SFU MEDIA TEST" + ); + + console.log( + "========================================" + ); + + console.log( + `Frontend: ${BASE_URL}` + ); + + console.log( + `Batch size: ${BATCH_SIZE}` + ); + + console.log( + `Batch interval: ${BATCH_INTERVAL_MS}ms` + ); + + console.log( + `Max viewers: ${MAX_VIEWERS}` + ); + + console.log( + "Backend CPU: not measured yet" + ); + + console.log(""); + + /* + * Launch Chromium. + */ + + const browser = + await puppeteer.launch({ + headless: true, + protocolTimeout: 120000, + args: CHROME_FLAGS, + }); + + try { + /* + * STEP 1 + * + * Create a REAL broadcaster. + */ + + const broadcaster = + await launchBroadcaster( + browser + ); + + const roomId = + broadcaster.roomId; + + /* + * Give mediasoup a moment to stabilize. + */ + + console.log( + "\nWaiting 5 seconds for media..." + ); + + await new Promise( + (resolve) => + setTimeout(resolve, 5000) + ); + + /* + * CSV. + */ + + const csvRows = [ + [ + "timestamp", + "viewers", + "joinP50", + "joinP99", + "firstFrameP50", + "firstFrameP99", + "failures", + ].join(","), + ]; + + const viewers = []; + + /* + * STEP 2 + * + * Add viewers in batches. + */ + + for ( + let target = BATCH_SIZE; + target <= MAX_VIEWERS; + target += BATCH_SIZE + ) { + console.log( + `\n========================================` + ); + + console.log( + `ADDING VIEWERS: ${target}` + ); + + console.log( + `========================================` + ); + + const batch = + await Promise.all( + Array.from( + { + length: BATCH_SIZE, + }, + (_, index) => + launchViewer( + browser, + roomId, + viewers.length + + index + ) + ) + ); + + viewers.push(...batch); + + /* + * Join latency. + */ + + const joinLatencies = + batch + .map( + (viewer) => + viewer.joinLatencyMs + ) + .filter(Number.isFinite) + .sort( + (a, b) => a - b + ); + + /* + * First frame latency. + */ + + const firstFrameLatencies = + batch + .map( + (viewer) => + viewer.firstFrameLatencyMs + ) + .filter(Number.isFinite) + .sort( + (a, b) => a - b + ); + + const failed = + batch.filter( + (viewer) => + !viewer.joined + ).length; + + failures += failed + + const joinP50 = + percentile( + joinLatencies, + 50 + ); + + const joinP99 = + percentile( + joinLatencies, + 99 + ); + + const frameP50 = + percentile( + firstFrameLatencies, + 50 + ); + + const frameP99 = + percentile( + firstFrameLatencies, + 99 + ); + + console.log( + `\nViewers: ${viewers.length}` + ); + + console.log( + `Join P50: ${joinP50 ?? "n/a"} ms` + ); + + console.log( + `Join P99: ${joinP99 ?? "n/a"} ms` + ); + + console.log( + `First frame P50: ${ + frameP50 ?? "n/a" + } ms` + ); + + console.log( + `First frame P99: ${ + frameP99 ?? "n/a" + } ms` + ); + + console.log( + `Failures: ${failures}` + ); + + /* + * Save results. + */ + + csvRows.push( + [ + new Date().toISOString(), + viewers.length, + joinP50 ?? "n/a", + joinP99 ?? "n/a", + frameP50 ?? "n/a", + frameP99 ?? "n/a", + failures, + ].join(",") + ); + + fs.writeFileSync( + OUT_CSV, + csvRows.join("\n") + ); + + /* + * Wait before next batch. + */ + + if ( + target < MAX_VIEWERS + ) { + console.log( + `\nWaiting ${ + BATCH_INTERVAL_MS / 1000 + } seconds...` + ); + + await new Promise( + (resolve) => + setTimeout( + resolve, + BATCH_INTERVAL_MS + ) + ); + } + } + + console.log( + `\n========================================` + ); + + console.log( + "SFU TEST COMPLETE" + ); + + console.log( + `Results: ${OUT_CSV}` + ); + + console.log( + `========================================` + ); + } finally { + await browser.close(); + } +} + +main().catch((error) => { + console.error( + "\nTEST FAILED:" + ); + + console.error(error); + + process.exit(1); +}); \ No newline at end of file