npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@mostajs/media-sfu

v0.1.0

Published

Selective Forwarding Unit (SFU) WebRTC pour l'écosystème @mostajs/* — basé sur mediasoup, signaling WHIP/WHEP standard W3C, scope MVP 1→N broadcast. Réutilisable par toute app Node.js.

Downloads

105

Readme

@mostajs/media-sfu

Auteur : Dr Hamid MADANI [email protected] License : AGPL-3.0-or-later Version : 0.1.0

SFU (Selective Forwarding Unit) WebRTC pour l'écosystème @mostajs/* — basé sur mediasoup, signaling WHIP (RFC 9725) et WHEP (draft IETF), réutilisable par toute application Node.js qui veut faire du live broadcast 1→N : cours en direct, conférence, événement, formation, supervision.

Scope v0.1.0 (MVP) : 1 publisher → N subscribers, WHIP ingest + WHEP egress, UDP+TCP, STUN public. Multi-router cross-browser intégré (Firefox + Chrome + Safari + Edge + Opera + smartphone). N→N (conference symétrique) et recording côté serveur reportés v0.2+.


Table des matières

  1. Pourquoi un SFU ? — Les 3 topologies WebRTC
  2. Architecture interne
  3. Quick start — how to use
  4. API détaillée
  5. Implémentation dans une app — how to impl
  6. Protocole WHIP / WHEP
  7. Cross-browser : multi-router et payload types
  8. Déploiement production
  9. Troubleshooting (bugs réels rencontrés)
  10. Limites v0.1.0 et roadmap
  11. Démo & examples
  12. Reuse dans d'autres apps

1. Pourquoi un SFU ? — Les 3 topologies WebRTC

Les 3 topologies P2P/MCU/SFU comparées

WebRTC offre 3 architectures pour faire passer du media entre N participants. Choisir la mauvaise = soit dépasser la bande passante client, soit cramer le CPU serveur, soit ne pas pouvoir scaler. Récap décisionnel :

| Topologie | Connexions | Upload client | Download client | CPU serveur | Sweet spot | |---|---|---|---|---|---| | P2P MESH | N×(N−1) | N flux | N flux | Faible | 2–4 pairs (visio intime) | | MCU (Multipoint Control Unit) | 2N | 1 flux | 1 flux (mixé) | Très élevé (mix+transcode) | Grande visio si CPU dispo | | SFU (Selective Forwarding Unit) | 2N | 1 flux | N flux | Faible (forward only) | 5–100 pairs, mainstream 2026 |

Pourquoi @mostajs/media-sfu est un SFU et pas un MCU :

  • Un MCU décompresse → mixe → recompresse en serveur : qualité supérieure mais 50–200× plus de CPU. Coût opérationnel insoutenable pour un projet sans hardware encoder dédié.
  • Un SFU forward bit à bit les paquets RTP des publishers vers les subscribers : zéro transcode, zéro re-encodage, scaling linéaire jusqu'à plusieurs centaines de participants par instance. C'est le standard industrie post-2024 (Google Meet, Jitsi Meet SFU mode, Twitch IVS, livekit, Zoom video, Riot.im, Element Call, etc.).

Trade-off SFU : le client doit télécharger N flux (un par publisher actif). Pour du 1→N broadcast (notre scope MVP), c'est 1 flux/client. Pour du N→N conference, scale en O(N²) côté bande passante client → simulcast + SVC requis (roadmap v0.2).

