@mostajs/media-p2p
v0.1.0
Published
P2P mesh signaling pour l'écosystème @mostajs/* — échange SDP+ICE entre 2-4 pairs qui se connectent en WebRTC direct. Pas de SFU server : zéro infra média, latence minimale.
Maintainers
Readme
@mostajs/media-p2p
Auteur : Dr Hamid MADANI [email protected] License : AGPL-3.0-or-later
P2P mesh signaling pour l'écosystème @mostajs/* — échange SDP+ICE entre 2-4 pairs qui se connectent en WebRTC peer-to-peer direct. Pas de SFU server : zéro infrastructure média, latence minimale (1 hop network).
Scope v0.1.0 : signaling layer (SSE + HTTP POST) pour découverte de pairs et échange SDP/ICE. Le media RTP transite directement entre browsers, le serveur ne voit que la signalisation.
Quand utiliser ce module vs SFU vs MCU ?
| Architecture | Pairs max | Infra serveur | Latence | Cas d'usage |
|---|---|---|---|---|
| @mostajs/media-p2p ← ici | 2-4 | Signaling seulement (~10 MB RAM) | Minimale (P2P direct) | Visio support 1-1, petite réunion, hotline |
| @mostajs/media-sfu | 5-100 | Workers mediasoup C++ + bandwidth montant | <1s | Cours live, conf, broadcast event |
| @mostajs/media-mcu | N→1 composite | ffmpeg CPU intensif | 1-3s | Régie 1 flux, archivage, broadcast régie |
Règle simple : si N ≤ 4 et pas besoin de recording serveur → P2P. Sinon SFU. MCU = cas spécial composite output.
Quick start (how to use)
Installation
npm install @mostajs/media-p2pPas de dépendances natives — pure JavaScript.
Bootstrap
// lib/p2p-bootstrap.ts
import { createP2pSignaling } from '@mostajs/media-p2p/server'
export const signaling = createP2pSignaling({
roomTtlSeconds: 3600, // close room after 1h inactivity
maxPeersPerRoom: 4, // P2P n'est efficace que jusqu'à 4
onEvent: (ev) => console.log('[p2p]', ev),
})Routes Next.js
// app/api/p2p/rooms/[roomId]/peers/route.ts
import { createP2pApiHandlers } from '@mostajs/media-p2p/api'
import { signaling } from '@/lib/p2p-bootstrap'
const handlers = createP2pApiHandlers({ signaling, permissionChecker: yourAuth })
// SSE long-poll pour recevoir les events (peers joined, SDP, ICE)
export const GET = (req: Request, ctx: any) => handlers.peerEvents(req, ctx.params)
// Join the room
export const POST = (req: Request, ctx: any) => handlers.peerJoin(req, ctx.params)
// Leave
export const DELETE = (req: Request, ctx: any) => handlers.peerLeave(req, ctx.params)// app/api/p2p/rooms/[roomId]/peers/[peerId]/signal/route.ts
// Envoyer SDP offer/answer ou ICE candidates à un peer spécifique
export const POST = (req: Request, ctx: any) => handlers.peerSignal(req, ctx.params)Côté browser
<script type="module">
async function joinRoom(roomId, myPeerId) {
// 1. Open SSE pour recevoir events
const es = new EventSource(`/api/p2p/rooms/${roomId}/peers?peerId=${myPeerId}`)
// 2. Announce join
await fetch(`/api/p2p/rooms/${roomId}/peers`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ peerId: myPeerId }),
})
// 3. Quand un autre peer rejoint → créer RTCPeerConnection + offer
const peers = new Map() // remotePeerId → RTCPeerConnection
es.addEventListener('peer.joined', async (e) => {
const { peerId: remoteId } = JSON.parse(e.data)
if (remoteId === myPeerId) return
const pc = createPeerConnection(remoteId, roomId, myPeerId)
peers.set(remoteId, pc)
// Add local tracks
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })
stream.getTracks().forEach(t => pc.addTrack(t, stream))
// Create offer
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
await sendSignal(roomId, remoteId, myPeerId, { type: 'offer', sdp: offer.sdp })
})
es.addEventListener('signal', async (e) => {
const { from, payload } = JSON.parse(e.data)
let pc = peers.get(from)
if (!pc) { pc = createPeerConnection(from, roomId, myPeerId); peers.set(from, pc) }
if (payload.type === 'offer') {
await pc.setRemoteDescription(payload)
const answer = await pc.createAnswer()
await pc.setLocalDescription(answer)
await sendSignal(roomId, from, myPeerId, { type: 'answer', sdp: answer.sdp })
} else if (payload.type === 'answer') {
await pc.setRemoteDescription(payload)
} else if (payload.candidate) {
await pc.addIceCandidate(payload.candidate)
}
})
es.addEventListener('peer.left', (e) => {
const { peerId } = JSON.parse(e.data)
peers.get(peerId)?.close()
peers.delete(peerId)
})
}
function createPeerConnection(remoteId, roomId, myPeerId) {
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
})
pc.onicecandidate = (e) => {
if (e.candidate) sendSignal(roomId, remoteId, myPeerId, { candidate: e.candidate })
}
pc.ontrack = (e) => {
// Render remote stream
const video = document.getElementById('video-' + remoteId)
if (video) video.srcObject = e.streams[0]
}
return pc
}
async function sendSignal(roomId, to, from, payload) {
await fetch(`/api/p2p/rooms/${roomId}/peers/${to}/signal`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from, payload }),
})
}
</script>API publique
Server bootstrap
import { createP2pSignaling, type P2pSignaling } from '@mostajs/media-p2p/server'
interface CreateP2pSignalingOptions {
roomTtlSeconds?: number // default 3600
maxPeersPerRoom?: number // default 4
onEvent?: (event: P2pEvent) => void
}
interface P2pSignaling {
createRoom(opts?: { roomId?: string }): Room
joinRoom(roomId: string, peerId: string): void
leaveRoom(roomId: string, peerId: string): void
// Forward signal from peerA to peerB inside the same room
forwardSignal(roomId: string, fromPeerId: string, toPeerId: string, payload: unknown): void
// SSE event stream pour un peer
subscribeEvents(roomId: string, peerId: string): AsyncIterable<P2pEvent>
close(): Promise<void>
}API handlers (Web-standard)
interface P2pApiHandlers {
peerEvents: (req: Request, ctx: { roomId: string }) => Promise<Response> // SSE
peerJoin: (req: Request, ctx: { roomId: string }) => Promise<Response>
peerLeave: (req: Request, ctx: { roomId: string; peerId: string }) => Promise<Response>
peerSignal: (req: Request, ctx: { roomId: string; peerId: string }) => Promise<Response>
}Pourquoi P2P et pas SFU ?
Avantages :
- Latence minimale : 1 seul hop network (peer→peer direct)
- Zéro infra média : juste un serveur de signaling lightweight (~5MB RAM par 100 rooms)
- Coût bandwidth serveur : nul (le media ne transite jamais par le serveur)
Limites :
- Bandwidth client : chaque peer doit envoyer N-1 streams en upload simultanément. 4 peers @ 720p → 3 × 1 Mbps = 3 Mbps upload chez chaque peer. Au-delà de 4 pairs, la plupart des connexions résidentielles saturent.
- CPU client : chaque peer encode N-1 streams (multi-encoding gourmand mobile/low-end)
- NAT traversal : besoin de STUN, parfois TURN si NAT strict (peut blocker P2P)
Verdict : 2-4 pairs OK, 5+ → bascule vers SFU.
Roadmap
- v0.1.0 : signaling SSE + HTTP POST, room/peer lifecycle ← current
- v0.2.0 : WebSocket alternative à SSE (bidirectionnel sans long-poll)
- v0.3.0 : helper TURN config + integration coturn
Comparaison @mostajs/media-* (résumé)
| Module | Quand l'utiliser | Doc |
|---|---|---|
| @mostajs/media-p2p | ≤ 4 pairs, latence critique, infra minimale | ici |
| @mostajs/media-sfu | 5-100 pairs, broadcast 1→N (live cours/event) | mosta-media-sfu/README.md |
| @mostajs/media-mcu | Composite 1 flux output (régie, archivage HLS) | mosta-media-mcu/README.md |
Voir aussi MEDIA-ARCHITECTURE.md (à créer) pour le guide de choix complet.
