Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

66 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

πŸŽ₯ CrowdStream

Broadcast. Watch. Connect β€” live.

Self-hosted, real-time live streaming infrastructure built for scale. CrowdStream enables low-latency broadcaster-to-audience sessions with live chat, reactions, co-broadcasting, and viewer presence β€” with no third-party streaming dependency, and no cloud vendor lock-in.

Node.js MediaSoup React Socket.IO Redis Coturn


πŸ“– Table of Contents


Overview

CrowdStream is a one-to-many live video streaming system. A broadcaster captures camera and mic and publishes media; many viewers subscribe and consume it. Media never flows peer-to-peer β€” it routes through a mediasoup SFU on the backend. Socket.IO carries all signaling; audio/video itself travels over WebRTC (UDP/DTLS/SRTP), traversing STUN/TURN when a direct path isn't available.

The full stack β€” signaling, SFU, NAT traversal, and multi-pod room routing β€” has been built and validated end to end on a local, self-hosted topology. The design is intentionally cloud-portable: every local component (ngrok, Nginx, HAProxy/Envoy, Coturn) maps one-to-one onto a standard cloud equivalent (public ingress, load balancer/API gateway, TURN relay), so moving to a cloud environment is a configuration swap, not a redesign.


✨ Key Features

Area Capability
πŸŽ₯ Broadcasting Live video and audio over WebRTC, captured directly from browser camera/mic
πŸ“‘ SFU Delivery Multi-viewer fan-out via mediasoup SFU β€” no peer-to-peer mesh, no transcoding
πŸ’¬ Live Chat Real-time chat alongside the stream
❀️ Reactions Real-time emoji reactions
πŸŽ™οΈ Co-broadcasting Multiple active broadcasters in a single room
πŸ‘€ Viewer Presence Live viewer count
🧩 Multi-Pod Signaling Viewers and broadcasters land on different backend pods but still join the same room transparently
🌐 NAT Traversal Self-hosted Coturn (TURN/STUN), fronted by HAProxy and Envoy

πŸ—οΈ Architecture

CrowdStream splits cleanly into a React client (broadcaster + viewer), a Node.js signaling layer, and a mediasoup SFU that owns the actual media plane. A Redis-backed layer sits between backend pods so signaling isn't bound to a single process.

flowchart TB
    subgraph Clients["πŸ–₯️ Clients β€” React + Vite"]
        BC[Broadcaster<br/>broadcaster.ts Β· room.ts<br/>Device Β· sendTransport Β· producers]
        VW[Viewer<br/>viewer.ts Β· room.ts<br/>Device Β· recvTransport Β· consumers]
    end

    subgraph Backend["βš™οΈ Backend Pods β€” Node.js + TypeScript"]
        SOCK[Socket.IO Gateway<br/>registerBroadcaster Β· registerViewer Β· disconnect]
        SIG[Signaling / Domain Logic<br/>viewer.handler Β· broadcaster.util Β· canConsume]
        MEDIA[Media Handlers<br/>consumer.handler β€” pause/resume/close]
    end

    subgraph Coord["πŸ”— Cross-Pod Coordination"]
        REDIS[(Redis<br/>Socket.IO adapter + room registry)]
    end

    subgraph SFU["πŸ“‘ MediaSoup SFU (^3.18)"]
        WORKER[Worker<br/>1 per room Β· rtcPorts 40000–49999]
        ROUTER[Router<br/>per room Β· Opus / VP8 / H264]
        TRANSPORT[WebRtcTransport<br/>send for broadcaster Β· recv per viewer]
    end

    subgraph State["πŸ—„οΈ Runtime State"]
        MEM[(memoryRoom<br/>Map&lt;roomId, Room&gt;)]
        MONGO[(MongoDB<br/>defined, not yet wired in)]
    end

    BC -->|Socket.IO signaling| SOCK
    VW -->|Socket.IO signaling| SOCK
    BC -->|WebRTC UDP/DTLS/SRTP| TRANSPORT
    VW -->|WebRTC UDP/DTLS/SRTP| TRANSPORT
    SOCK <-->|pub/sub across pods| REDIS
    SOCK --> SIG --> MEDIA
    SIG --> ROUTER
    ROUTER --> WORKER
    ROUTER --> TRANSPORT
    SIG --> MEM
    SIG -->|roomId β†’ owning pod| REDIS
    MONGO -.not invoked at runtime.-> MEM