Pour le mode 2–4 pairs (mesh P2P), voir le module sœur @mostajs/media-p2p (signaling-only, pas d'infrastructure media). Pour le mode mix/transcode/enregistrement composite, voir @mostajs/media-mcu (ffmpeg-based).


2. Architecture interne

┌─────────────────────────────────────────────────────────────────┐
│                  @mostajs/media-sfu                              │
│                                                                  │
│  ┌──────────────────┐         ┌──────────────────────────────┐  │
│  │  Express / Next  │         │  mediasoup workers (C++)     │  │
│  │  Fastify / etc.  │         │                              │  │
│  │                  │         │  ┌─────────┐  ┌─────────┐    │  │
│  │  WHIP route ─────┼─────────┼─▶│ Router  │  │ Router  │    │  │
│  │  WHEP route ─────┼─────────┼─▶│ gecko   │  │libwebrtc│    │  │
│  │                  │         │  │(PT 109/ │  │(PT 111/ │    │  │
│  │  Apache/Nginx    │         │  │ 120)    │  │ 96)     │    │  │
│  │  TLS termination │         │  └────┬────┘  └────┬────┘    │  │
│  │                  │         │       │            │         │  │
│  └────────┬─────────┘         │       └── pipe ────┘         │  │
│           │                   │                              │  │
│           ▼                   └──────────────────────────────┘  │
│    HTTPS signaling                       UDP/TCP media           │
│    (WHIP/WHEP POST)                  (DTLS-SRTP, RTP/RTCP)      │
└─────────────────────────────────────────────────────────────────┘
            ▲                                  ▲
            │                                  │
       Browser publishers              Browser subscribers
       (Chrome, Firefox,               (Chrome, Firefox,
        Safari, Edge, Opera,            Safari, Edge, Opera,
        smartphone)                     smartphone)

Composants :

  • createSfuServer({ numWorkers, listenIps, minPort, maxPort }) : initialise les workers mediasoup (1 process C++ par CPU recommandé). Retourne un SfuServer avec API createRoom/getRoom/closeRoom/listRooms.
  • 2 routers par room (multi-router cross-browser, cf. §7) : un avec PTs Gecko (Firefox), un avec PTs libwebrtc (Chrome/Edge/Opera/Safari). pipeToRouter() forward un producer entre routers à la volée.
  • createSfuApiHandlers({ sfu, permissionChecker, auditLog }) : retourne 4 handlers Web-standard (Request → Response) à brancher dans Next/Express/Fastify : whip, whipClose, whep, whepClose.
  • Détection profile browser automatique depuis la SDP offer (regex sur o=mozilla + PT opus 109/111).
  • DTLS ICE-Lite côté serveur : mediasoup ne fait pas de connectivity checks sortants, le browser initie tout. Réduit la complexité réseau (pas de TURN nécessaire si serveur IP publique).

3. Quick start — how to use

Installation

npm install @mostajs/media-sfu

Node ≥ 18. mediasoup workers natifs C++ téléchargés au npm install (prebuilds), ou compilés localement si la combinaison kernel/arch n'a pas de prebuild (build prend ~3 min, nécessite python3-pip + ninja-build + g++).

Bootstrap minimal (Node http natif)

// server.mjs
import http from 'node:http'
import { createSfuServer, createSfuApiHandlers } from '@mostajs/media-sfu'

const sfu = await createSfuServer({
  numWorkers: 1,
  listenIps: [{ ip: '0.0.0.0', announcedIp: process.env.PUBLIC_IP ?? '127.0.0.1' }],
  minPort: 50000,
  maxPort: 50100,
  logLevel: 'warn',
  onEvent: ev => console.log('[sfu]', ev.type, ev.roomId, ev.peerId ?? ''),
})

await sfu.createRoom({ roomId: 'demo' })

const handlers = createSfuApiHandlers({
  sfu,
  permissionChecker: () => ({ user: 'anonymous' }), // pas d'auth en démo
  auditLog: ev => console.log('[api]', ev.method, ev.route, ev.status, `${ev.durationMs}ms`),
})

http.createServer(async (req, res) => {
  const path = new URL(req.url, `http://${req.headers.host}`).pathname
  const adapt = async (h, ctx) => {
    const body = req.method !== 'GET' ? await new Promise(r => {
      const c = []; req.on('data', d => c.push(d)); req.on('end', () => r(Buffer.concat(c)))
    }) : undefined
    const webReq = new Request(`http://x${req.url}`, { method: req.method, headers: req.headers, body })
    const webResp = await h(webReq, ctx)
    res.writeHead(webResp.status, Object.fromEntries(webResp.headers.entries()))
    res.end(Buffer.from(await webResp.arrayBuffer()))
  }
  let m
  if ((m = path.match(/^\/api\/sfu\/rooms\/([^/]+)\/whip$/)) && req.method === 'POST') return adapt(handlers.whip, { roomId: m[1] })
  if ((m = path.match(/^\/api\/sfu\/rooms\/([^/]+)\/whip\/([^/]+)$/)) && req.method === 'DELETE') return adapt(handlers.whipClose, { roomId: m[1], peerId: m[2] })
  if ((m = path.match(/^\/api\/sfu\/rooms\/([^/]+)\/whep$/)) && req.method === 'POST') return adapt(handlers.whep, { roomId: m[1] })
  if ((m = path.match(/^\/api\/sfu\/rooms\/([^/]+)\/whep\/([^/]+)$/)) && req.method === 'DELETE') return adapt(handlers.whepClose, { roomId: m[1], peerId: m[2] })
  res.writeHead(404); res.end()
}).listen(4555, () => console.log('SFU on http://localhost:4555'))

Côté browser — publisher (publish webcam)

<video id="local" autoplay muted playsinline></video>
<button id="start">Publish</button>
<script>
const ROOM = 'demo'
let pc, stream
document.getElementById('start').onclick = async () => {
  stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })
  document.getElementById('local').srcObject = stream

  pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] })
  for (const track of stream.getTracks()) pc.addTrack(track, stream)

  const offer = await pc.createOffer()
  await pc.setLocalDescription(offer)

  const resp = await fetch(`/api/sfu/rooms/${ROOM}/whip`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/sdp' },
    body: offer.sdp,
  })
  if (!resp.ok) return alert('WHIP failed: ' + await resp.text())
  const peerId = resp.headers.get('Location').split('/').pop()
  await pc.setRemoteDescription({ type: 'answer', sdp: await resp.text() })
  console.log('Publisher live, peerId=' + peerId)
}
</script>

