@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
Maintainers
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
- Pourquoi un SFU ? — Les 3 topologies WebRTC
- Architecture interne
- Quick start — how to use
- API détaillée
- Implémentation dans une app — how to impl
- Protocole WHIP / WHEP
- Cross-browser : multi-router et payload types
- Déploiement production
- Troubleshooting (bugs réels rencontrés)
- Limites v0.1.0 et roadmap
- Démo & examples
- Reuse dans d'autres apps
1. Pourquoi un SFU ? — Les 3 topologies WebRTC

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 unSfuServeravec APIcreateRoom/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-sfuNode ≥ 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.
mutedpermet 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.ts→whip(POST)app/api/sfu/rooms/[roomId]/whip/[peerId]/route.ts→whipClose(DELETE)app/api/sfu/rooms/[roomId]/whep/route.ts→whep(POST)app/api/sfu/rooms/[roomId]/whep/[peerId]/route.ts→whepClose(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, whepCloseMulti-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 :
- À la création d'une room, on crée 2 routers mediasoup (un par famille), chacun avec son
mediaCodecsaligné sur les PTs natifs du browser cible. - Au WHIP, on détecte le profile du publisher depuis sa SDP offer (regex sur
o=mozillaou PT opus 109 vs 111) et on crée son producer dans le router correspondant. - 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. - 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). - 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 volumeFirewall
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 oulocalhost). 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] --redirectPM2
// 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 saveSTUN / 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
- Firewall externe hébergeur (IONOS, AWS SG...) bloque l'UDP entrant. Cf. §8.
announcedIpmal configuré : doit être l'IP publique vue par les browsers, pas127.0.0.1ni l'IP interne. Vérifier viaip -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- 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)
- Keyframe pas reçu à temps :
consumer.requestKeyFrame()est appelé automatiquement aprèstransport.consume()pour les consumers video. Si ça ne suffit pas → publisher doit envoyer plus de keyframes (réduirekeyFrameRequestDelaycôté capture). - Hardware decode VP8 manquant sur Chrome Linux : ouvrir
chrome://gpu→ chercher "Video Decode". Si "Software only" → activerchrome://flags/#enable-accelerated-video-decodeet 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 mobilepublisher.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,
LiveSessionSchemacô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:permissionCheckerconsomme directement la session/JWT.@mostajs/rbac: check rôle publisher vs viewer (teacher/student, animator/attendee, etc.).@mostajs/audit:auditLogcallback 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.mjsLicense : AGPL-3.0-or-later Auteur : Dr Hamid MADANI [email protected]