Loading

Notes

  • SFU model: the broadcaster uploads one stream; the SFU fans it out to N viewers β€” no transcoding, no P2P mesh.
  • Runtime state is in-memory (memoryRoom), scoped per pod. The MongoDB layer is defined but not yet wired into the handler flow, so room/session state doesn't survive a restart.
  • One mediasoup Worker per room today β€” no worker pool yet; horizontal scaling across CPU cores within a pod is a known next step.
  • Redis does double duty: it backs the Socket.IO adapter (cross-pod event fan-out) and holds the roomId β†’ pod ownership registry described below.

🧩 Multi-Pod Signaling

A broadcaster and a viewer can land on different backend pods β€” normal behavior behind any load balancer with more than one replica β€” and still end up in the same room without either client being redirected or reconnected.

How it works:

  1. When a broadcaster starts a room, the owning pod creates the mediasoup Worker/Router for that room and writes a roomId β†’ podId mapping into Redis.
  2. The Socket.IO Redis adapter connects every pod's Socket.IO instance to the same Redis pub/sub channel, so a join-room or chat/reaction event emitted from a socket on Pod B is transparently broadcast to sockets on Pod A (and vice versa). From the client's perspective there is a single logical signaling namespace, regardless of which pod terminated its connection.
  3. When a viewer on Pod B requests to join a room owned by Pod A, the signaling layer looks up ownership in Redis and routes the mediasoup-specific calls (getRouterRtpCapabilities, createViewerTransport, connectConsumerTransport, consume) to Pod A internally β€” the viewer's browser still talks to its own pod over Socket.IO/HTTPS the whole time.
  4. Chat, reactions, and viewer-presence events fan out to every pod via Redis pub/sub, so viewers stay in sync no matter which pod they're attached to.
sequenceDiagram
    participant V as Viewer (connected to Pod B)
    participant PB as Backend Pod B
    participant R as Redis (adapter + room registry)
    participant PA as Backend Pod A (owns the room)
    participant SFU as mediasoup SFU (on Pod A)

    V->>PB: joinRoom(roomId)
    PB->>R: lookup roomId β†’ owning pod
    R-->>PB: roomId owned by Pod A
    PB->>R: publish joinRoom event (Socket.IO adapter)
    R-->>PA: deliver joinRoom event
    PA->>SFU: getRouterRtpCapabilities / createViewerTransport
    SFU-->>PA: transport + ICE/DTLS params
    PA->>R: publish response event
    R-->>PB: deliver response event
    PB-->>V: transport params (over its own Socket.IO connection)
    V->>SFU: WebRTC media (ICE/DTLS/SRTP) β€” direct to SFU, not via Pod B
Loading

Why this matters: clients never need to know or care which pod they're attached to, and a room isn't tied to "whichever pod the client happens to hit." This is the same pattern used to horizontally scale any Socket.IO deployment (@socket.io/redis-adapter), extended here with an explicit room-ownership registry so mediasoup-specific calls are routed to the one pod that actually holds the Router for that room.


🌍 Deployment Topology β€” Local ↔ Cloud

The current deployment is fully self-hosted and has been validated locally. Every layer maps directly onto a cloud equivalent β€” nothing in the design is local-only, so moving environments is a matter of pointing the same configuration at managed infrastructure rather than rebuilding it.

Local component Cloud equivalent Role
ngrok Public ingress / DNS + managed TLS Exposes the app publicly
Nginx Nginx (unchanged) or a managed reverse proxy Routes HTTP/Socket.IO traffic to backend pods
HAProxy (TCP) Cloud NLB / TCP listener TURN-over-TCP path for clients where UDP is blocked
Envoy (UDP) Cloud NLB / UDP listener STUN/TURN-over-UDP path β€” the primary ICE path
Coturn Coturn (unchanged) TURN/STUN relay for NAT traversal
Single-pod backend Multiple backend pods behind a load balancer Same Socket.IO + mediasoup code, now horizontally scaled via the Redis adapter above
sequenceDiagram
    participant U as Broadcaster / Viewer
    participant Ingress as Ingress<br/>(ngrok locally / DNS+LB in cloud)
    participant NG as Nginx
    participant BE as Node.js Backend Pod
    participant SFU as mediasoup SFU
    participant TURN as Coturn

    U->>Ingress: HTTPS / Socket.IO
    Ingress->>NG: forward request
    NG->>BE: route to backend pod
    BE->>BE: createRoom / exchange RTP capabilities
    BE->>SFU: create WebRTC transport
    SFU-->>BE: ICE + DTLS parameters
    SFU->>SFU: generate ICE candidates
    SFU->>TURN: STUN binding / TURN allocation
    TURN-->>SFU: relay candidate
    SFU-->>U: candidates returned via signaling
    U->>U: ICE connectivity checks
    U->>TURN: selected candidate pair
    TURN->>SFU: DTLS handshake β†’ SRTP media flow