Côté browser — viewer (subscribe & play)

<video id="remote" autoplay playsinline muted controls></video>
<button id="sub">Subscribe</button>
<script>
const ROOM = 'demo'
let pc
document.getElementById('sub').onclick = async () => {
  pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] })
  pc.addEventListener('track', e => {
    if (!remote.srcObject) remote.srcObject = e.streams[0]
  })
  pc.addTransceiver('audio', { direction: 'recvonly' })
  pc.addTransceiver('video', { direction: 'recvonly' })

  const offer = await pc.createOffer()
  await pc.setLocalDescription(offer)

  const resp = await fetch(`/api/sfu/rooms/${ROOM}/whep`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/sdp' },
    body: offer.sdp,
  })
  if (!resp.ok) return alert('WHEP failed: ' + await resp.text())
  await pc.setRemoteDescription({ type: 'answer', sdp: await resp.text() })
}
</script>

muted+controls obligatoire côté viewer : Chrome/Safari bloquent l'autoplay avec audio sans interaction utilisateur explicite. muted permet le démarrage immédiat, l'utilisateur unmute via les controls s'il veut le son.

Démo complète clé-en-main : voir examples/server.mjs + examples/publisher.html + examples/viewer.html (room list dynamique, logs debug, QR code mobile).


4. API détaillée

createSfuServer(opts) → Promise<SfuServer>

interface CreateSfuOptions {
  /** Nombre de workers mediasoup. Recommandation : 1 par CPU. Default : min(cpus, 4). */
  numWorkers?: number

  /** IPs d'écoute mediasoup pour les transports WebRTC. */
  listenIps: Array<{
    ip: string                  // bind IP (souvent '0.0.0.0' en prod)
    announcedIp?: string        // IP publique annoncée aux browsers (mandatory en prod derrière NAT)
  }>

  /** Range de ports UDP/TCP pour le RTP. À ouvrir au firewall. */
  minPort: number               // ex. 40000
  maxPort: number               // ex. 49999

  /** mediasoup log level. 'warn' en prod, 'debug' pour debugging DTLS/ICE. */
  logLevel?: 'debug' | 'warn' | 'error' | 'none'
  logTags?: Array<'ice'|'dtls'|'rtp'|'rtcp'|'srtp'>

  /** Callback events (room.created/closed, peer.joined/left). Optionnel pour audit/logging. */
  onEvent?: (ev: SfuEvent) => void | Promise<void>
}

interface SfuServer {
  createRoom(opts?: { roomId?: string }): Promise<Room>
  getRoom(roomId: string): Room | undefined
  closeRoom(roomId: string): Promise<void>
  listRooms(): RoomInfo[]
  close(): Promise<void>
}

interface Room {
  readonly id: string
  readonly createdAt: number
  publisherPeerId: string | null
  publisherProfile: 'gecko' | 'libwebrtc' | null
  publisher: Peer | null
  subscribers: Map<string, Peer>
  producers: Map<string, mediasoupTypes.Producer>
  /** Liste des routers par profile browser. */
  routers: Map<'gecko'|'libwebrtc', mediasoupTypes.Router>
}

interface RoomInfo {
  id: string
  createdAt: number
  publisherPeerId: string | null
  subscribersCount: number
  producersCount: number
}

createSfuApiHandlers(opts) → SfuApiHandlers

interface CreateSfuApiOptions {
  sfu: SfuServer

  /**
   * Vérifie l'autorisation de la requête. Appelé pour chaque WHIP/WHEP.
   * - Retourne `{ user: string }` pour autoriser
   * - Retourne `{ forbidden: Response }` pour rejeter (la Response sera renvoyée)
   * Permet de plug une auth maison (NextAuth, JWT, API key, etc.).
   */
  permissionChecker: (
    req: Request,
    ctx: { roomId: string; peerId?: string },
  ) => ApiPrincipal | Promise<ApiPrincipal>

  /** Callback pour logger les actions API (audit trail). */
  auditLog?: (ev: SfuApiEvent) => void | Promise<void>
}

type ApiPrincipal = { user: string } | { forbidden: Response }

interface SfuApiHandlers {
  whip(req: Request, ctx: { roomId: string }): Promise<Response>
  whipClose(req: Request, ctx: { roomId: string; peerId: string }): Promise<Response>
  whep(req: Request, ctx: { roomId: string }): Promise<Response>
  whepClose(req: Request, ctx: { roomId: string; peerId: string }): Promise<Response>
}

