SFU server built for EmulatorJS Netplay (mediasoup + socket.io).
This repo now includes a Docker build so you can run the SFU as a container.
- Build:
docker build -t emulatorjs-sfu . - Run (example):
docker run --rm -p 3001:3001 -p 20000:20000/udp -p 20000:20000/tcp emulatorjs-sfu
There is also a ready-to-copy compose file: docker-compose.example.yml.
This SFU is "secure by default": auth is always required.
The SFU does not connect to Redis/Valkey directly. Instead, it calls back into RomM's internal SFU API to validate JWT/JTI and to store/resolve room registry records.
You only need to provide:
ROMM_API_BASE_URL(RomM base URL reachable from the SFU container, e.g.http://romm:8080)ROMM_SFU_INTERNAL_SECRET(shared secret for SFU->RomM calls; must NOT beROMM_AUTH_SECRET_KEY)
Everything else has sensible defaults (ports, takeover grace window, STUN default).
Example:
ROMM_API_BASE_URL=http://romm:8080 ROMM_SFU_INTERNAL_SECRET=... docker compose up -d
- Signaling (HTTP + socket.io):
PORT(default3001) - WebRTC media (mediasoup WebRtcServer):
WEBRTC_PORT(default20000)
When USE_WEBRTC_SERVER is enabled (default), all WebRTC transports share the same UDP
listening port. This lets you open a single UDP port in your firewall/NAT instead of a range.
USE_WEBRTC_SERVER(default1): set to0to disable WebRtcServer and fall back to per-transport listen IPs.LISTEN_IP(default0.0.0.0): bind address for WebRtcServer.ANNOUNCED_IP: public IP/hostname to advertise in ICE candidates.- If clients connect over the public internet and the SFU is behind NAT (typical home server / VPS behind a proxy), you almost always need this.
- If all clients are on the same LAN and can reach the SFU directly by its LAN IP, you can usually omit it.
SFU_STUN_SERVERS(optional): STUN servers for clients, comma/space separated (example:stun.l.google.com:19302,stun1.example.com:3478). If unset, defaults tostun.l.google.com:19302.- TURN servers (optional): configure via either
SFU_TURN_SERVERS(recommended) or numbered env vars.- Recommended (arbitrary count):
SFU_TURN_SERVERSas JSON array of RTCIceServer objects. Example:SFU_TURN_SERVERS=[{"urls":["turn:turn.example.com:3478?transport=udp","turn:turn.example.com:3478?transport=tcp"],"username":"user","credential":"pass"}]
- Simple fallback (up to 4):
SFU_TURN_SERVER1=turn:turn.example.com:3478?transport=udpSFU_TURN_USER1=userSFU_TURN_PASS1=pass- Repeat for
2..4. Notes:
- TURN credentials are delivered to clients as part of the WebRTC config. Prefer short-lived credentials (TURN REST API) if possible.
- Recommended (arbitrary count):
WEBRTC_PORT(default20000): shared WebRTC media port (UDP + TCP).WEBRTC_UDP_PORT(optional): override UDP port (defaults toWEBRTC_PORT).ENABLE_WEBRTC_TCP(default1): set to0to disable TCP candidates.WEBRTC_TCP_PORT(optional): override TCP port (defaults toWEBRTC_PORT).RTC_MIN_PORT/RTC_MAX_PORT(defaults20000/20200): mediasoup worker RTC port range. Not relevent when using WebRtcServer
These are intentionally opt-in: the SFU is client-driven and does not force what the host sends. They exist to help operators detect/deny unexpected client behavior in mixed-client deployments.
SFU_EXPECT_VP9_SVC_MODE(optional): if set, compare VP9scalabilityModeagainst this value (example:L2T3).SFU_ENFORCE_VP9_SVC_MODE(default0): set to1to reject VP9 producers whosescalabilityModedoesn't matchSFU_EXPECT_VP9_SVC_MODE.SFU_ENFORCE_2_LAYER_SIMULCAST(default0): set to1to reject simulcast producers that publish anything other than 2 encodings.
RomM netplay data channels are expected to be binary only.
SFU_REQUIRE_BINARY_DATA_CHANNEL(default1): set to0to allow text messages over data channels. When enabled, any dataProducer that sends a text/non-binary payload is immediately closed.
This server can run as multiple SFU nodes (horizontal scaling) using a RomM-backed room registry. Each room is "sticky" to the node that created it.
How it works:
- When a room is opened, that node registers
room_name -> { url, nodeId }in RomM (with a TTL). - Other nodes can list rooms cluster-wide via
/listand resolve a room via/resolve?room=.... - If a client tries to
join-roomon a node that doesn't host the room, the server emitsroom-redirectand also returns{ redirect: <url> }via the join callback.
ROMM_API_BASE_URL: enable the registry (example:http://romm:8080)ROMM_SFU_INTERNAL_SECRET: secret for SFU->RomM internal API callsPUBLIC_URL: the public signaling URL for this node (example:https://sfu-2.example.com)NODE_ID(optional): stable id for this node (defaults to a random UUID)ROOM_REGISTRY_TTL_SECONDS(default60): refresh interval hint for re-upserts
- For best results, put your SFU nodes behind a load balancer for initial connections.
Once a client knows the room host, it should connect directly to that node's
PUBLIC_URL. - True cross-node media forwarding (a single room spanning multiple nodes) is not implemented here; this design distributes load by spreading rooms across nodes.
- Open
TCP PORT(default3001) to clients for signaling. - Open
UDP WEBRTC_UDP_PORT(default20000) to clients for media. - If you keep TCP candidates enabled, also open
TCP WEBRTC_TCP_PORT.
Common gotcha: if gameplay works on your LAN but fails for remote clients, set ANNOUNCED_IP to the public IP/hostname that remote clients use to reach you.
When SFU auth is enabled, the server allows an immediate "takeover" reconnect for the
same authenticated userid even if the previous Socket.IO connection is still alive.
This avoids spurious userid in use errors during fast network transitions.
-
SFU_ALLOW_AUTH_TAKEOVER(default1): set to0to disable takeover and enforce a strict single active Socket.IO connection peruserid. -
SFU_AUTH_TAKEOVER_GRACE_SECONDS(default30): when takeover is enabled, only allow it if the existing connection looks "stale" and the overlap is recent. This helps avoid surprise takeovers when the same account is used from another device. Set to0to remove the grace window.
The SFU talks to RomM using a shared secret header:
- Header:
x-romm-sfu-secret: <secret> - Secret:
ROMM_SFU_INTERNAL_SECRET