Skip to content

Latest commit

Β 

History

92 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

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


🎬 Demo

Live session, verified across devices and networks β€” desktop broadcaster (left) streaming to a mobile viewer over 4G (right), same session ID on both ends.

CrowdStream cross-device demo


πŸ“– 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:

🧩 Multi-Pod Signaling

Viewers can join a room whose mediasoup Router lives on a different pod than the one they're connected to, and the system transparently forwards the relevant calls to the owning pod and relays the response back β€” this path is implemented and working for joinRoom, createViewerTransport, connectConsumerTransport, consume, pauseConsumer, resumeConsumer, and viewer heartbeats.

How it works:

  1. Room ownership (roomId β†’ nodeId) is stored in Redis at room creation, keyed off each pod's config.instanceId.
  2. When a viewer's request lands on a non-owning pod, that pod doesn't reject it β€” it packages the call into a PodCommandPayload (a typed request with a requestId, the original args, and a replyTo channel scoped to itself), publishes it to the owning pod over Redis, and registers a pending entry (with a 5-second timeout) in an in-memory podRequestHandleMap keyed by requestId.
  3. The owning pod executes the actual mediasoup call locally and publishes the result back on the replyTo channel; the originating pod's pending entry resolves, and the viewer's ack() fires with the real result β€” the viewer's browser never needs to know a different pod handled it.
  4. If the owning pod doesn't respond within 5 seconds, or the cross-pod publish reaches zero receivers, the request fails explicitly (CROSS_POD_TIMEOUT / CROSS_POD_UNREACHABLE) rather than hanging.

Host vs. co-broadcaster: the room's primary host always creates the room on their own pod via createRoom, so the host is the owning pod by construction β€” cross-pod forwarding for createBroadcasterTransport/connectBroadcasterTransport/produce is never needed on the host path. It is needed for a co-broadcaster joining a room already owned by a different pod β€” that path currently checks ownership and explicitly rejects with 409 Room belongs to another node rather than forwarding, marked as a TODO in the handler.

πŸ—„οΈ Persistence (MongoDB)

MongoDB is wired into the live request path, write-through on every state change β€” not read from yet to gate behavior (Redis is still the source of truth for live routing decisions; nothing currently queries Mongo before letting a join or produce proceed).

  • LiveRoom β€” one document per room, created on createRoom with sfuNodeId set to the owning pod's config.instanceId (the same value used for the Redis ownership registry), totalViewersJoined incremented on every successful join.
  • Broadcaster β€” one document per broadcaster session, with hashed IP/user-agent (ipHash, userAgentHash β€” not raw values), and transportIds/producerIds appended as transports and producers are created.
  • Viewer β€” one document per viewer session, with the same hashed identifiers, transportIds and consumerIds appended as they're created.

All writes are fire-and-forget (void Model.create(...).catch(...)) off the critical path β€” persistence failures are logged but never block or fail the underlying signaling response.

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.

⚠️ Local setup requirement: Coturn needs to advertise the machine's actual public-facing IPv4 (its external-ip config) for ICE candidates to resolve correctly. If the machine running the server is on a mobile hotspot, the visible public IP can be behind carrier-grade NAT or change between sessions β€” in that case, TURN relay candidates get advertised with the wrong address, and clients on a different network than the host will fail to connect even though everything looks configured correctly. Bind Coturn's external-ip to a stable, reachable public IPv4 (a static IP where available, or the actual current public IP if not) before testing across networks. This is a local/self-hosted-topology concern only β€” a cloud deployment behind a proper NLB/TURN relay (see Deployment Topology) doesn't have this problem, since the relay's public address is stable by design.

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) β€” write-through persistence for rooms, broadcasters, and viewers; not yet read from on the live request path
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