Les handlers consomment et retournent des Request / Response Web-standard (Fetch API). Compatibles out-of-the-box :

  • Next.js App Router (app/api/.../route.ts)
  • Cloudflare Workers, Deno, Bun, Hono, Itty Router
  • Express (besoin d'un mini adapter Node ↔ Fetch — voir §5)

Events

type SfuEvent =
  | { type: 'room.created'|'room.closed', roomId: string, timestamp: number }
  | { type: 'peer.joined'|'peer.left', roomId: string, peerId: string,
      details?: { kind: 'publisher'|'subscriber', profile?: 'gecko'|'libwebrtc',
                  producers?: number, consumers?: number },
      timestamp: number }

interface SfuApiEvent {
  route: string             // '/sfu/rooms/[roomId]/whip' etc.
  method: 'POST'|'DELETE'
  status: number
  user: string
  durationMs: number
  details?: any
}

5. Implémentation dans une app — how to impl

Next.js App Router

// app/api/sfu/rooms/[roomId]/whip/route.ts
import { createSfuApiHandlers } from '@mostajs/media-sfu/api'
import { getSfu } from '@/lib/sfu-bootstrap'
import { getServerSession } from 'next-auth'

let _handlers: ReturnType<typeof createSfuApiHandlers> | null = null
async function getHandlers() {
  if (_handlers) return _handlers
  _handlers = createSfuApiHandlers({
    sfu: await getSfu(),
    permissionChecker: async (req, ctx) => {
      const session = await getServerSession()
      if (!session) return { forbidden: new Response('Unauthorized', { status: 401 }) }
      return { user: session.user!.email! }
    },
    auditLog: ev => console.log('[audit]', ev),
  })
  return _handlers
}

export async function POST(req: Request, ctx: { params: Promise<{ roomId: string }> }) {
  const { roomId } = await ctx.params
  return (await getHandlers()).whip(req, { roomId })
}

Routes parallèles requises :

  • app/api/sfu/rooms/[roomId]/whip/route.tswhip (POST)
  • app/api/sfu/rooms/[roomId]/whip/[peerId]/route.tswhipClose (DELETE)
  • app/api/sfu/rooms/[roomId]/whep/route.tswhep (POST)
  • app/api/sfu/rooms/[roomId]/whep/[peerId]/route.tswhepClose (DELETE)

Express adapter (Node IncomingMessage → Web Request)

import express from 'express'
import { createSfuServer, createSfuApiHandlers } from '@mostajs/media-sfu'

async function toWebRequest(req: express.Request): Promise<Request> {
  const url = `http://${req.headers.host}${req.url}`
  const body = ['POST','PUT','PATCH'].includes(req.method!) ? await rawBody(req) : undefined
  return new Request(url, { method: req.method, headers: req.headers as any, body })
}
async function rawBody(req: express.Request): Promise<Buffer> {
  return new Promise((resolve) => {
    const chunks: Buffer[] = []; req.on('data', c => chunks.push(c))
    req.on('end', () => resolve(Buffer.concat(chunks)))
  })
}
async function sendWebResp(webResp: Response, res: express.Response) {
  res.status(webResp.status)
  for (const [k, v] of webResp.headers) res.setHeader(k, v)
  res.send(Buffer.from(await webResp.arrayBuffer()))
}

const sfu = await createSfuServer({ /* ... */ })
const handlers = createSfuApiHandlers({ sfu, permissionChecker: () => ({ user: 'anon' }) })
const app = express()

app.post('/api/sfu/rooms/:roomId/whip', async (req, res) => {
  const resp = await handlers.whip(await toWebRequest(req), { roomId: req.params.roomId })
  await sendWebResp(resp, res)
})
// ...idem pour whep, whipClose, whepClose

Multi-rooms et lifecycle

// API pour créer/supprimer des rooms à la demande
app.post('/api/sfu/rooms', async (req, res) => {
  const { roomId } = req.body
  const room = await sfu.createRoom({ roomId })
  res.json({ roomId: room.id, createdAt: room.createdAt })
})

app.delete('/api/sfu/rooms/:roomId', async (req, res) => {
  await sfu.closeRoom(req.params.roomId)
  res.json({ ok: true })
})

app.get('/api/sfu/rooms', (_, res) => {
  res.json({ rooms: sfu.listRooms() })
})

Auth + droits par room (pattern)

const handlers = createSfuApiHandlers({
  sfu,
  permissionChecker: async (req, ctx) => {
    const token = req.headers.get('authorization')?.replace(/^Bearer /, '')
    if (!token) return { forbidden: new Response('No token', { status: 401 }) }

    const user = await verifyJWT(token)
    if (!user) return { forbidden: new Response('Invalid token', { status: 401 }) }

    // Vérifier droits sur la room (publisher = enseignant, viewer = élève par ex.)
    const isPublisher = req.url.includes('/whip')
    if (isPublisher && !user.roles.includes('teacher')) {
      return { forbidden: new Response('Only teachers can publish', { status: 403 }) }
    }
    return { user: user.email }
  },
})

Audit / observabilité

const handlers = createSfuApiHandlers({
  sfu,
  permissionChecker: () => ({ user: 'anon' }),
  auditLog: async ev => {
    // Vers Sentry, ELK, Datadog, prometheus, etc.
    await prom.observe('sfu_api_duration_ms', ev.durationMs, { route: ev.route, status: String(ev.status) })
  },
})

// Et côté événements SFU :
await createSfuServer({
  // ...
  onEvent: async ev => {
    await prom.increment('sfu_' + ev.type.replace('.', '_'), { roomId: ev.roomId })
    if (ev.type === 'peer.joined' || ev.type === 'peer.left') {
      await db.insert('sfu_events', { ...ev, kind: ev.details?.kind })
    }
  },
})

6. Protocole WHIP / WHEP

WHIP (RFC 9725) et WHEP (draft IETF) sont des protocoles de signaling minimalistes standard W3C/IETF, soutenus par Meta, Google, Cloudflare, OBS. Pas de WebSocket, pas de Socket.IO : un seul POST HTTP avec body application/sdp.

WHIP — Ingest (publisher)

┌─────────────┐                                  ┌─────────────┐
│   Browser   │                                  │   SFU       │
│  publisher  │                                  │             │
└──────┬──────┘                                  └──────┬──────┘
       │                                                │
       │ 1. getUserMedia() → MediaStream                │
       │                                                │
       │ 2. RTCPeerConnection.addTrack(...)             │
       │    pc.createOffer() → SDP                      │
       │                                                │
       │ 3. POST /sfu/rooms/{id}/whip                   │
       │    Content-Type: application/sdp               │
       │    Body: <SDP offer>                           │
       │ ─────────────────────────────────────────────▶ │
       │                                                │ 4. parseWhipOffer
       │                                                │    transport = createWebRtcTransport
       │                                                │    transport.connect(dtls)
       │                                                │    for each track: transport.produce(...)
       │                                                │    composeWhipAnswer
       │                                                │
       │ 5. ◀ 201 Created                               │
       │     Location: /sfu/rooms/{id}/whip/{peerId}    │
       │     Body: <SDP answer>                         │
       │                                                │
       │ 6. pc.setRemoteDescription(answer)             │
       │                                                │
       │ 7. ICE + DTLS-SRTP handshake (UDP/TCP)         │
       │ ◀─────────────────────────────────────────────▶│
       │                                                │
       │ 8. RTP/RTCP media flow (browser → SFU)         │
       │ ─────────────────────────────────────────────▶ │
       │                                                │
       │ ... live ...                                   │
       │                                                │
       │ 9. DELETE /sfu/rooms/{id}/whip/{peerId}        │
       │ ─────────────────────────────────────────────▶ │
       │ ◀ 200 OK                                       │
       │                                                │

WHEP — Egress (subscriber)

Inverse symétrique de WHIP : POST avec offer recvonly, answer sendonly côté SFU.

   POST /sfu/rooms/{id}/whep        201
       SDP offer (recvonly) ───▶    SDP answer (sendonly)
                              ◀─── Location: /sfu/rooms/{id}/whep/{peerId}

   ICE/DTLS ↔
   RTP (SFU → browser) ─────▶

Avantages WHIP/WHEP vs signaling custom

| | WHIP/WHEP | Socket.IO / custom | |---|---|---| | Round-trips | 1 (POST) | 3–6 (offer/answer/iceCandidate split) | | Stateful | Non (HTTP REST) | Oui (WS connection) | | Reverse proxy compatible | ✅ Apache/Nginx natif | Headers WS, sticky session | | Auth | Authorization header standard | Custom over WS | | Adoption industrie | Google, Meta, Cloudflare, OBS, FFmpeg | Variable |

Codecs négociés (v0.1.0)

  • Audio : Opus 48 kHz stéréo (PT 109 sur gecko / 111 sur libwebrtc, cf. §7)
  • Vidéo : VP8 (PT 120 / 96)

H.264 retiré du MVP : matching profile-level-id varie selon device (Android baseline 42e01f, iOS 42001f, etc.) → instable cross-device. VP8 = no profile dependency, supporté Chrome/Firefox/Safari moderne (Safari 12.1+). v0.2 ajoutera H.264 multi-profile + VP9 + AV1.


7. Cross-browser : multi-router et payload types

Problème observé : aucun PT (payload type) n'est commun à toutes les familles de browsers pour les codecs dynamiques (opus, VP8) :

| Browser | opus PT | VP8 PT | Famille | |---|---|---|---| | Firefox / Gecko | 109 | 120 | gecko | | Chrome / Edge / Opera / Brave | 111 | 96 | libwebrtc | | Safari (macOS / iOS) | 111 | 96 / 97 | libwebrtc | | Chrome Android | 111 | 96 | libwebrtc |

Si l'answer SDP du SFU annonce opus PT=111 à un Firefox qui a offert 109 9 0 8 101 dans son audio m-line, Firefox désactive silencieusement la m-line (cf. Mozilla Bug 1288105). Symptôme : setRemoteDescription réussit mais ICE ne démarre jamais → "Connection failed".

Solution adoptée — multi-router avec pipeToRouter :

  1. À la création d'une room, on crée 2 routers mediasoup (un par famille), chacun avec son mediaCodecs aligné sur les PTs natifs du browser cible.
  2. Au WHIP, on détecte le profile du publisher depuis sa SDP offer (regex sur o=mozilla ou PT opus 109 vs 111) et on crée son producer dans le router correspondant.
  3. Au WHEP, on détecte le profile du viewer : si même profile que publisher → consume direct ; sinon → sourceRouter.pipeToRouter({ producerId, router: targetRouter, keepId: false }) lazy + cache, puis consume depuis le router cible.
  4. Le pipe est créé avec keepId: false : mediasoup génère un nouvel UUID pour le pipe producer, évitant un bug d'ID conflict observé en pratique (Channel request handler with ID xxx already exists).
  5. Pre-pipe à WHIP-time : dès qu'un publisher arrive, on pipe vers tous les autres routers immédiatement, pour éviter toute race au moment du premier WHEP.

Conséquence pour l'utilisateur : rien à faire, tout est transparent. Code applicatif identique pour publisher Chrome ou Firefox, viewer libre.

Détection profile (exposée)

import { detectBrowserProfile } from '@mostajs/media-sfu/lib/sdp'

const profile = detectBrowserProfile(sdpOffer)  // 'gecko' | 'libwebrtc'

8. Déploiement production

Variables d'environnement recommandées

# Public IP du serveur, annoncée dans les ICE candidates
SFU_ANNOUNCED_IP=212.132.109.40

# Range UDP/TCP pour le media. À ouvrir au firewall.
SFU_MIN_PORT=50000
SFU_MAX_PORT=50100

# Bind interface (0.0.0.0 = toutes interfaces)
SFU_LISTEN_IP=0.0.0.0

# Niveau log mediasoup
SFU_LOG_LEVEL=warn   # 'debug' pour analyser ICE/DTLS, attention au volume

Firewall

mediasoup utilise un range de ports UDP ET TCP (pour TCP candidates fallback). À ouvrir :

# UFW (Ubuntu/Debian) — ne suffit PAS chez certains hébergeurs (cf. note IONOS)
sudo ufw allow 50000:50100/udp
sudo ufw allow 50000:50100/tcp
sudo ufw reload

⚠️ Firewall externe (IONOS, Hetzner Cloud Firewall, AWS Security Groups...)

Très important : certains hébergeurs ont un firewall en amont de l'OS avec policy "deny all" par défaut. Ouvrir UFW ne suffit pas : les paquets sont droppés à l'hyperviseur.

Symptôme : tcpdump -i any 'udp portrange 50000-50100' sur la VM montre 0 paquet entrant alors que curl HTTPS fonctionne. Browser side : WebRTC: ICE failed, add a TURN server.

Cas IONOS : ouvrir la règle UDP dans le panel https://my.ionos.com → Server & Cloud → Network → Firewall Policies :

  • Protocol = UDP
  • Port = 50000-50100 (tiret accepté pour les ranges)
  • Allowed IP = 0.0.0.0/0

Documentation détaillée du cas + diagnostic complet : voir docs/INVESTIGATION-IONOS-UDP-BLOCK-15052026.md.

Apache reverse proxy (signaling HTTP, pas le media)

<VirtualHost *:443>
    ServerName studio.example.com
    SSLEngine on
    SSLCertificateFile /etc/letsencrypt/live/studio.example.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/studio.example.com/privkey.pem

    ProxyPreserveHost On
    ProxyPass / http://127.0.0.1:4555/
    ProxyPassReverse / http://127.0.0.1:4555/

    ErrorLog ${APACHE_LOG_DIR}/studio-error.log
    CustomLog ${APACHE_LOG_DIR}/studio-access.log combined
</VirtualHost>

<VirtualHost *:80>
    ServerName studio.example.com
    Redirect permanent / https://studio.example.com/
</VirtualHost>

HTTPS obligatoire : navigator.mediaDevices.getUserMedia() exige un contexte sécurisé (HTTPS ou localhost). Mobile : strictement HTTPS, pas de bypass possible.

Let's Encrypt (certbot)

sudo certbot --apache -d studio.example.com --non-interactive --agree-tos -m [email protected] --redirect

PM2

// ecosystem.config.cjs
module.exports = {
  apps: [{
    name: 'sfu',
    script: 'server.mjs',
    instances: 1,                // mediasoup gère lui-même les workers C++
    max_memory_restart: '500M',
    env: {
      NODE_ENV: 'production',
      PORT: '4555',
      SFU_LISTEN_IP: '0.0.0.0',
      SFU_ANNOUNCED_IP: '212.132.109.40',
      SFU_MIN_PORT: '50000',
      SFU_MAX_PORT: '50100',
    },
  }],
}
pm2 start ecosystem.config.cjs && pm2 save

STUN / TURN

  • STUN public suffit dans 95 % des cas (serveur a une IP publique routable + UDP entrant ouvert). mediasoup tourne en ICE-Lite : pas besoin de gathering côté serveur.
  • TURN uniquement nécessaire si certains clients sont derrière un NAT symétrique strict OU sur réseau d'entreprise/école qui bloque tout UDP entrant. Externaliser via coturn sur un host dédié, Twilio Network Traversal Service, ou Cloudflare Calls TURN.

9. Troubleshooting (bugs réels rencontrés)

WebRTC: ICE failed, add a TURN server (Firefox) OU Connection failed après quelques secondes

  1. Firewall externe hébergeur (IONOS, AWS SG...) bloque l'UDP entrant. Cf. §8.
  2. announcedIp mal configuré : doit être l'IP publique vue par les browsers, pas 127.0.0.1 ni l'IP interne. Vérifier via ip -4 addr show + sondage externe :
    node -e 'import("dgram").then(m => { const s = m.default.createSocket("udp4"); s.send("x", 50050, "<PUBLIC_IP>") })'
    # Sur le serveur : sudo tcpdump -i any -n 'udp port 50050'  → doit voir le paquet
  3. TURN nécessaire pour clients derrière NAT symétrique strict. Rare en réseau résidentiel, fréquent en réseau d'entreprise/école.

Connection failed après ICE = connected (DTLS échoue)

mediasoup attend que le browser soit DTLS client (après le SDP setup:passive). Si on passe dtlsParameters.role: 'auto' à transport.connect(), mediasoup peut choisir client aussi → deadlock (les deux côtés en client).

Fix : parseWhipOffer/parseWhepOffer force role: 'client' (= rôle du REMOTE browser). Déjà appliqué dans v0.1.0.

setRemoteDescription failed: order of m-lines doesn't match

L'answer doit suivre strictement l'ordre des m-lines de l'offer browser (audio puis video, ou inversement). composeWhepAnswer itère sur parsed.mediaSections (ordre offer) et émet une m-line rejected (port=0, direction inactive) si aucun consumer dispo. Déjà géré.

Answer changed id for extmap attribute (Firefox)

Les IDs des header extensions dans l'answer doivent matcher ceux de l'offer pour la même URI. parseWhepOffer extrait uri→id du browser, composeWhepAnswer filtre les ext supportées par le consumer en utilisant les IDs de l'offer. Déjà géré.

Video lente / quasi-figée mais audio OK (Chrome PC Linux notamment)

  1. Keyframe pas reçu à temps : consumer.requestKeyFrame() est appelé automatiquement après transport.consume() pour les consumers video. Si ça ne suffit pas → publisher doit envoyer plus de keyframes (réduire keyFrameRequestDelay côté capture).
  2. Hardware decode VP8 manquant sur Chrome Linux : ouvrir chrome://gpu → chercher "Video Decode". Si "Software only" → activer chrome://flags/#enable-accelerated-video-decode et restart. C'est un problème côté client, pas SFU.

Channel request handler with ID xxx already exists [method:transport.produce]

Bug observé lors d'un pipeToRouter avec keepId: true (default) : mediasoup tente de créer un pipe producer avec le même ID que le producer source dans le router cible → conflit. Fix : passer keepId: false pour générer un nouvel UUID. Déjà appliqué dans v0.1.0.

mLine[obj.push].forEach is not a function (sdp-transform writer)

L'attribut msid de la grammar sdp-transform est typé push: (array attendu). Si on passe une string, le writer crashe. Fix : msid: [{ id: 'sfu-stream', appdata: 'sfu-audio-0' }]. Déjà appliqué.

Smartphone Android : unsupported codec [mimeType:video/H264, payloadType:103]

H264 baseline 42e01f négocié par certains Android n'est pas dans nos mediaCodecs. Solution v0.1 : ne pas annoncer H264 du tout (router config) → browsers basculent sur VP8. v0.2 ajoutera H264 multi-profile.

Cache stale entre tests

Hard refresh navigation privée côté client (Ctrl+Shift+R) et redémarrer le SFU pour wiper l'état stale (pm2 restart sfu puis vérifier curl https://studio.example.com/api/sfu/rooms).


10. Limites v0.1.0 et roadmap

Limites MVP v0.1.0

  • 1 publisher / room : 2e WHIP refusé avec 409. N publishers (conference) = roadmap v0.2.
  • Codecs : Opus + VP8 uniquement. Pas de H264 / VP9 / AV1.
  • Pas de simulcast / SVC : chaque viewer reçoit le même flux qu'envoyé par le publisher. Pas d'adaptation bande passante.
  • Pas de recording côté serveur : pour enregistrer, hook un consumer ffmpeg via PlainTransport (pattern documenté v0.2).
  • Pas de TURN intégré : externaliser si besoin.
  • Pas de horizontal scaling : 1 process Node = 1 SFU. Sharding multi-process = v0.3 (PipeTransport entre instances).

Roadmap

  • v0.2 — N→N conference : queue de publishers, room.maxPublishers, gestion lifecycle multi-producer. Recording PlainTransport→ffmpeg. H264 multi-profile + VP9.
  • v0.3 — Simulcast 3 layers (low/medium/high). SVC VP9. Adaptive layer selection per consumer.
  • v0.4 — Horizontal scaling : sharding rooms entre N processes Node via PipeTransport.
  • v1.0 — Stable API freeze. Tests cross-browser CI (Playwright + selenium-grid).

11. Démo & examples

Dossier examples/ :

  • server.mjs : serveur démo Node http natif clé-en-main (60 lignes utiles)
  • index.html : page d'accueil démo avec QR code pour scanner sur mobile
  • publisher.html : webcam capture + WHIP, panneau logs debug intégré
  • viewer.html : liste dynamique des rooms actives + WHEP, panneau logs debug

Lancer la démo locale :

git clone https://github.com/apolocine/mosta-media-sfu.git
cd mosta-media-sfu
npm install   # télécharge mediasoup workers (ou compile localement, ~3 min)
node examples/server.mjs
# Ouvrir http://localhost:4555/ dans 2 tabs (publisher + viewer)

Pour tester depuis un smartphone, expose en HTTPS via tunnel :

# Cloudflare tunnel (gratuit, anonyme)
cloudflared tunnel --url http://localhost:4555
# → https://xxx-yyy-zzz.trycloudflare.com
# Scanner le QR code de la page d'accueil sur ton smartphone

⚠️ Cloudflare tunnel forward uniquement HTTP/HTTPS (le signaling). Le media UDP/TCP doit transiter directement vers ta machine : ouvre le firewall local OU déploie sur un VPS public.

Démo live publique

L'instance de référence tourne sur https://studio.amia.fr/. Architecture déployée : Apache HTTPS Let's Encrypt → PM2 → mediasoup, hébergée sur VPS IONOS avec firewall UDP 50000-50100 ouvert.


12. Reuse dans d'autres apps

Pattern : module-as-a-service

// lib/sfu.ts
import { createSfuServer, createSfuApiHandlers, type SfuServer } from '@mostajs/media-sfu'

let _sfu: SfuServer | null = null
let _handlers: ReturnType<typeof createSfuApiHandlers> | null = null

export async function getSfu() {
  if (_sfu) return _sfu
  _sfu = await createSfuServer({ /* config from env */ })
  return _sfu
}

export async function getHandlers() {
  if (_handlers) return _handlers
  _handlers = createSfuApiHandlers({ sfu: await getSfu(), permissionChecker: /* ... */ })
  return _handlers
}

Apps cibles (écosystème @mostajs)

  • iquesta-formation (live classes) : consumer principal v0.2, LiveSessionSchema côté DB pour orchestrer cohorts + accès.
  • orphin / iquesta (Q&A en direct) : intégration progressive comme transport pour les sessions live.
  • orphan-care : webinars asso, séances thérapeutiques 1→N.

Patterns d'intégration croisée

  • @mostajs/auth : permissionChecker consomme directement la session/JWT.
  • @mostajs/rbac : check rôle publisher vs viewer (teacher/student, animator/attendee, etc.).
  • @mostajs/audit : auditLog callback alimente la timeline de traces.
  • @mostajs/storage : pour stocker les enregistrements (v0.2).

Contributing

PR bienvenues. Discussions architecturales : ouvrir un GitHub Discussion avant de coder une feature majeure. Bug reports : reproductible + log SFU + browser console + version mediasoup.

git clone https://github.com/apolocine/mosta-media-sfu.git
cd mosta-media-sfu
npm install
npm run build     # tsc
node examples/server.mjs

License : AGPL-3.0-or-later Auteur : Dr Hamid MADANI [email protected]