Loading

πŸ”€ NAT Traversal: HAProxy + Envoy in Front of Coturn

With no managed load balancer in the self-hosted path, TURN/STUN traffic is split across two proxies before it reaches Coturn β€” HAProxy handles the TCP path, Envoy handles the UDP path. This pair stands in for a cloud ALB/NLB and works identically against a cloud-hosted deployment.

flowchart TB
    CLIENT[WebRTC Client] -->|ICE: STUN / TURN| SPLIT{Transport type}
    SPLIT -->|TURN over TCP| HAP[HAProxy<br/>TCP Proxy]
    SPLIT -->|STUN/TURN over UDP| ENVOY[Envoy<br/>UDP Proxy]
    HAP --> TURN[(Coturn)]
    ENVOY --> TURN
    TURN --> RELAY[Relay Media]
    RELAY --> SFU[mediasoup SFU]
Loading
  • HAProxy proxies TURN-over-TCP connections β€” useful for clients on networks that block or throttle UDP.
  • Envoy proxies the UDP path for STUN and TURN β€” the primary route for ICE connectivity checks and media relay.
  • Both sit in front of a single Coturn instance, giving it one consistent entry point regardless of which transport a client's ICE agent picks.
  • This layer is a like-for-like substitute for a cloud ALB/NLB; the same HAProxy/Envoy configuration runs unchanged in front of a cloud-hosted Coturn instance.

πŸ” Key Flows

Broadcast (publish): createRoom β†’ owning pod spins up Worker + Router, registers roomId β†’ podId in Redis β†’ getRouterRtpCapabilities β†’ client loads Device β†’ createBroadcasterTransport β†’ connectBroadcasterTransport (DTLS) β†’ produce (video + audio) β†’ Producers stored in memoryRoom on the owning pod.

View (subscribe), same pod or different pod: joinRoom β†’ signaling layer resolves the room's owning pod via Redis (transparent if it's the same pod) β†’ load Device β†’ createViewerTransport β†’ connectConsumerTransport (DTLS) β†’ consume (creates paused Consumers per producer, gated by canConsume) β†’ resumeConsumer β†’ client builds a MediaStream and renders. Signaling events are relayed pod-to-pod over the Socket.IO Redis adapter; the media path (WebRTC) always goes directly from the client to the SFU that owns the room.

Teardown: disconnect β†’ handleDisconnect closes transports/producers/consumers, removes the entry from memoryRoom, and clears the room's ownership entry from Redis.


🧰 Tech Stack

Layer Technology
Backend Node.js, TypeScript, Socket.IO
Cross-Pod Coordination Redis β€” Socket.IO adapter (pub/sub) + room-ownership registry
Media MediaSoup (WebRTC SFU) β€” one worker process per room, media plane in C++ off the event loop
Frontend React, Vite, mediasoup-client
Database MongoDB (Mongoose) β€” defined, not yet wired into runtime flow
NAT Traversal Coturn (TURN/STUN)
Proxy / Ingress ngrok (local) / DNS + managed ingress (cloud), Nginx, HAProxy (TCP), Envoy (UDP)
Media Processing FFmpeg (thumbnails now, recording planned)

πŸ“ž Contact

Built by Harshit Singh Parihar.

Low-latency, self-hosted live streaming β€” no third-party dependency.

About

CrowdStream enables low-latency peer to peer sessions with live chat, reactions, co-broadcasting, and viewer presence with no third-party streaming dependency, and no cloud vendor lock-in.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages